@magland/mochi 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +138 -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 +192 -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
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runnerListCommand = void 0;
4
4
  exports.runnerAddCmd = runnerAddCmd;
5
+ exports.runnerEditCmd = runnerEditCmd;
5
6
  exports.runnerWakeCmd = runnerWakeCmd;
6
7
  exports.runnerRemoveCmd = runnerRemoveCmd;
7
8
  exports.runnerRunCmd = runnerRunCmd;
@@ -31,6 +32,23 @@ function parseDuration(text) {
31
32
  const unit = (m[2] ?? 's').toLowerCase();
32
33
  return unit === 'h' ? n * 3600 : unit === 'm' ? n * 60 : n;
33
34
  }
35
+ /**
36
+ * "20", "45m", "2h" -> minutes, for the job timeout.
37
+ *
38
+ * Separate from parseDuration above, and in a different unit, because a bare
39
+ * number means different things in the two places: seconds is the natural
40
+ * reading of --idle and the wrong reading of a job timeout, which an operator
41
+ * states in minutes.
42
+ */
43
+ function parseTimeoutMinutes(text) {
44
+ const m = /^(\d+)\s*(m|h)?$/i.exec(text.trim());
45
+ if (!m) {
46
+ console.error(`--job-timeout takes minutes, or a duration like 45m or 2h, got: ${text}`);
47
+ process.exit(1);
48
+ }
49
+ const n = parseInt(m[1], 10);
50
+ return (m[2] ?? 'm').toLowerCase() === 'h' ? n * 60 : n;
51
+ }
34
52
  function parseArgs(args, usage) {
35
53
  const out = {
36
54
  name: null,
@@ -42,6 +60,9 @@ function parseArgs(args, usage) {
42
60
  images: {},
43
61
  workDir: null,
44
62
  network: null,
63
+ jobMemory: null,
64
+ jobCpus: null,
65
+ jobPids: null,
45
66
  cacheDir: null,
46
67
  actionsUrl: null,
47
68
  actionCache: true,
@@ -51,6 +72,7 @@ function parseArgs(args, usage) {
51
72
  wakeSecret: null,
52
73
  wakeUrl: null,
53
74
  clear: false,
75
+ jobTimeout: null,
54
76
  engine: null,
55
77
  };
56
78
  const list = (v) => v.split(/[\s,]+/).filter((s) => s.length > 0);
@@ -78,6 +100,27 @@ function parseArgs(args, usage) {
78
100
  out.actionCache = false;
79
101
  else if (a === '--network')
80
102
  out.network = args[++i];
103
+ else if (a === '--job-memory' || a === '--job-cpus') {
104
+ // Passed through to the engine as written, which is the authority on
105
+ // what "2g" or "1.5" means; only an empty value is refused here.
106
+ const v = (args[++i] ?? '').trim();
107
+ if (!/^[0-9][0-9.]*[bkmgBKMG]?$/.test(v)) {
108
+ console.error(`${a} takes a number, as docker run does (${a === '--job-memory' ? '2g, 512m' : '2, 0.5'}), got: ${v}`);
109
+ process.exit(1);
110
+ }
111
+ if (a === '--job-memory')
112
+ out.jobMemory = v;
113
+ else
114
+ out.jobCpus = v;
115
+ }
116
+ else if (a === '--job-pids') {
117
+ const n = parseInt(args[++i] ?? '', 10);
118
+ if (!Number.isInteger(n) || n < 0) {
119
+ console.error('--job-pids takes a number of processes, or 0 for no limit');
120
+ process.exit(1);
121
+ }
122
+ out.jobPids = n;
123
+ }
81
124
  else if (a === '--idle')
82
125
  out.idleSeconds = parseDuration(args[++i] ?? '');
83
126
  else if (a === '--wake-port') {
@@ -94,6 +137,13 @@ function parseArgs(args, usage) {
94
137
  out.wakeUrl = args[++i];
95
138
  else if (a === '--clear')
96
139
  out.clear = true;
140
+ else if (a === '--job-timeout') {
141
+ // Empty (or the word 'default') puts the runner back on the vault's
142
+ // default, which is a real answer and not a missing one; anything else
143
+ // is minutes, or a duration for an operator who thinks in hours.
144
+ const raw = (args[++i] ?? '').trim();
145
+ out.jobTimeout = raw === '' || raw === 'default' ? 'default' : parseTimeoutMinutes(raw);
146
+ }
97
147
  else if (a === '--save')
98
148
  out.save = true;
99
149
  else if (a === '--engine') {
@@ -150,11 +200,16 @@ async function runnerAddCmd(args, usage) {
150
200
  name: a.name,
151
201
  labels,
152
202
  allow: a.allow,
203
+ ...(typeof a.jobTimeout === 'number' ? { jobTimeoutMinutes: a.jobTimeout } : {}),
153
204
  ...(wake ? { wakeUrl: wake.url, wakeSecret: wake.secret } : {}),
154
205
  });
155
206
  console.log(`Registered runner ${data.name}`);
156
207
  console.log(` labels: ${data.labels.join(', ')}`);
157
208
  console.log(` serving: ${data.allow.join(', ')}`);
209
+ // A vault older than the setting reports no timeout, and saying nothing is
210
+ // better than reporting a default this CLI only assumes it has.
211
+ if (data.jobTimeout !== undefined)
212
+ console.log(` timeout: ${String(data.jobTimeout)} minutes per job`);
158
213
  if (wake)
159
214
  console.log(` wake: ${wake.url}`);
160
215
  console.log('');
@@ -191,6 +246,34 @@ async function runnerAddCmd(args, usage) {
191
246
  }
192
247
  console.log('');
193
248
  }
249
+ /**
250
+ * `mochi runner edit <name> --job-timeout <d>`: change what a registered runner
251
+ * is allowed to do, which for now is how long a job may run on it.
252
+ *
253
+ * Its labels and allow list are deliberately not here: both decide which jobs
254
+ * a machine may take, and changing them under a runner that is already trusted
255
+ * with a set of repositories is a re-registration rather than an edit.
256
+ */
257
+ async function runnerEditCmd(args, usage) {
258
+ const a = parseArgs(args, usage);
259
+ if (!a.name) {
260
+ console.error('Which runner? Usage: mochi runner edit <name> --job-timeout <minutes>');
261
+ process.exit(1);
262
+ }
263
+ if (a.jobTimeout === null) {
264
+ console.error("Nothing to change. Pass --job-timeout <minutes>, or --job-timeout '' for the default.");
265
+ process.exit(1);
266
+ }
267
+ const target = await (0, cli_api_1.remoteTarget)(a);
268
+ const data = await (0, cli_api_1.api)(target, 'PATCH', `/api/runners/${encodeURIComponent(a.name)}`, {
269
+ jobTimeoutMinutes: a.jobTimeout === 'default' ? null : a.jobTimeout,
270
+ });
271
+ const minutes = Number(data.jobTimeout);
272
+ console.log(data.jobTimeoutMinutes === null
273
+ ? `Runner ${a.name} is back on the vault's default job timeout of ${minutes} minutes.`
274
+ : `Jobs on ${a.name} now stop after ${minutes} minute${minutes === 1 ? '' : 's'}.`);
275
+ console.log('Applies to the next job it takes; one already running keeps the timeout it started with.');
276
+ }
194
277
  /**
195
278
  * `mochi runner wake <name>`: send the wake request, or change where it goes.
196
279
  *
@@ -292,6 +375,7 @@ is the usual reason a run sits at queued forever.`,
292
375
  r.name,
293
376
  `labels: ${r.labels.join(', ') || '(none)'}`,
294
377
  `serving: ${r.allow.join(', ') || '(none)'}`,
378
+ r.jobTimeout === undefined ? '' : `timeout: ${r.jobTimeout}m`,
295
379
  // A vault older than liveness reporting leaves both out, and saying
296
380
  // "seen never" of a runner that may be perfectly healthy would be
297
381
  // worse than saying nothing.
@@ -360,6 +444,11 @@ async function runnerRunCmd(args, usage) {
360
444
  images: { ...client_1.DEFAULT_IMAGES, ...(saved?.images ?? {}), ...a.images },
361
445
  workDir: a.workDir ?? saved?.workDir,
362
446
  network: a.network ?? saved?.network,
447
+ limits: {
448
+ memory: a.jobMemory ?? saved?.limits?.memory,
449
+ cpus: a.jobCpus ?? saved?.limits?.cpus,
450
+ pids: a.jobPids ?? saved?.limits?.pids,
451
+ },
363
452
  cacheDir: a.cacheDir ?? saved?.cacheDir,
364
453
  actionsUrl: a.actionsUrl ?? saved?.actionsUrl,
365
454
  actionCache: a.actionCache && (saved?.actionCache ?? true),
package/dist/scan.js CHANGED
@@ -33,10 +33,12 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.repoSiblingSuffixes = void 0;
36
+ exports.repoSiblingSuffixes = exports.MAX_NAME_LENGTH = void 0;
37
37
  exports.isValidName = isValidName;
38
38
  exports.isDotName = isDotName;
39
39
  exports.isValidUserName = isValidUserName;
40
+ exports.collectionCaseClash = collectionCaseClash;
41
+ exports.repoCaseClash = repoCaseClash;
40
42
  exports.reservedRepoSuffix = reservedRepoSuffix;
41
43
  exports.isBareRepo = isBareRepo;
42
44
  exports.displayName = displayName;
@@ -45,11 +47,13 @@ exports.listCollections = listCollections;
45
47
  exports.findRepo = findRepo;
46
48
  exports.siteDir = siteDir;
47
49
  exports.forkParent = forkParent;
50
+ exports.upstreamOf = upstreamOf;
48
51
  exports.repoDescription = repoDescription;
49
52
  const fs = __importStar(require("fs"));
50
53
  const path = __importStar(require("path"));
51
54
  const git_1 = require("./git");
52
55
  const layout_1 = require("./layout");
56
+ const source_1 = require("./source");
53
57
  // Names the UI owns as top-level path segments. None of these may ever be a
54
58
  // collection or repo name, since a collection is reached at /<collection> and
55
59
  // a repository at /<collection>/<repo>. The vault's own files are not among
@@ -67,6 +71,7 @@ const RESERVED_NAMES = new Set([
67
71
  'import',
68
72
  'admin',
69
73
  'settings',
74
+ 'topics',
70
75
  ]);
71
76
  function isValidName(name) {
72
77
  if (RESERVED_NAMES.has(name))
@@ -96,7 +101,41 @@ function isDotName(name) {
96
101
  * created, since only a repository may carry one.
97
102
  */
98
103
  function isValidUserName(name) {
99
- return isValidName(name) && !isDotName(name);
104
+ return isValidName(name) && !isDotName(name) && name.length <= exports.MAX_NAME_LENGTH;
105
+ }
106
+ /**
107
+ * The longest name a collection, repository, or user may be created under.
108
+ * Far below any filesystem limit on purpose: the interface has to render
109
+ * these, and the on-disk form carries suffixes (`<name>.git`, `<name>.issues`)
110
+ * that a name near the 255-byte filename limit would push over it. Enforced
111
+ * only where a name comes into being; reading stays permissive, so a vault
112
+ * that somehow holds a longer one still serves it.
113
+ */
114
+ exports.MAX_NAME_LENGTH = 100;
115
+ /**
116
+ * The existing collection whose name matches `name` apart from letter case, or
117
+ * null when none does (an exact match does not count). Creation refuses such a
118
+ * near-duplicate: two collections telling apart only by case are confusing in
119
+ * every listing and cannot coexist on a case-insensitive filesystem, which is
120
+ * where a vault copied to macOS or Windows would find itself.
121
+ */
122
+ function collectionCaseClash(root, name) {
123
+ const lower = name.toLowerCase();
124
+ for (const c of listCollections(root)) {
125
+ if (c.name !== name && c.name.toLowerCase() === lower)
126
+ return c.name;
127
+ }
128
+ return null;
129
+ }
130
+ /** As collectionCaseClash, for a repository name within one collection. */
131
+ function repoCaseClash(root, collection, name) {
132
+ const lower = name.toLowerCase();
133
+ for (const d of listRepoDirs(root, collection)) {
134
+ const existing = displayName(d);
135
+ if (existing !== name && existing.toLowerCase() === lower)
136
+ return existing;
137
+ }
138
+ return null;
100
139
  }
101
140
  // The directories a repository accumulates beside its bare repository, by the
102
141
  // suffix each one carries. Every feature has its own helper for the directory
@@ -218,6 +257,23 @@ function forkParent(dir) {
218
257
  return null;
219
258
  return { collection, repo };
220
259
  }
260
+ /**
261
+ * The URL outside this vault the repository was forked from, or null. Written
262
+ * by `mochi fork` as `mochi.upstream` and read the same way forkParent is:
263
+ * out of the config file directly, since this too is asked on page renders.
264
+ * A stored value that parseUpstream rejects reads as no upstream.
265
+ */
266
+ function upstreamOf(dir) {
267
+ let text;
268
+ try {
269
+ text = fs.readFileSync(path.join(dir, 'config'), 'utf8');
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ const m = text.match(/^\s*upstream\s*=\s*(\S+)\s*$/m);
275
+ return m ? (0, source_1.parseUpstream)(m[1]) : null;
276
+ }
221
277
  function repoDescription(dir) {
222
278
  try {
223
279
  const t = fs.readFileSync(path.join(dir, 'description'), 'utf8').trim();
package/dist/server.js CHANGED
@@ -60,15 +60,18 @@ const releases_1 = require("./releases");
60
60
  const lfsstore_1 = require("./lfsstore");
61
61
  const layout_1 = require("./layout");
62
62
  const logo_1 = require("./logo");
63
+ const maintenance_1 = require("./maintenance");
63
64
  const migrate_1 = require("./migrate");
64
65
  const perms_1 = require("./perms");
65
66
  const redirects_1 = require("./redirects");
66
67
  const scan_1 = require("./scan");
68
+ const topics_1 = require("./topics");
67
69
  const session_1 = require("./session");
68
70
  const site_1 = require("./site");
69
- const siteshost_1 = require("./siteshost");
71
+ const domains_1 = require("./domains");
70
72
  const assets_1 = require("./assets");
71
73
  const pagescript_1 = require("./pagescript");
74
+ const agescript_1 = require("./agescript");
72
75
  const themes_1 = require("./themes");
73
76
  const views = __importStar(require("./views"));
74
77
  const webops_1 = require("./webops");
@@ -130,6 +133,14 @@ function isCompressible(req, res) {
130
133
  * obtained from a signed-in reader who thought they were clicking something of
131
134
  * the framing page's. base-uri and form-action close the two ways injected
132
135
  * markup could redirect a relative URL or a form post without running script.
136
+ *
137
+ * form-action names github.com beside 'self' because browsers apply the
138
+ * directive to a redirect that follows a form submission, not only to the
139
+ * form's own target: the account page's "Link a GitHub account" form posts to
140
+ * this vault, whose answer is a redirect to GitHub's authorize page (see
141
+ * src/webops.ts), and without the entry the browser blocks that redirect. An
142
+ * injected form is not helped by it: posting to github.com carries nothing of
143
+ * the vault's, and the sign-in cookie in particular never leaves 'self'.
133
144
  */
134
145
  const FORGE_CSP = [
135
146
  "default-src 'self'",
@@ -140,13 +151,13 @@ const FORGE_CSP = [
140
151
  "connect-src 'self'",
141
152
  "object-src 'none'",
142
153
  "base-uri 'none'",
143
- "form-action 'self'",
154
+ "form-action 'self' https://github.com",
144
155
  "frame-ancestors 'self'",
145
156
  ].join('; ');
146
157
  function isRateExempt(root, req) {
147
158
  if (req.path.startsWith('/api/runner/'))
148
159
  return true;
149
- if ((0, siteshost_1.isUnderSitesHost)((0, config_1.loadConfig)(root).sites.host, req.hostname))
160
+ if ((0, domains_1.isSiteRequest)(root, req.hostname))
150
161
  return false;
151
162
  return req.path.startsWith('/assets/') || req.path === '/favicon.svg' || req.path === '/favicon.ico';
152
163
  }
@@ -167,8 +178,19 @@ function createApp(root) {
167
178
  for (const note of permNotes)
168
179
  console.log(`permissions: ${note}`);
169
180
  }
181
+ // And a vault from before force pushes were allowed, whose repositories
182
+ // would otherwise go on refusing them while every new one accepted them.
183
+ const unfrozen = (0, migrate_1.migratePushPolicy)(root);
184
+ if (unfrozen.length > 0) {
185
+ console.log(`push policy: force pushes now allowed on ${unfrozen.join(', ')}`);
186
+ }
170
187
  const app = (0, express_1.default)();
171
188
  app.disable('x-powered-by');
189
+ // Node's own querystring rather than the qs package express 4 defaults to.
190
+ // Nothing here reads a nested query (?a[b]=c), and qs is where express 4's
191
+ // open advisories live: the parser runs on every anonymous request, so the
192
+ // package is better not run at all than trusted to be patched in time.
193
+ app.set('query parser', 'simple');
172
194
  const config = (0, config_1.loadConfig)(root);
173
195
  // Behind a TLS proxy (Caddy, Fly, etc.) X-Forwarded-Proto/Host must win, or
174
196
  // clone URLs and Secure cookies would use the internal address. On a vault
@@ -182,7 +204,14 @@ function createApp(root) {
182
204
  // internet is trusted is a restart-worthy event in a way that changing a
183
205
  // colour scheme is not. The same goes for the limits below, which hold live
184
206
  // counts that cannot be rebuilt per request without discarding them.
185
- app.set('trust proxy', config.network.trustProxy);
207
+ //
208
+ // One hop, not `true`: with `true`, req.ip is the leftmost address in
209
+ // X-Forwarded-For, which is whatever the client wrote there when the proxy
210
+ // appends to the header rather than replacing it (Fly's and nginx's do),
211
+ // and every per-address limit would be the client's to choose again. One
212
+ // hop is the address the proxy itself saw, the last in the list, which is
213
+ // right both for a proxy that appends and for one that replaces (Caddy's).
214
+ app.set('trust proxy', config.network.trustProxy ? 1 : false);
186
215
  // Outgoing bytes, counted per repository and capped per day. The cap is read
187
216
  // through loadConfig on every call rather than captured here, unlike the rest
188
217
  // of the limits block: it is the one limit whose value an operator needs to
@@ -252,7 +281,8 @@ function createApp(root) {
252
281
  // Sites served from their own hostname are answered before every other route,
253
282
  // the asset routes included: otherwise /assets/style.css would give a site the
254
283
  // forge's stylesheet instead of its own, and /favicon.svg the forge's icon.
255
- // Nothing here runs unless a sites host is configured.
284
+ // Nothing here runs unless a sites host is configured or a custom domain is
285
+ // mapped in domains.json.
256
286
  (0, site_1.registerSiteHost)(app, root);
257
287
  // The forge's own pages may only be framed by the forge. A CSRF token and the
258
288
  // Origin check in src/session.ts refuse a request forged from somewhere else,
@@ -269,6 +299,14 @@ function createApp(root) {
269
299
  // ordinary for a static site and no business of the forge's.
270
300
  app.use((_req, res, next) => {
271
301
  res.setHeader('Content-Security-Policy', FORGE_CSP);
302
+ // On every response, not only the raw file routes that already carried it:
303
+ // nothing the forge serves relies on type sniffing, so there is no reason
304
+ // to leave any response open to it.
305
+ res.setHeader('X-Content-Type-Options', 'nosniff');
306
+ // The browser's own default, stated explicitly so a link out of a private
307
+ // vault never carries a path in its referrer whatever the browser's
308
+ // settings say.
309
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
272
310
  next();
273
311
  });
274
312
  // Then, before any route that resolves a name: an address a rename left
@@ -303,6 +341,19 @@ function createApp(root) {
303
341
  .set('X-Content-Type-Options', 'nosniff')
304
342
  .send(script.body);
305
343
  });
344
+ // The encrypted-file script, on the same terms again. It is ~300 KB of
345
+ // vendored cryptography plus its glue, which is why only the pages that
346
+ // need it link it (see PageOpts.ageScript) and why the immutable caching
347
+ // matters more here than anywhere.
348
+ app.get('/assets/age.js', (req, res) => {
349
+ const script = (0, agescript_1.ageScript)();
350
+ const fresh = String(req.query.v ?? '') === script.tag;
351
+ res
352
+ .type('text/javascript')
353
+ .set('Cache-Control', fresh ? 'public, max-age=31536000, immutable' : 'no-cache')
354
+ .set('X-Content-Type-Options', 'nosniff')
355
+ .send(script.body);
356
+ });
306
357
  app.get('/assets/hl.css', (req, res) => {
307
358
  // Code colours are a whole stylesheet rather than a set of tokens, so the
308
359
  // reader's theme picks a file instead of an attribute. The name still
@@ -321,23 +372,26 @@ function createApp(root) {
321
372
  }
322
373
  res.type('text/css').set('Cache-Control', 'public, max-age=86400').send(css);
323
374
  });
324
- // Every repository the interface would show this viewer anyway, as a list
325
- // of names, for the jump box to search without a round trip per keystroke.
326
- // It says no more than the front page already does to the same eyes: a
327
- // private repository is listed only for a viewer with a role there, which
328
- // is also why the answer is marked private to shared caches. It is not
329
- // /api/repos, which is the authenticated interface for programs and carries
330
- // much more per repository than a name.
375
+ // Every repository the interface would show this viewer anyway, as names
376
+ // and topics, for the jump box to search without a round trip per
377
+ // keystroke. It says no more than the front page already does to the same
378
+ // eyes: a private repository is listed only for a viewer with a role there,
379
+ // which is also why the answer is marked private to shared caches. It is
380
+ // not /api/repos, which is the authenticated interface for programs and
381
+ // carries much more per repository. Topics ride along only where a
382
+ // repository has them, so the common entry stays one name.
331
383
  app.get('/assets/repos.json', (req, res) => {
332
384
  const viewer = (0, session_1.getViewer)(req, root);
333
385
  const repos = [];
334
386
  for (const c of (0, scan_1.listCollections)(root)) {
335
387
  for (const d of (0, scan_1.listRepoDirs)(root, c.name)) {
336
388
  const name = (0, scan_1.displayName)(d);
337
- if ((0, perms_1.repoRole)(root, viewer?.auth ?? null, { collection: c.name, name, dir: (0, layout_1.repoPath)(root, c.name, d) }) === null) {
389
+ const dir = (0, layout_1.repoPath)(root, c.name, d);
390
+ if ((0, perms_1.repoRole)(root, viewer?.auth ?? null, { collection: c.name, name, dir }) === null) {
338
391
  continue;
339
392
  }
340
- repos.push(`${c.name}/${name}`);
393
+ const topics = (0, topics_1.repoTopics)(dir);
394
+ repos.push(topics.length ? { name: `${c.name}/${name}`, topics } : { name: `${c.name}/${name}` });
341
395
  }
342
396
  }
343
397
  res.set('Cache-Control', 'private, no-cache').json(repos);
@@ -378,11 +432,24 @@ function createApp(root) {
378
432
  // The CI engine plans and schedules workflow runs; jobs execute on runners
379
433
  // elsewhere (mochi runner run), never in this process.
380
434
  const engine = new engine_1.CiEngine(root, () => (0, config_1.loadConfig)(root).ci);
435
+ // Unreachable objects, from a force push or a branch deleted or a history
436
+ // rewritten, are collected on a slow timer with a generous grace period. See
437
+ // src/maintenance.ts for why it is safe to run beside live traffic.
438
+ (0, maintenance_1.startMaintenance)(root);
381
439
  // A runner that stops when it has nothing to do cannot be told that it has
382
440
  // something to do, so the vault starts it: this watches for a queued job
383
441
  // whose runner is not connected and pokes that runner's wake address.
384
442
  // Runners without one are unaffected, which is all of them by default.
385
443
  (0, wake_1.startWakeDispatcher)(root, engine);
444
+ // Sliding sessions: a session cookie seen in the second half of its life is
445
+ // re-issued with a fresh expiry, so regular use never runs into a sign-out.
446
+ // Registered after the asset routes above on purpose -- several of them are
447
+ // publicly cacheable, and a Set-Cookie must never ride on a response a
448
+ // shared cache may store and replay to someone else.
449
+ app.use((req, res, next) => {
450
+ (0, session_1.renewSession)(req, res, root);
451
+ next();
452
+ });
386
453
  // Registration order matters: the API and the UI-owned top-level paths
387
454
  // (/login, /new, /admin, ...) come before the generic /:collection and /:collection/:repo
388
455
  // browse routes, and more-specific wildcard routes before their prefixes.
@@ -405,20 +472,34 @@ function createApp(root) {
405
472
  res.status(404).type('html').send(views.errorPage(404, 'Page not found', { viewer: (0, session_1.getViewer)(req, root) }));
406
473
  });
407
474
  app.use((err, req, res, _next) => {
408
- console.error(err);
409
- if (!res.headersSent) {
410
- let viewer = null;
411
- try {
412
- viewer = (0, session_1.getViewer)(req, root);
413
- }
414
- catch {
415
- viewer = null;
416
- }
417
- res.status(500).type('html').send(views.errorPage(500, 'Internal server error', { viewer }));
418
- }
419
- else {
475
+ if (res.headersSent) {
476
+ console.error(err);
420
477
  res.end();
478
+ return;
479
+ }
480
+ let viewer = null;
481
+ try {
482
+ viewer = (0, session_1.getViewer)(req, root);
483
+ }
484
+ catch {
485
+ viewer = null;
421
486
  }
487
+ // The body parsers throw typed errors carrying the status they deserve --
488
+ // a body over the route's limit, a malformed encoding, an aborted upload.
489
+ // Those are refusals of the request, not failures of the server, so they
490
+ // get their status and a sentence a person can act on rather than the bare
491
+ // 500 below.
492
+ const status = err.statusCode ?? err.status;
493
+ const type = err.type;
494
+ if (typeof status === 'number' && status >= 400 && status < 500) {
495
+ const message = type === 'entity.too.large'
496
+ ? 'What you submitted is larger than this form accepts. Files this size can go through the Upload files form, or a git push.'
497
+ : 'The request could not be read; go back and try again.';
498
+ res.status(status).type('html').send(views.errorPage(status, message, { viewer }));
499
+ return;
500
+ }
501
+ console.error(err);
502
+ res.status(500).type('html').send(views.errorPage(500, 'Internal server error', { viewer }));
422
503
  });
423
504
  return app;
424
505
  }