@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/browse.js CHANGED
@@ -35,11 +35,11 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.IMAGE_TYPES = void 0;
37
37
  exports.registerBrowse = registerBrowse;
38
- const crypto = __importStar(require("crypto"));
39
38
  const fs = __importStar(require("fs"));
40
39
  const git_1 = require("./git");
41
40
  const languages_1 = require("./languages");
42
41
  const markdown_1 = require("./markdown");
42
+ const agefile_1 = require("./agefile");
43
43
  const pointer_1 = require("./pointer");
44
44
  const profile_1 = require("./profile");
45
45
  const render_1 = require("./render");
@@ -49,7 +49,9 @@ const diff_1 = require("./diff");
49
49
  const layout_1 = require("./layout");
50
50
  const perms_1 = require("./perms");
51
51
  const scan_1 = require("./scan");
52
+ const topics_1 = require("./topics");
52
53
  const session_1 = require("./session");
54
+ const sitesettings_1 = require("./sitesettings");
53
55
  const site_1 = require("./site");
54
56
  const vault_1 = require("./vault");
55
57
  const views = __importStar(require("./views"));
@@ -57,6 +59,50 @@ const views_1 = require("./views");
57
59
  const web_1 = require("./web");
58
60
  const COMMITS_PER_PAGE = 35;
59
61
  const MAX_RENDER_SIZE = 1024 * 1024;
62
+ /**
63
+ * Raw files over this size are streamed from git rather than read into
64
+ * memory first. Below it the bytes are read whole, which is what lets the
65
+ * route tell a pointer from a file and text from binary; above it nothing
66
+ * needs the bytes but the socket.
67
+ */
68
+ const STREAM_ABOVE = 8 * 1024 * 1024;
69
+ /**
70
+ * The caching headers a raw file carries, and whether the request was
71
+ * answered with a 304 and needs nothing more.
72
+ *
73
+ * Reading a public repository is anonymous, so its raw files may be cached
74
+ * publicly; the question is only for how long. A full commit id can never
75
+ * come to name different bytes, so under one the answer is forever. Under a
76
+ * branch or tag it is "until it changes", which HTTP spells as revalidate
77
+ * every time: the ETag is the blob's own object id, which is a hash of the
78
+ * bytes, so an unchanged file costs a 304 and no body whichever commit now
79
+ * holds it. A private repository's bytes were served to one reader and must
80
+ * never come out of a shared cache for another.
81
+ */
82
+ function rawCacheHeaders(req, res, ref, blobSha, isPrivate) {
83
+ const cacheScope = isPrivate ? 'private' : 'public';
84
+ if (/^[0-9a-f]{40}$/.test(ref)) {
85
+ res.setHeader('Cache-Control', `${cacheScope}, max-age=31536000, immutable`);
86
+ return false;
87
+ }
88
+ res.setHeader('Cache-Control', `${cacheScope}, no-cache`);
89
+ return notModified(req, res, `"${blobSha}"`);
90
+ }
91
+ /**
92
+ * Set the validator and answer 304 if the request already holds it, saying
93
+ * so. Compared here rather than through req.fresh, which answered false to a
94
+ * matching If-None-Match on these routes for as long as they have set an
95
+ * ETag; the comparison is three lines, and this way it is known to run.
96
+ */
97
+ function notModified(req, res, etag) {
98
+ res.setHeader('ETag', etag);
99
+ const presented = req.headers['if-none-match'];
100
+ if (presented && presented.split(',').some((t) => t.trim() === etag || t.trim() === '*')) {
101
+ res.status(304).end();
102
+ return true;
103
+ }
104
+ return false;
105
+ }
60
106
  const MAX_LISTED_COMMITS = 250;
61
107
  const ARCHIVE_FORMATS = {
62
108
  'tar.gz': { format: 'tar.gz', type: 'application/gzip' },
@@ -91,14 +137,16 @@ function registerBrowse(app, root, gates, lfs = null) {
91
137
  // A repository with a site is linked straight to it from the listing,
92
138
  // at its own origin where it has one, so a visitor scanning a
93
139
  // collection reaches the published page without stopping at the
94
- // repository first.
95
- const hasSite = (0, scan_1.siteDir)(root, collection, name) !== null;
140
+ // repository first. Only when the site is enabled: files on disk with
141
+ // the switch off are not a site.
142
+ const hasSite = (0, sitesettings_1.siteSettings)(repo.dir).enabled && (0, scan_1.siteDir)(root, collection, name) !== null;
96
143
  const origin = hasSite ? (0, site_1.siteHostUrl)(root, req, collection, name) : null;
97
144
  const run = (0, runs_1.latestRun)(root, collection, name);
98
145
  return {
99
146
  collection,
100
147
  name,
101
148
  description: (0, scan_1.repoDescription)(repo.dir),
149
+ topics: (0, topics_1.repoTopics)(repo.dir),
102
150
  isPrivate: (0, perms_1.repoIsPrivate)(repo.dir),
103
151
  updated: await repo.lastUpdated(),
104
152
  siteUrl: !hasSite
@@ -121,13 +169,47 @@ function registerBrowse(app, root, gates, lfs = null) {
121
169
  function sortParam(req) {
122
170
  return req.query.sort === 'name' ? 'name' : 'recent';
123
171
  }
172
+ // The topic a listing is narrowed to. A value that is not a topic reads as
173
+ // no narrowing rather than an error, the way an unknown sort does.
174
+ function topicParam(req) {
175
+ const t = typeof req.query.topic === 'string' ? req.query.topic : '';
176
+ return (0, topics_1.isValidTopic)(t) ? t : '';
177
+ }
178
+ /** The filter a listing page hands its view: what was chosen, and what there is to choose. */
179
+ function topicFilter(cards, topic) {
180
+ return {
181
+ filtered: topic === '' ? cards : cards.filter((c) => (c.topics ?? []).includes(topic)),
182
+ filter: { current: topic, inUse: (0, topics_1.countTopics)(cards.map((c) => c.topics ?? [])) },
183
+ };
184
+ }
124
185
  app.get('/', (0, web_1.ah)(async (req, res) => {
125
186
  const viewer = (0, session_1.getViewer)(req, root);
126
187
  const cardsPer = await Promise.all((0, scan_1.listCollections)(root).map((c) => repoCards(req, c.name, viewer)));
127
188
  // The counts beside collection names count what this viewer can see,
128
189
  // for the same reason the cards do.
129
190
  const collections = (0, scan_1.listCollections)(root).map((c, i) => ({ name: c.name, repoCount: cardsPer[i].length }));
130
- res.type('html').send(views.homePage(root, collections, cardsPer.flat(), sortParam(req), viewer));
191
+ const { filtered, filter } = topicFilter(cardsPer.flat(), topicParam(req));
192
+ res.type('html').send(views.homePage(root, collections, filtered, sortParam(req), viewer, filter));
193
+ }));
194
+ // The vault's topics, and one topic's repositories. Registered before the
195
+ // generic /:collection routes, and `topics` is a reserved name (see
196
+ // src/scan.ts), so no collection can sit where these answer.
197
+ app.get('/topics', (0, web_1.ah)(async (req, res) => {
198
+ const viewer = (0, session_1.getViewer)(req, root);
199
+ const cardsPer = await Promise.all((0, scan_1.listCollections)(root).map((c) => repoCards(req, c.name, viewer)));
200
+ const counts = (0, topics_1.countTopics)(cardsPer.flat().map((c) => c.topics ?? []));
201
+ res.type('html').send(views.topicsIndexPage(counts, viewer));
202
+ }));
203
+ app.get('/topics/:topic', (0, web_1.ah)(async (req, res) => {
204
+ const viewer = (0, session_1.getViewer)(req, root);
205
+ const topic = req.params.topic;
206
+ if (!(0, topics_1.isValidTopic)(topic)) {
207
+ (0, web_1.send404)(res, 'Not found', viewer);
208
+ return;
209
+ }
210
+ const cardsPer = await Promise.all((0, scan_1.listCollections)(root).map((c) => repoCards(req, c.name, viewer)));
211
+ const repos = cardsPer.flat().filter((c) => (c.topics ?? []).includes(topic));
212
+ res.type('html').send(views.topicPage(topic, repos, sortParam(req), viewer));
131
213
  }));
132
214
  app.get('/about', (req, res) => {
133
215
  res.type('html').send(views.aboutPage((0, web_1.baseUrlOf)(req), (0, session_1.getViewer)(req, root)));
@@ -142,7 +224,12 @@ function registerBrowse(app, root, gates, lfs = null) {
142
224
  catch {
143
225
  collectionIsDir = false;
144
226
  }
145
- if (!(0, scan_1.isValidName)(collection) || !collectionIsDir) {
227
+ // A vault user's namespace page exists as soon as the user does: the
228
+ // collection directory only appears with their first repository, and a
229
+ // profile that 404s until then would say the user does not exist.
230
+ const state = (0, vault_1.loadVault)(root);
231
+ const userRecord = (0, scan_1.isValidUserName)(collection) && state.status === 'ok' ? (state.vault.users[collection] ?? null) : null;
232
+ if (!(0, scan_1.isValidName)(collection) || (!collectionIsDir && !userRecord)) {
146
233
  (0, web_1.send404)(res, `Collection ${collection} not found`, viewer);
147
234
  return;
148
235
  }
@@ -150,16 +237,29 @@ function registerBrowse(app, root, gates, lfs = null) {
150
237
  // introduces the collection only to viewers who could read it.
151
238
  const profileRepo = (0, scan_1.findRepo)(root, collection, '.mochi');
152
239
  const profileVisible = profileRepo === null || (0, perms_1.repoRole)(root, viewer?.auth ?? null, profileRepo) !== null;
153
- const [cards, profile] = await Promise.all([
154
- repoCards(req, collection, viewer),
240
+ const [allCards, profile] = await Promise.all([
241
+ collectionIsDir ? repoCards(req, collection, viewer) : Promise.resolve([]),
155
242
  profileVisible
156
243
  ? (0, profile_1.collectionProfile)(root, collection)
157
244
  : Promise.resolve({ readme: null, addUrl: `/new?collection=${encodeURIComponent(collection)}` }),
158
245
  ]);
159
- const canSettings = viewer !== null && (0, perms_1.canAdminCollection)(root, viewer.auth, collection);
246
+ const canSettings = collectionIsDir && viewer !== null && (0, perms_1.canAdminCollection)(root, viewer.auth, collection);
247
+ // What the page shows of the user behind the name. The links were held
248
+ // to http(s) where the profile was saved; the filter here keeps a
249
+ // hand-edited vault.json from linking the page anywhere else.
250
+ const owner = userRecord
251
+ ? {
252
+ displayName: userRecord.profile?.name ?? null,
253
+ bio: userRecord.profile?.bio ?? null,
254
+ links: (userRecord.profile?.links ?? []).filter((l) => /^https?:\/\//i.test(l)),
255
+ siteAdmin: userRecord.siteAdmin === true,
256
+ isViewer: viewer !== null && viewer.auth.username === collection,
257
+ }
258
+ : null;
259
+ const { filtered, filter } = topicFilter(allCards, topicParam(req));
160
260
  res
161
261
  .type('html')
162
- .send(views.collectionPage(collection, cards, sortParam(req), viewer, canSettings, profile));
262
+ .send(views.collectionPage(collection, filtered, sortParam(req), viewer, canSettings, profile, owner, filter));
163
263
  }));
164
264
  async function renderTree(req, res, loaded, ref, treePath) {
165
265
  const viewer = (0, session_1.getViewer)(req, root);
@@ -212,6 +312,7 @@ function registerBrowse(app, root, gates, lfs = null) {
212
312
  blobBase: `${base}/blob/${(0, views_1.encPath)(ref)}${dirSuffix}`,
213
313
  issueBase: `${base}/issues`,
214
314
  commitBase: `${base}/commit`,
315
+ mentions: (name) => (0, vault_1.userExists)(root, name),
215
316
  });
216
317
  }
217
318
  else {
@@ -226,10 +327,12 @@ function registerBrowse(app, root, gates, lfs = null) {
226
327
  latest,
227
328
  commitCount,
228
329
  description: (0, scan_1.repoDescription)(loaded.repo.dir),
330
+ topics: (0, topics_1.repoTopics)(loaded.repo.dir),
229
331
  contributors,
230
332
  readmeHtml,
231
333
  readmeName,
232
334
  languages,
335
+ msg: typeof req.query.msg === 'string' ? req.query.msg : undefined,
233
336
  }));
234
337
  }
235
338
  app.get('/:collection/:repo', (0, web_1.ah)(async (req, res) => {
@@ -282,9 +385,20 @@ function registerBrowse(app, root, gates, lfs = null) {
282
385
  (0, web_1.send404)(res, `File ${filePath} not found at ${ref}`, viewer);
283
386
  return;
284
387
  }
285
- const buf = await loaded.repo.catBlob(ref, filePath);
286
388
  const ext = (filePath.split('.').pop() ?? '').toLowerCase();
287
389
  const rawUrl = `${(0, views_1.repoUrl)(ctx)}/raw/${(0, views_1.encPath)(ref)}/${(0, views_1.encPath)(filePath)}`;
390
+ // The size first, and the bytes only if they will be shown. A file over
391
+ // the render limit gets its card from the size alone: nothing below
392
+ // would render it, and reading it whole to learn that held a file the
393
+ // size of the machine in memory. An LFS pointer is a few hundred bytes
394
+ // and never reaches this branch.
395
+ const info = await loaded.repo.blobInfo(ref, filePath);
396
+ if (info && info.size > MAX_RENDER_SIZE) {
397
+ const kind = exports.IMAGE_TYPES[ext] ? 'image' : 'too-large';
398
+ res.type('html').send(views.blobPage(ctx, filePath, { kind, rawUrl, size: info.size }));
399
+ return;
400
+ }
401
+ const buf = await loaded.repo.catBlob(ref, filePath);
288
402
  // LFS pointer detection precedes every content branch: an LFS-tracked
289
403
  // .md or .png must render as a download card, not as its pointer text.
290
404
  // ?plain=1 falls through to the source view, as on GitHub, keeping the
@@ -311,6 +425,20 @@ function registerBrowse(app, root, gates, lfs = null) {
311
425
  }, true));
312
426
  return;
313
427
  }
428
+ // An age ciphertext renders as a decrypt-in-the-browser card, whatever
429
+ // its framing: armored files are text and binary ones are not, and the
430
+ // card treats both alike. ?plain=1 falls through, so the armored source
431
+ // is inspectable the way a markdown file's is.
432
+ if ((0, agefile_1.isAgeFile)(filePath) && req.query.plain !== '1') {
433
+ res.type('html').send(views.blobPage(ctx, filePath, {
434
+ kind: 'age',
435
+ rawUrl,
436
+ size: buf.length,
437
+ editable: ctx.canPush && ctx.refIsBranch,
438
+ markdownInner: (0, markdown_1.isMarkdownFile)((0, agefile_1.ageInnerName)(filePath)),
439
+ }));
440
+ return;
441
+ }
314
442
  if (exports.IMAGE_TYPES[ext]) {
315
443
  res.type('html').send(views.blobPage(ctx, filePath, { kind: 'image', rawUrl, size: buf.length }));
316
444
  return;
@@ -334,6 +462,7 @@ function registerBrowse(app, root, gates, lfs = null) {
334
462
  blobBase: `${(0, views_1.repoUrl)(ctx)}/blob/${(0, views_1.encPath)(ref)}${dir}`,
335
463
  issueBase: `${(0, views_1.repoUrl)(ctx)}/issues`,
336
464
  commitBase: `${(0, views_1.repoUrl)(ctx)}/commit`,
465
+ mentions: ctx.hasUser,
337
466
  });
338
467
  res
339
468
  .type('html')
@@ -361,12 +490,17 @@ function registerBrowse(app, root, gates, lfs = null) {
361
490
  }
362
491
  const ctx = await (0, web_1.makeCtx)(root, req, loaded, ref, viewer);
363
492
  const blobUrl = `${(0, views_1.repoUrl)(ctx)}/blob/${(0, views_1.encPath)(ref)}/${(0, views_1.encPath)(filePath)}`;
364
- if ((await loaded.repo.entryType(ref, filePath)) !== 'blob') {
493
+ const info = await loaded.repo.blobInfo(ref, filePath);
494
+ if (!info) {
365
495
  (0, web_1.send404)(res, `File ${filePath} not found at ${ref}`, viewer);
366
496
  return;
367
497
  }
498
+ if (info.size > MAX_RENDER_SIZE) {
499
+ res.redirect(blobUrl);
500
+ return;
501
+ }
368
502
  const buf = await loaded.repo.catBlob(ref, filePath);
369
- if ((0, render_1.isBinary)(buf) || buf.length > MAX_RENDER_SIZE) {
503
+ if ((0, render_1.isBinary)(buf)) {
370
504
  res.redirect(blobUrl);
371
505
  return;
372
506
  }
@@ -383,11 +517,28 @@ function registerBrowse(app, root, gates, lfs = null) {
383
517
  (0, web_1.send404)(res);
384
518
  return;
385
519
  }
386
- const type = await loaded.repo.entryType(ref, filePath);
387
- if (type !== 'blob') {
520
+ const info = await loaded.repo.blobInfo(ref, filePath);
521
+ if (!info) {
388
522
  res.status(404).type('text/plain').send('not found');
389
523
  return;
390
524
  }
525
+ // A large file is streamed from git to the socket and never held here;
526
+ // see rawCacheHeaders for what the two paths share. What the streamed
527
+ // path gives up is the sniff: without the bytes in hand it cannot tell
528
+ // text from binary, so anything that is not an image by extension is
529
+ // served as a download. Small files, which are the ones a browser can
530
+ // usefully show inline, keep the buffered path below.
531
+ if (info.size > STREAM_ABOVE) {
532
+ const ext = (filePath.split('.').pop() ?? '').toLowerCase();
533
+ if (rawCacheHeaders(req, res, ref, info.sha, (0, perms_1.repoIsPrivate)(loaded.repo.dir)))
534
+ return;
535
+ res.setHeader('Content-Security-Policy', 'sandbox');
536
+ res.setHeader('X-Content-Type-Options', 'nosniff');
537
+ res.setHeader('Content-Length', String(info.size));
538
+ res.type(exports.IMAGE_TYPES[ext] ?? 'application/octet-stream');
539
+ await loaded.repo.streamBlob(ref, filePath, res);
540
+ return;
541
+ }
391
542
  const buf = await loaded.repo.catBlob(ref, filePath);
392
543
  // A pointer blob redirects to the stored object; the filename gives the
393
544
  // browser something better to save than a 64-character object id.
@@ -417,18 +568,8 @@ function registerBrowse(app, root, gates, lfs = null) {
417
568
  // on purpose: a redirect to a presigned URL expires and must not be
418
569
  // cached. A private repository's bytes were served to one reader and
419
570
  // must never come out of a shared cache for another.
420
- const cacheScope = (0, perms_1.repoIsPrivate)(loaded.repo.dir) ? 'private' : 'public';
421
- if (/^[0-9a-f]{40}$/.test(ref)) {
422
- res.setHeader('Cache-Control', `${cacheScope}, max-age=31536000, immutable`);
423
- }
424
- else {
425
- res.setHeader('Cache-Control', `${cacheScope}, no-cache`);
426
- res.setHeader('ETag', `"${crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)}"`);
427
- if (req.fresh) {
428
- res.status(304).end();
429
- return;
430
- }
431
- }
571
+ if (rawCacheHeaders(req, res, ref, info.sha, (0, perms_1.repoIsPrivate)(loaded.repo.dir)))
572
+ return;
432
573
  const ext = (filePath.split('.').pop() ?? '').toLowerCase();
433
574
  // Repository content must never be able to inject HTML into this
434
575
  // origin: non-image types are served as text/plain in a sandbox.
@@ -477,11 +618,8 @@ function registerBrowse(app, root, gates, lfs = null) {
477
618
  }
478
619
  else {
479
620
  res.setHeader('Cache-Control', `${cacheScope}, no-cache`);
480
- res.setHeader('ETag', `"${sha}"`);
481
- if (req.fresh) {
482
- res.status(304).end();
621
+ if (notModified(req, res, `"${sha}"`))
483
622
  return;
484
- }
485
623
  }
486
624
  // The slot is held until the stream ends, which is what the gate is for: an
487
625
  // archive holds a subprocess and a socket for as long as the client cares
@@ -609,18 +747,18 @@ function registerBrowse(app, root, gates, lfs = null) {
609
747
  // not 301: a permanent redirect would be cached hard, and removing
610
748
  // sites.host from config.json must take effect on the next request.
611
749
  app.get('/:collection/:repo/site/*', (req, res) => {
612
- const origin = (0, site_1.siteHostUrl)(root, req, req.params.collection, (0, scan_1.displayName)(req.params.repo));
750
+ const origin = (0, site_1.siteRedirectUrl)(root, req, req.params.collection, (0, scan_1.displayName)(req.params.repo));
613
751
  if (origin) {
614
752
  const query = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
615
753
  res.redirect(302, `${origin}/${(0, web_1.wildcard)(req)}${query}`);
616
754
  return;
617
755
  }
618
- (0, site_1.serveSite)(root, req.params.collection, req.params.repo, req, res, 'sandbox');
756
+ (0, site_1.serveSite)(root, req.params.collection, req.params.repo, req, res, 'sandbox', (0, session_1.getViewer)(req, root)?.auth ?? null);
619
757
  });
620
758
  // The missing-slash redirect lands on the site origin in one hop rather than
621
759
  // two when there is one.
622
760
  app.get('/:collection/:repo/site', (req, res) => {
623
- const origin = (0, site_1.siteHostUrl)(root, req, req.params.collection, (0, scan_1.displayName)(req.params.repo));
761
+ const origin = (0, site_1.siteRedirectUrl)(root, req, req.params.collection, (0, scan_1.displayName)(req.params.repo));
624
762
  if (origin) {
625
763
  res.redirect(302, `${origin}/`);
626
764
  return;
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "7b95ca5-dirty",
3
- "builtAt": "2026-08-21T14:34:24.115Z"
2
+ "commit": "0e4047e",
3
+ "builtAt": "2026-09-29T02:15:46.393Z"
4
4
  }
package/dist/ci/api.js CHANGED
@@ -41,6 +41,8 @@ const express_1 = __importDefault(require("express"));
41
41
  const fs = __importStar(require("fs"));
42
42
  const path = __importStar(require("path"));
43
43
  const scan_1 = require("../scan");
44
+ const sitesettings_1 = require("../sitesettings");
45
+ const sitepublish_1 = require("../sitepublish");
44
46
  const site_1 = require("../site");
45
47
  const perms_1 = require("../perms");
46
48
  const vault_1 = require("../vault");
@@ -171,6 +173,11 @@ function registerCiApi(app, root, engine, authLimiter) {
171
173
  allow: r.allow,
172
174
  createdBy: r.createdBy,
173
175
  createdAt: r.createdAt,
176
+ // What was set and what is in force, the two differing for a runner
177
+ // left on the default: a caller wanting to know how long a job may run
178
+ // there should not have to know what the default is.
179
+ jobTimeoutMinutes: r.jobTimeoutMinutes ?? null,
180
+ jobTimeout: (0, runners_1.runnerJobTimeout)(r),
174
181
  // Registration says what a runner may do; these two say whether it is
175
182
  // there at all and what it is doing, which is what a caller looking at
176
183
  // a run that has not started actually wants to know.
@@ -192,6 +199,13 @@ function registerCiApi(app, root, engine, authLimiter) {
192
199
  * far as this can go: where it points is the administrator's business, and
193
200
  * they are already trusted with a runner that executes repository code.
194
201
  */
202
+ // A wake address is somewhere the vault will send requests to, on a timer
203
+ // and on demand, and report back whether it answered. Every user owns a
204
+ // collection and so may register a runner for it, which would have made
205
+ // this a way for any user to have the vault probe an address of their
206
+ // choosing, an internal one included. So the address alone takes a site
207
+ // admin, as it does in the web interface; the runner itself does not.
208
+ const WAKE_ADMIN_ONLY = 'only a site admin may give a runner a wake address or send its wake request: the vault sends requests to whatever the address names';
195
209
  function wakeFrom(body) {
196
210
  const url = body.wakeUrl;
197
211
  const secret = body.wakeSecret;
@@ -262,13 +276,30 @@ function registerCiApi(app, root, engine, authLimiter) {
262
276
  apiError(res, 400, wake.error);
263
277
  return;
264
278
  }
279
+ if (wake.wake && !(0, perms_1.isSiteAdmin)(auth)) {
280
+ apiError(res, 403, WAKE_ADMIN_ONLY);
281
+ return;
282
+ }
283
+ const timeout = jobTimeoutFrom(body);
284
+ if ('error' in timeout) {
285
+ apiError(res, 400, timeout.error);
286
+ return;
287
+ }
265
288
  const { token, runner } = (0, runners_1.registerRunner)(root, name, {
266
289
  labels: labels.length ? labels : ['ubuntu-latest'],
267
290
  allow,
268
291
  createdBy: auth.username,
292
+ jobTimeoutMinutes: timeout.minutes,
269
293
  wake: wake.wake,
270
294
  });
271
- res.json({ name, token, labels: runner.labels, allow: runner.allow, wakeUrl: runner.wakeUrl ?? null });
295
+ res.json({
296
+ name,
297
+ token,
298
+ labels: runner.labels,
299
+ allow: runner.allow,
300
+ jobTimeout: (0, runners_1.runnerJobTimeout)(runner),
301
+ wakeUrl: runner.wakeUrl ?? null,
302
+ });
272
303
  });
273
304
  /**
274
305
  * Issue a new token for a runner, invalidating the one it had.
@@ -300,6 +331,73 @@ function registerCiApi(app, root, engine, authLimiter) {
300
331
  }
301
332
  res.json({ name, token: issued.token, labels: issued.runner.labels, allow: issued.runner.allow });
302
333
  });
334
+ /**
335
+ * The job timeout in a request body, or a message saying what is wrong.
336
+ *
337
+ * Absent and null both read as the default, which is what registration
338
+ * wants; the PATCH route below tells the two apart itself, because there an
339
+ * absent field is a caller who asked for nothing. A value that is not a
340
+ * usable number of minutes is refused rather than rounded: an operator who
341
+ * typed 0 meant something, and it was not "no limit".
342
+ */
343
+ function jobTimeoutFrom(body) {
344
+ const v = body.jobTimeoutMinutes;
345
+ if (v === undefined)
346
+ return { minutes: null };
347
+ if (v === null)
348
+ return { minutes: null };
349
+ if (typeof v !== 'number' || !(0, runners_1.isUsableJobTimeout)(v)) {
350
+ return {
351
+ error: `"jobTimeoutMinutes" must be a whole number of minutes from 1 to ${runners_1.MAX_JOB_TIMEOUT_MINUTES}, or null for the default of ${runners_1.DEFAULT_JOB_TIMEOUT_MINUTES}`,
352
+ };
353
+ }
354
+ return { minutes: v };
355
+ }
356
+ /**
357
+ * The longest a job may run on this runner. A ceiling rather than a default:
358
+ * a job's own `timeout-minutes` applies when it asks for less, and is cut
359
+ * down to this when it asks for more or asks for nothing.
360
+ *
361
+ * A change applies to the next job the runner takes, not to one already
362
+ * running: the runner enforces the timeout it was handed with the job, and
363
+ * reaching into a job in flight to shorten it would be a cancellation
364
+ * wearing a setting's clothes.
365
+ */
366
+ app.patch('/api/runners/:name', json, (req, res) => {
367
+ const auth = requireAdmin(req, res);
368
+ if (!auth)
369
+ return;
370
+ const name = req.params.name;
371
+ const existing = (0, runners_1.loadRunners)(root).runners[name];
372
+ if (!existing) {
373
+ apiError(res, 404, `no runner named ${name}`);
374
+ return;
375
+ }
376
+ if (!(0, perms_1.canAdminRunnerGlobs)(root, auth, existing.allow)) {
377
+ apiError(res, 403, 'you must own every collection this runner serves; a site admin may manage any runner');
378
+ return;
379
+ }
380
+ const body = (req.body ?? {});
381
+ if (body.jobTimeoutMinutes === undefined) {
382
+ apiError(res, 400, 'nothing to change; provide "jobTimeoutMinutes"');
383
+ return;
384
+ }
385
+ const timeout = jobTimeoutFrom(body);
386
+ if ('error' in timeout) {
387
+ apiError(res, 400, timeout.error);
388
+ return;
389
+ }
390
+ const runner = (0, runners_1.setRunnerJobTimeout)(root, name, timeout.minutes);
391
+ if (!runner) {
392
+ apiError(res, 404, `no runner named ${name}`);
393
+ return;
394
+ }
395
+ res.json({
396
+ name,
397
+ jobTimeoutMinutes: runner.jobTimeoutMinutes ?? null,
398
+ jobTimeout: (0, runners_1.runnerJobTimeout)(runner),
399
+ });
400
+ });
303
401
  /**
304
402
  * Point a runner's wake address somewhere, or clear it with an empty body.
305
403
  *
@@ -327,6 +425,10 @@ function registerCiApi(app, root, engine, authLimiter) {
327
425
  apiError(res, 400, wake.error);
328
426
  return;
329
427
  }
428
+ if (wake.wake && !(0, perms_1.isSiteAdmin)(auth)) {
429
+ apiError(res, 403, WAKE_ADMIN_ONLY);
430
+ return;
431
+ }
330
432
  const runner = (0, runners_1.setRunnerWake)(root, name, wake.wake);
331
433
  res.json({ name, wakeUrl: runner?.wakeUrl ?? null });
332
434
  });
@@ -348,8 +450,8 @@ function registerCiApi(app, root, engine, authLimiter) {
348
450
  apiError(res, 404, `no runner named ${name}`);
349
451
  return;
350
452
  }
351
- if (!(0, perms_1.canAdminRunnerGlobs)(root, auth, existing.allow)) {
352
- apiError(res, 403, 'you must own every collection this runner serves; a site admin may manage any runner');
453
+ if (!(0, perms_1.isSiteAdmin)(auth)) {
454
+ apiError(res, 403, WAKE_ADMIN_ONLY);
353
455
  return;
354
456
  }
355
457
  const wake = (0, wake_1.wakeOf)(existing);
@@ -412,7 +514,10 @@ function registerCiApi(app, root, engine, authLimiter) {
412
514
  gone.abort();
413
515
  }
414
516
  });
415
- const spec = await engine.waitForJob(auth.name, labels, auth.runner.allow, (0, web_1.baseUrlOf)(req), ACQUIRE_TIMEOUT_MS, gone.signal);
517
+ const spec = await engine.waitForJob(auth.name, labels, auth.runner.allow, (0, web_1.baseUrlOf)(req),
518
+ // The registry decides how long a job may run on this machine, so the
519
+ // limit is read here rather than trusted from the runner's own request.
520
+ (0, runners_1.runnerJobTimeout)(auth.runner), ACQUIRE_TIMEOUT_MS, gone.signal);
416
521
  if (!spec) {
417
522
  if (!closed)
418
523
  res.status(204).end();
@@ -684,6 +789,9 @@ function registerCiApi(app, root, engine, authLimiter) {
684
789
  });
685
790
  // ---- artifacts ----
686
791
  //
792
+ /** How many distinct artifacts one run may hold; see the upload route. */
793
+ const MAX_ARTIFACTS_PER_RUN = 100;
794
+ //
687
795
  // A job uploads under a name and a later job in the same run downloads by
688
796
  // that name. Authorization is the job's lease, so an artifact can only be
689
797
  // written by a job that is actually running, and only into its own run.
@@ -713,6 +821,18 @@ function registerCiApi(app, root, engine, authLimiter) {
713
821
  }
714
822
  const limit = engine.artifactLimitBytes();
715
823
  fs.mkdirSync(dir, { recursive: true });
824
+ // The per-upload limit reads as a limit on the run, and was not one: a
825
+ // job that uploaded under a new name each time could fill the disk a
826
+ // capped artifact at a time. Replacing an artifact is always allowed;
827
+ // adding one is, up to a count no honest workflow approaches.
828
+ if (!fs.existsSync(file)) {
829
+ const present = fs.readdirSync(dir).filter((n) => !/\.tmp-\d+$/.test(n)).length;
830
+ if (present >= MAX_ARTIFACTS_PER_RUN) {
831
+ req.resume();
832
+ apiError(res, 413, `this run already holds ${present} artifacts, the most a run may hold`);
833
+ return;
834
+ }
835
+ }
716
836
  const tmp = `${file}.tmp-${process.pid}`;
717
837
  const out = fs.createWriteStream(tmp);
718
838
  let written = 0;
@@ -800,6 +920,20 @@ function registerCiApi(app, root, engine, authLimiter) {
800
920
  apiError(res, 409, 'the lease on this job is no longer valid');
801
921
  return;
802
922
  }
923
+ // The repository's own settings gate the write, not the runner's standing:
924
+ // a site that is not enabled is not deployed to, and one published by
925
+ // copying files is not deployed to either, so a workflow cannot overwrite
926
+ // what somebody maintains by hand. The refusal names the setting to change.
927
+ const repo = (0, scan_1.findRepo)(root, a.collection, a.repo);
928
+ const settings = repo ? (0, sitesettings_1.siteSettings)(repo.dir) : null;
929
+ if (!settings || !settings.enabled) {
930
+ apiError(res, 403, `the site for ${a.collection}/${a.repo} is not enabled; a repository admin can enable it in the repository's settings`);
931
+ return;
932
+ }
933
+ if (settings.source !== 'actions') {
934
+ apiError(res, 403, `the site for ${a.collection}/${a.repo} is published by ${(0, sitepublish_1.describeSiteSource)(settings.source)}, not by workflow deploys; set its site source to workflow deploys first`);
935
+ return;
936
+ }
803
937
  const body = (req.body ?? {});
804
938
  const name = typeof body.artifact === 'string' ? body.artifact : 'github-pages';
805
939
  if (!(0, artifacts_1.isValidArtifactName)(name)) {