@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/README.md CHANGED
@@ -4,7 +4,7 @@ A self-hosted git forge, GitHub-shaped: repository browsing, in-browser editing,
4
4
 
5
5
  Mochi Forge is usually shortened to *mochi*, which is how the command, the npm package, and the vault's own files are spelled.
6
6
 
7
- Reading is anonymous, unless a repository is made private, in which case it is visible only to its collaborators, its collection's owners, and site admins. Every write is authorized by a token, and users are created by an administrator rather than registering themselves. Permissions are GitHub-shaped: a user owns the collection named after them, repositories take collaborators with read, write, or admin roles, and one site-admin bit runs the vault.
7
+ Reading is anonymous, unless a repository is made private, in which case it is visible only to its collaborators, its collection's owners, and site admins. Every write is authorized by a token, and users are created by an administrator rather than registering themselves. Permissions are GitHub-shaped: a user owns the collection named after them, repositories take collaborators with read, write, or admin roles, and one site-admin bit runs the vault. Signing in on the web takes the token once; after that a passkey signs in with a touch, a short code carries a session to another device, and `mochi web` opens a signed-in browser from the terminal.
8
8
 
9
9
  ## Try it locally
10
10
 
@@ -39,8 +39,9 @@ The same command deploys updates. See [Deploying a vault](docs/deploying.md) for
39
39
  - **Editing in the browser:** files, uploads, branches, tags, repositories, collections, forks, users. Controls a token cannot use are not shown.
40
40
  - **Issues and pull requests,** stored as markdown in the vault. Merge or squash, refused on conflicts.
41
41
  - **Releases** tied to a tag, with Atom feeds.
42
+ - **Forking from GitHub:** `mochi fork` imports a repository and records its upstream, `mochi sync` fast-forwards from it, and `mochi pr export` sends a pull request made here on to GitHub as one of yours. All three run on your machine, through your own git and `gh` credentials; the vault holds no GitHub token.
42
43
  - **Workflows:** GitHub Actions workflows, planned by the server and run by a Docker runner you start elsewhere, including one deployed to Fly.io with a command, which stops when idle and is woken by the vault when a job is queued. A job marked `runs-on: manual` instead waits for a command you paste on a machine of your choosing, which shows the steps and asks before executing them.
43
- - **Sites:** a static site per repository, sandboxed by default, optionally on its own hostname.
44
+ - **Sites:** an opt-in static site per repository, sandboxed by default, optionally on its own hostname or a custom domain.
44
45
  - **Git:** anonymous clone over smart HTTP for public repositories and authenticated clone for private ones, token-authenticated push including push-to-create, and LFS to S3 or to the vault.
45
46
  - **CLI and JSON API** covering everything the web UI does, plus a generic `mochi api`. Built for scripts: `--json` everywhere, distinct exit codes, no prompts.
46
47
 
@@ -70,17 +71,17 @@ No database, no state outside the directory. Backup is `cp -a`, migration is `rs
70
71
  - [The command line](docs/cli.md) - the `mochi` command and its subcommands
71
72
  - [The vault](docs/vault.md) - the on-disk layout, tokens, and sessions
72
73
  - [Deploying a vault](docs/deploying.md) | [Backing up a vault](docs/backup.md)
73
- - [Workflows](docs/workflows.md) | [Sites](docs/sites.md) | [Git LFS](docs/lfs.md) | [Themes](docs/themes.md) | [Issues and pull requests](docs/issues-and-pull-requests.md)
74
+ - [Workflows](docs/workflows.md) | [Sites](docs/sites.md) | [Git LFS](docs/lfs.md) | [Themes](docs/themes.md) | [Issues and pull requests](docs/issues-and-pull-requests.md) | [Encrypted files](docs/encrypted-files.md)
74
75
  - [The JSON API](docs/api.md) - every route, body, and response
75
76
  - [Mochi Forge for an agent](docs/agents.md) - short enough to paste into a context window
76
77
 
77
78
  ## Using a vault from Claude Code
78
79
 
79
- [mochi-skill](https://github.com/magland/mochi-skill) teaches an agent this CLI the way it already knows `gh`.
80
+ [mochiforge-skill](https://github.com/magland/mochiforge-skill) teaches an agent this CLI the way it already knows `gh`.
80
81
 
81
82
  ```
82
- /plugin marketplace add magland/mochi-skill
83
- /plugin install mochi@mochi-skill
83
+ /plugin marketplace add magland/mochiforge-skill
84
+ /plugin install mochi@mochiforge-skill
84
85
  ```
85
86
 
86
87
  The agent needs `mochi` on its PATH and either `MOCHI_HOST`/`MOCHI_TOKEN` or a completed `mochi login`.
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ // Age-encrypted files, as the web interface understands them.
3
+ //
4
+ // A file named `*.age` is treated as an age encryption (age-encryption.org)
5
+ // ciphertext: the blob page offers to decrypt it in the browser, and the web
6
+ // editor encrypts before committing, so the plaintext and the passphrase
7
+ // never reach the server. The extension is the whole contract, the way `.md`
8
+ // is: the vault stores and serves bytes it cannot read, and the same file
9
+ // decrypts with the standard `age` CLI anywhere else.
10
+ //
11
+ // The two spellings of the format are both honoured when reading: the binary
12
+ // framing, which begins with the version line, and the ASCII armor the spec
13
+ // defines (and the browser editor always writes, so its commits diff as
14
+ // text). What this module decides is only naming and framing; the cryptography
15
+ // itself happens in the reader's browser, in the vendored typage bundle (see
16
+ // src/vendor-age.ts), or in whatever age client the user prefers.
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.isAgeFile = isAgeFile;
19
+ exports.ageInnerName = ageInnerName;
20
+ exports.looksLikeAge = looksLikeAge;
21
+ /** Whether this path names an age ciphertext, by its extension. */
22
+ function isAgeFile(path) {
23
+ return /\.age$/i.test(path);
24
+ }
25
+ /**
26
+ * The name inside the encryption: `secrets.md.age` is a markdown file, and
27
+ * the viewer renders the decrypted text accordingly. A bare `x.age` has no
28
+ * inner extension and is shown as plain text.
29
+ */
30
+ function ageInnerName(path) {
31
+ return path.replace(/\.age$/i, '');
32
+ }
33
+ const BINARY_HEADER = Buffer.from('age-encryption.org/v1\n');
34
+ const ARMOR_HEADER = Buffer.from('-----BEGIN AGE ENCRYPTED FILE-----');
35
+ /**
36
+ * Whether these bytes begin the way an age ciphertext does, in either
37
+ * framing. This is what stands between a failed script and a plaintext
38
+ * commit: the web forms refuse to write a `*.age` path whose content is not
39
+ * age-shaped, because the likeliest way to produce one is a browser that
40
+ * never ran the encryption. It is a framing check, not a validation; a
41
+ * truncated or corrupted ciphertext still passes and fails at decryption,
42
+ * where it can be reported honestly.
43
+ */
44
+ function looksLikeAge(buf) {
45
+ return (buf.subarray(0, BINARY_HEADER.length).equals(BINARY_HEADER) ||
46
+ buf.subarray(0, ARMOR_HEADER.length).equals(ARMOR_HEADER));
47
+ }
@@ -0,0 +1,343 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ageScript = ageScript;
4
+ const crypto_1 = require("crypto");
5
+ const vendor_age_1 = require("./vendor-age");
6
+ // The script for pages that touch age-encrypted files: the blob view of a
7
+ // `*.age` file, the editor for one, and the new-file form (which may be given
8
+ // a `*.age` name). Served as /assets/age.js and loaded only by those pages,
9
+ // so the 300 KB of vendored cryptography is never fetched by a page that
10
+ // cannot need it; ordinary pages carry /assets/page.js alone.
11
+ //
12
+ // Everything here runs on the reader's side of the trust line. The server
13
+ // hands out ciphertext and receives ciphertext; the passphrase lives in a
14
+ // closure for exactly as long as the page needs it, is never written to
15
+ // storage of any kind, and the plaintext exists only in the DOM of this open
16
+ // page. That is the entire point of the feature, so nothing below may relax
17
+ // it. See docs/encrypted-files.md for the contract as the user sees it.
18
+ //
19
+ // Like page.js, everything page-specific arrives through data attributes
20
+ // (data-age-view, data-age-edit, data-age-new and their companions), so this
21
+ // file is one cacheable body for every vault. Unlike page.js it may assume a
22
+ // modern browser: WebCrypto is required by the cryptography itself, so there
23
+ // is nobody older to degrade for. The vendored bundle (src/vendor-age.ts)
24
+ // provides window.MochiAge (typage) and window.MochiMarkdownIt.
25
+ const GLUE_JS = `
26
+ (function () {
27
+ 'use strict';
28
+
29
+ var ARMOR_HEAD = '-----BEGIN AGE ENCRYPTED FILE-----';
30
+
31
+ function fetchCiphertext(url) {
32
+ return fetch(url).then(function (resp) {
33
+ if (!resp.ok) throw new Error('Could not fetch the file (HTTP ' + resp.status + ').');
34
+ return resp.arrayBuffer();
35
+ }).then(function (buf) { return new Uint8Array(buf); });
36
+ }
37
+
38
+ // Both framings are read: the armor the browser editor writes, and the
39
+ // binary framing an age CLI writes by default.
40
+ function decodeFramed(bytes) {
41
+ var head = '';
42
+ for (var i = 0; i < Math.min(bytes.length, ARMOR_HEAD.length); i++) head += String.fromCharCode(bytes[i]);
43
+ if (head === ARMOR_HEAD) return window.MochiAge.armor.decode(new TextDecoder().decode(bytes));
44
+ return bytes;
45
+ }
46
+
47
+ function decryptText(bytes, pass) {
48
+ var d = new window.MochiAge.Decrypter();
49
+ d.addPassphrase(pass);
50
+ return d.decrypt(decodeFramed(bytes), 'text');
51
+ }
52
+
53
+ // Armored output on purpose: it commits as text, so the vault's diffs,
54
+ // blame, and text-shaped write path all keep working over the ciphertext.
55
+ function encryptArmored(text, pass) {
56
+ var e = new window.MochiAge.Encrypter();
57
+ e.setPassphrase(pass);
58
+ return e.encrypt(text).then(function (bytes) { return window.MochiAge.armor.encode(bytes) + '\\n'; });
59
+ }
60
+
61
+ // Passphrase failures all look alike from the outside, and honesty about
62
+ // which it was is not available: age reports only that no recipient matched.
63
+ function explain(err) {
64
+ var msg = err && err.message ? String(err.message) : String(err);
65
+ if (msg.indexOf('no identity matched') !== -1) {
66
+ return 'That passphrase does not open this file. (A corrupted file reports the same way.)';
67
+ }
68
+ if (msg.indexOf('invalid header') !== -1 || msg.indexOf('parsing header') !== -1) {
69
+ return 'This is not a readable age file: ' + msg;
70
+ }
71
+ return msg;
72
+ }
73
+
74
+ function setError(box, msg) {
75
+ var el = box.querySelector('.age-error');
76
+ if (!el) return;
77
+ if (msg) { el.textContent = msg; el.hidden = false; } else { el.hidden = true; }
78
+ }
79
+
80
+ // A working button says so. The scrypt derivation takes long enough to feel,
81
+ // so while it runs the button shows the busy label the server put on it
82
+ // ('Decrypting...', 'Encrypting...') rather than reading as a dead click.
83
+ function setBtnBusy(btn, busy) {
84
+ if (!btn) return;
85
+ btn.disabled = busy;
86
+ var label = btn.getAttribute('data-busy-label');
87
+ if (!label) return;
88
+ if (busy) {
89
+ if (!btn.getAttribute('data-idle-label')) btn.setAttribute('data-idle-label', btn.textContent);
90
+ btn.textContent = label;
91
+ } else if (btn.getAttribute('data-idle-label')) {
92
+ btn.textContent = btn.getAttribute('data-idle-label');
93
+ }
94
+ }
95
+
96
+ function setBusy(box, busy) {
97
+ setBtnBusy(box.querySelector('.age-unlock button[type=submit]'), busy);
98
+ }
99
+
100
+ // A failed passphrase leaves the input focused and selected, so the retry
101
+ // is a retype rather than a clear-then-retype.
102
+ function failedPass(input) { input.focus(); input.select(); }
103
+
104
+ // Every passphrase input arrives wrapped with a show/hide toggle (the
105
+ // .age-eye button the server renders beside it). Only the input's type
106
+ // flips; the value still never carries a name, so it still cannot post.
107
+ function wireEyes() {
108
+ var wraps = document.querySelectorAll('.age-pass-wrap');
109
+ for (var i = 0; i < wraps.length; i++) (function (wrap) {
110
+ var input = wrap.querySelector('input');
111
+ var btn = wrap.querySelector('.age-eye');
112
+ if (!input || !btn) return;
113
+ btn.addEventListener('click', function () {
114
+ var show = input.type === 'password';
115
+ input.type = show ? 'text' : 'password';
116
+ wrap.classList.toggle('showing', show);
117
+ btn.setAttribute('aria-pressed', show ? 'true' : 'false');
118
+ btn.setAttribute('aria-label', show ? 'Hide the passphrase' : 'Show the passphrase');
119
+ input.focus();
120
+ });
121
+ })(wraps[i]);
122
+ }
123
+
124
+ // The plaintext to copy comes from the closure, not from the DOM, so it is
125
+ // exact bytes even where the output was rendered as a markdown document.
126
+ function copyPlain(btn, text) {
127
+ if (!navigator.clipboard || !navigator.clipboard.writeText) return;
128
+ navigator.clipboard.writeText(text).then(function () {
129
+ btn.classList.add('copied');
130
+ setTimeout(function () { btn.classList.remove('copied'); }, 1400);
131
+ }, function () {});
132
+ }
133
+
134
+ // ---- the blob page ----
135
+
136
+ // The decrypted file is rendered the way its inner name asks: markdown as a
137
+ // document, anything else as preformatted text. Rendering happens here
138
+ // rather than on the server for the same reason decryption does.
139
+ function renderPlain(out, inner, text) {
140
+ out.textContent = '';
141
+ if (inner === 'markdown') {
142
+ var md = window.MochiMarkdownIt({ html: false, linkify: true });
143
+ var doc = document.createElement('div');
144
+ doc.className = 'rendered markdown-body';
145
+ doc.innerHTML = md.render(text);
146
+ out.appendChild(doc);
147
+ } else {
148
+ var pre = document.createElement('pre');
149
+ pre.className = 'age-plain';
150
+ pre.textContent = text;
151
+ out.appendChild(pre);
152
+ }
153
+ out.hidden = false;
154
+ }
155
+
156
+ // On unlock the explanatory card gives way to a slim bar over the output:
157
+ // a note that the decryption stayed in the page, a copy of the exact
158
+ // plaintext, and the lock that puts the card back. Locking drops the
159
+ // plaintext from the closure as well as from the DOM.
160
+ function wireView(box) {
161
+ var form = box.querySelector('form.age-unlock');
162
+ var pass = form ? form.querySelector('input') : null;
163
+ var out = box.querySelector('.age-output');
164
+ var card = box.querySelector('.age-card');
165
+ var bar = box.querySelector('[data-age-bar]');
166
+ var lock = box.querySelector('[data-age-lock]');
167
+ var copy = box.querySelector('[data-age-copy]');
168
+ if (!form || !pass || !out || !card) return;
169
+ var plain = null;
170
+ form.addEventListener('submit', function (ev) {
171
+ ev.preventDefault();
172
+ setError(card, null);
173
+ setBusy(card, true);
174
+ fetchCiphertext(box.getAttribute('data-age-raw'))
175
+ .then(function (bytes) { return decryptText(bytes, pass.value); })
176
+ .then(function (text) {
177
+ plain = text;
178
+ pass.value = '';
179
+ renderPlain(out, box.getAttribute('data-age-inner'), text);
180
+ card.hidden = true;
181
+ if (bar) bar.hidden = false;
182
+ })
183
+ .catch(function (err) { setError(card, explain(err)); failedPass(pass); })
184
+ .then(function () { setBusy(card, false); });
185
+ });
186
+ if (lock) lock.addEventListener('click', function () {
187
+ plain = null;
188
+ out.textContent = '';
189
+ out.hidden = true;
190
+ if (bar) bar.hidden = true;
191
+ card.hidden = false;
192
+ pass.focus();
193
+ });
194
+ if (copy) copy.addEventListener('click', function () {
195
+ if (plain !== null) copyPlain(copy, plain);
196
+ });
197
+ pass.focus();
198
+ }
199
+
200
+ // ---- the editor ----
201
+
202
+ // The server cannot fill the textarea, so the page starts locked: the same
203
+ // unlock form as the blob view, and on success the plaintext lands in the
204
+ // editor and the passphrase stays in this closure for the re-encryption at
205
+ // commit time. The textarea carries no name; what the form posts is the
206
+ // hidden content field, written at the last moment with fresh ciphertext.
207
+ // An optional pair of new-passphrase inputs (also nameless) re-keys the
208
+ // file: filled and matching, the commit encrypts with the new passphrase.
209
+ function wireEdit(form) {
210
+ var passForm = document.querySelector('form.age-unlock[data-age-for-edit]');
211
+ var passInput = passForm ? passForm.querySelector('input') : null;
212
+ var card = passForm ? passForm.closest('.age-card') : null;
213
+ var editor = form.querySelector('textarea.code-editor');
214
+ var content = form.querySelector('input[name=content]');
215
+ var commitBtn = form.querySelector('button[type=submit]');
216
+ var path = form.querySelector('input[name=path]');
217
+ var renameWarn = form.querySelector('[data-age-rename-warn]');
218
+ var newPass = form.querySelectorAll('.age-newpass input');
219
+ if (!passForm || !passInput || !card || !editor || !content || !commitBtn) return;
220
+ var passphrase = null;
221
+ var readyToPost = false;
222
+ passForm.addEventListener('submit', function (ev) {
223
+ ev.preventDefault();
224
+ setError(card, null);
225
+ setBusy(card, true);
226
+ var pass = passInput.value;
227
+ fetchCiphertext(form.getAttribute('data-age-raw'))
228
+ .then(function (bytes) { return decryptText(bytes, pass); })
229
+ .then(function (text) {
230
+ passphrase = pass;
231
+ passInput.value = '';
232
+ editor.value = text;
233
+ editor.disabled = false;
234
+ commitBtn.disabled = false;
235
+ card.hidden = true;
236
+ form.hidden = false;
237
+ editor.focus();
238
+ })
239
+ .catch(function (err) { setError(card, explain(err)); failedPass(passInput); })
240
+ .then(function () { setBusy(card, false); });
241
+ });
242
+ // Renaming away from .age is legal but almost never meant: the commit
243
+ // would still be ciphertext, under a name the vault reads as plain text.
244
+ // The warning appears as the name changes, not as a surprise afterwards.
245
+ if (path && renameWarn) path.addEventListener('input', function () {
246
+ renameWarn.hidden = /\\.age$/i.test(path.value.trim());
247
+ });
248
+ form.addEventListener('submit', function (ev) {
249
+ if (readyToPost) return;
250
+ ev.preventDefault();
251
+ if (passphrase === null) return;
252
+ var usePass = passphrase;
253
+ if (newPass.length === 2 && (newPass[0].value !== '' || newPass[1].value !== '')) {
254
+ if (newPass[0].value !== newPass[1].value) {
255
+ setError(form, 'The new passphrases do not match.');
256
+ failedPass(newPass[1]);
257
+ return;
258
+ }
259
+ usePass = newPass[0].value;
260
+ }
261
+ setError(form, null);
262
+ setBtnBusy(commitBtn, true);
263
+ encryptArmored(editor.value, usePass)
264
+ .then(function (armored) {
265
+ content.value = armored;
266
+ readyToPost = true;
267
+ form.submit();
268
+ })
269
+ .catch(function (err) {
270
+ setBtnBusy(commitBtn, false);
271
+ setError(form, explain(err));
272
+ });
273
+ });
274
+ passInput.focus();
275
+ }
276
+
277
+ // ---- the new-file form ----
278
+
279
+ // The form is the ordinary one; a file name ending in .age reveals the
280
+ // passphrase pair (and retires the hint that said so) and switches the
281
+ // commit to encrypt-then-post. The passphrase inputs carry no name, so
282
+ // they can never be posted, and the server refuses a .age path whose
283
+ // content is not age-shaped, so a page where this script failed cannot
284
+ // commit plaintext under the name.
285
+ function wireNew(form) {
286
+ var filename = form.querySelector('input[name=filename]');
287
+ var fields = form.querySelector('.age-pass-fields');
288
+ var hint = form.querySelector('[data-age-hint]');
289
+ var editor = form.querySelector('textarea[name=content]');
290
+ var commitBtn = form.querySelector('button[type=submit]');
291
+ if (!filename || !fields || !editor) return;
292
+ var inputs = fields.querySelectorAll('.age-pass-wrap input');
293
+ if (inputs.length !== 2) return;
294
+ var readyToPost = false;
295
+ function wantsAge() { return /\\.age$/i.test(filename.value.trim()); }
296
+ function toggle() {
297
+ fields.hidden = !wantsAge();
298
+ if (hint) hint.hidden = wantsAge();
299
+ }
300
+ filename.addEventListener('input', toggle);
301
+ toggle();
302
+ form.addEventListener('submit', function (ev) {
303
+ if (readyToPost || !wantsAge()) return;
304
+ ev.preventDefault();
305
+ setError(fields, null);
306
+ if (inputs[0].value === '') { setError(fields, 'Choose a passphrase for this file.'); failedPass(inputs[0]); return; }
307
+ if (inputs[0].value !== inputs[1].value) { setError(fields, 'The passphrases do not match.'); failedPass(inputs[1]); return; }
308
+ setBtnBusy(commitBtn, true);
309
+ encryptArmored(editor.value, inputs[0].value)
310
+ .then(function (armored) {
311
+ inputs[0].value = '';
312
+ inputs[1].value = '';
313
+ editor.removeAttribute('name');
314
+ var hidden = document.createElement('input');
315
+ hidden.type = 'hidden';
316
+ hidden.name = 'content';
317
+ hidden.value = armored;
318
+ form.appendChild(hidden);
319
+ readyToPost = true;
320
+ form.submit();
321
+ })
322
+ .catch(function (err) { setBtnBusy(commitBtn, false); setError(fields, explain(err)); });
323
+ });
324
+ }
325
+
326
+ wireEyes();
327
+ var view = document.querySelector('[data-age-view]');
328
+ if (view) wireView(view);
329
+ var edit = document.querySelector('form[data-age-edit]');
330
+ if (edit) wireEdit(edit);
331
+ var fresh = document.querySelector('form[data-age-new]');
332
+ if (fresh) wireNew(fresh);
333
+ })();
334
+ `;
335
+ let made = null;
336
+ /** The served asset: the vendored bundle and the glue above, one body, one tag. */
337
+ function ageScript() {
338
+ if (made)
339
+ return made;
340
+ const body = vendor_age_1.VENDOR_AGE_JS + '\n' + GLUE_JS;
341
+ made = { body, tag: (0, crypto_1.createHash)('sha256').update(body).digest('hex').slice(0, 12) };
342
+ return made;
343
+ }
package/dist/api/admin.js CHANGED
@@ -38,9 +38,9 @@ const fs = __importStar(require("fs"));
38
38
  const config_1 = require("../config");
39
39
  const layout_1 = require("../layout");
40
40
  const ops_1 = require("../ops");
41
- const redirects_1 = require("../redirects");
42
41
  const scan_1 = require("../scan");
43
42
  const siteshost_1 = require("../siteshost");
43
+ const sitesettings_1 = require("../sitesettings");
44
44
  const themes_1 = require("../themes");
45
45
  const perms_1 = require("../perms");
46
46
  const vault_1 = require("../vault");
@@ -119,58 +119,90 @@ function registerAdminApi(app, root, limiter, lfs = null, engine, egress) {
119
119
  (0, auth_1.sendOpError)(res, e, 'could not rename the collection');
120
120
  }
121
121
  });
122
- // Only an empty one, and only a directory: a collection is a directory, so
123
- // removing it is an rmdir and refusing a non-empty one is the filesystem's own
124
- // rule rather than a policy invented here.
125
- app.delete('/api/collections/:name', (req, res) => {
122
+ /**
123
+ * A collection's site alias: the label standing in for its name in each of
124
+ * its repositories' derived site hostnames. Ownership of the collection, as
125
+ * the rename above takes, since it decides those hostnames and nothing else.
126
+ *
127
+ * `siteAlias` is the only field, `""` clearing it and falling back to the
128
+ * collection's own name where that is usable as a hostname label and its name
129
+ * rewritten as one otherwise. An alias another collection is already reached
130
+ * by is refused with 409, naming it, the same answer a claimed repository
131
+ * label gets.
132
+ */
133
+ app.patch('/api/collections/:name', (req, res) => {
126
134
  const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
127
135
  if (!auth)
128
136
  return;
129
137
  const name = req.params.name;
138
+ if (!(0, scan_1.isValidName)(name) || !isCollection(name)) {
139
+ (0, auth_1.apiError)(res, 404, `no collection ${name} in this vault`);
140
+ return;
141
+ }
130
142
  if (!(0, perms_1.canAdminCollection)(root, auth, name)) {
131
143
  (0, auth_1.apiError)(res, 403, `you are not an owner of ${name}`);
132
144
  return;
133
145
  }
134
- if (!(0, scan_1.isValidName)(name)) {
135
- (0, auth_1.apiError)(res, 400, 'that is not a usable collection name');
146
+ const alias = (0, auth_1.stringField)((0, auth_1.bodyOf)(req), 'siteAlias');
147
+ if (alias === null) {
148
+ (0, auth_1.apiError)(res, 400, 'nothing to change; provide "siteAlias"');
136
149
  return;
137
150
  }
138
- const dir = (0, layout_1.collectionDir)(root, name);
139
- let isDir = false;
151
+ if (alias !== '' && !(0, sitesettings_1.isUsableCollectionAlias)(alias)) {
152
+ (0, auth_1.apiError)(res, 400, '"siteAlias" is lowercase letters, digits, and single interior hyphens, with no doubled hyphen, at most 63 characters');
153
+ return;
154
+ }
155
+ if (alias !== '') {
156
+ const holder = (0, sitesettings_1.collectionAliasConflict)(root, alias, name);
157
+ if (holder) {
158
+ (0, auth_1.apiError)(res, 409, `the alias ${alias} is already how the collection ${holder} is reached`);
159
+ return;
160
+ }
161
+ }
162
+ (0, sitesettings_1.setCollectionAlias)(root, name, alias);
163
+ res.json({ name, siteAlias: (0, sitesettings_1.storedCollectionAlias)(root, name), alias: (0, sitesettings_1.collectionSiteAlias)(root, name) });
164
+ });
165
+ /** Whether the vault holds a collection of this name; the rename's own check. */
166
+ function isCollection(name) {
140
167
  try {
141
- isDir = fs.statSync(dir).isDirectory();
168
+ return fs.statSync((0, layout_1.collectionDir)(root, name)).isDirectory();
142
169
  }
143
170
  catch {
144
- isDir = false;
171
+ return false;
145
172
  }
146
- if (!isDir) {
147
- (0, auth_1.apiError)(res, 404, `no collection ${name} in this vault`);
173
+ }
174
+ // Only an empty one: what "empty" means, and what goes with the directory,
175
+ // is deleteCollection's business, shared with the web route.
176
+ app.delete('/api/collections/:name', (req, res) => {
177
+ const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
178
+ if (!auth)
148
179
  return;
149
- }
150
- // Empty means the collection holds nothing but its own empty repos
151
- // directory: no repository, nothing beside one, and nothing of the
152
- // collection's own.
153
- const repos = (0, layout_1.reposDir)(root, name);
154
- const own = fs.readdirSync(dir).filter((n) => n !== layout_1.REPOS_DIR);
155
- const inRepos = fs.existsSync(repos) ? fs.readdirSync(repos) : [];
156
- if (own.length > 0 || inRepos.length > 0) {
157
- (0, auth_1.apiError)(res, 409, `collection ${name} is not empty`);
180
+ const name = req.params.name;
181
+ if (!(0, perms_1.canAdminCollection)(root, auth, name)) {
182
+ (0, auth_1.apiError)(res, 403, `you are not an owner of ${name}`);
158
183
  return;
159
184
  }
160
185
  try {
161
- fs.rmSync(dir, { recursive: true });
186
+ (0, ops_1.deleteCollection)(root, name);
162
187
  }
163
188
  catch (e) {
164
- (0, auth_1.apiError)(res, 409, `could not remove ${name}: ${e instanceof Error ? e.message : String(e)}`);
189
+ (0, auth_1.sendOpError)(res, e, 'could not remove the collection');
165
190
  return;
166
191
  }
167
- // Any redirect that led here goes with it. A collection created later
168
- // under this name would otherwise inherit the traffic a former name of
169
- // this one still sends.
170
- (0, redirects_1.forgetCollectionRedirects)(root, name);
171
192
  res.json({ deleted: name });
172
193
  });
173
194
  // ---- users and their tokens ----
195
+ // A token-scoped token reaches its globs and nothing else, and in
196
+ // particular administers nothing (src/perms.ts). Its own user's token list
197
+ // is something to administer: a scoped token handed to a script could
198
+ // otherwise list and revoke the unrestricted tokens beside it, locking the
199
+ // user out of git and the CLI.
200
+ function requireUnscoped(auth, res) {
201
+ if (!(0, perms_1.tokenIsScoped)(auth))
202
+ return true;
203
+ (0, auth_1.apiError)(res, 403, 'a token-scoped token may not list or revoke tokens');
204
+ return false;
205
+ }
174
206
  app.get('/api/users/:name', (req, res) => {
175
207
  const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
176
208
  if (!auth)
@@ -191,6 +223,8 @@ function registerAdminApi(app, root, limiter, lfs = null, engine, egress) {
191
223
  (0, auth_1.apiError)(res, 403, 'site admin required to touch another user');
192
224
  return;
193
225
  }
226
+ if (!requireUnscoped(auth, res))
227
+ return;
194
228
  res.json({
195
229
  name: req.params.name,
196
230
  siteAdmin: user.siteAdmin === true,
@@ -215,6 +249,8 @@ function registerAdminApi(app, root, limiter, lfs = null, engine, egress) {
215
249
  (0, auth_1.apiError)(res, 403, 'site admin required to touch another user');
216
250
  return;
217
251
  }
252
+ if (!requireUnscoped(auth, res))
253
+ return;
218
254
  // Never the token, and never the hash either: an id is what revocation
219
255
  // takes, and the hash is a credential-shaped thing with no reason to travel.
220
256
  res.json({
@@ -240,6 +276,8 @@ function registerAdminApi(app, root, limiter, lfs = null, engine, egress) {
240
276
  (0, auth_1.apiError)(res, 403, 'site admin required to touch another user');
241
277
  return;
242
278
  }
279
+ if (!requireUnscoped(auth, res))
280
+ return;
243
281
  let result;
244
282
  try {
245
283
  result = (0, vault_1.revokeToken)(root, req.params.name, req.params.id);
package/dist/api/auth.js CHANGED
@@ -10,6 +10,7 @@ exports.bodyOf = bodyOf;
10
10
  exports.stringField = stringField;
11
11
  exports.stringsField = stringsField;
12
12
  exports.limitParam = limitParam;
13
+ const naming_1 = require("../naming");
13
14
  const ops_1 = require("../ops");
14
15
  const perms_1 = require("../perms");
15
16
  const scan_1 = require("../scan");
@@ -38,11 +39,11 @@ function apiError(res, status, message) {
38
39
  function requireApiAuth(root, limiter, req, res) {
39
40
  const state = (0, vault_1.loadVault)(root);
40
41
  if (state.status === 'missing') {
41
- apiError(res, 401, 'no vault.json in this vault; restart the server to initialize one');
42
+ apiError(res, 401, `no ${naming_1.naming.stateFile} in this ${naming_1.naming.rootNoun}; restart the server to initialize one`);
42
43
  return null;
43
44
  }
44
45
  if (state.status === 'error') {
45
- apiError(res, 500, `vault.json could not be read: ${state.message}`);
46
+ apiError(res, 500, `${naming_1.naming.stateFile} could not be read: ${state.message}`);
46
47
  return null;
47
48
  }
48
49
  const m = (req.get('authorization') ?? '').match(/^bearer\s+(.+)$/i);