@magland/mochi 0.3.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 (116) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +108 -0
  3. package/dist/ansi.js +174 -0
  4. package/dist/api/admin.js +416 -0
  5. package/dist/api/auth.js +166 -0
  6. package/dist/api/backup.js +598 -0
  7. package/dist/api/ci.js +336 -0
  8. package/dist/api/contents.js +339 -0
  9. package/dist/api/issues.js +165 -0
  10. package/dist/api/pulls.js +244 -0
  11. package/dist/api/releases.js +83 -0
  12. package/dist/api/repos.js +156 -0
  13. package/dist/api/write.js +518 -0
  14. package/dist/api.js +326 -0
  15. package/dist/assets.js +29 -0
  16. package/dist/atom.js +32 -0
  17. package/dist/atomic.js +171 -0
  18. package/dist/avatar.js +81 -0
  19. package/dist/browse.js +630 -0
  20. package/dist/build-info.json +4 -0
  21. package/dist/ci/actionref.js +86 -0
  22. package/dist/ci/api.js +829 -0
  23. package/dist/ci/artifacts.js +201 -0
  24. package/dist/ci/dispatch.js +30 -0
  25. package/dist/ci/engine.js +1321 -0
  26. package/dist/ci/expr.js +526 -0
  27. package/dist/ci/manual.js +199 -0
  28. package/dist/ci/present.js +82 -0
  29. package/dist/ci/protocol.js +6 -0
  30. package/dist/ci/runners.js +256 -0
  31. package/dist/ci/runs.js +208 -0
  32. package/dist/ci/trigger.js +28 -0
  33. package/dist/ci/views.js +441 -0
  34. package/dist/ci/wake.js +194 -0
  35. package/dist/ci/web.js +617 -0
  36. package/dist/ci/workflow.js +436 -0
  37. package/dist/cli/admin-cmd.js +324 -0
  38. package/dist/cli/api-cmd.js +128 -0
  39. package/dist/cli/backup-cmd.js +1500 -0
  40. package/dist/cli/exit.js +69 -0
  41. package/dist/cli/input.js +64 -0
  42. package/dist/cli/issue-cmd.js +243 -0
  43. package/dist/cli/output.js +93 -0
  44. package/dist/cli/parse.js +317 -0
  45. package/dist/cli/pr-cmd.js +289 -0
  46. package/dist/cli/release-cmd.js +171 -0
  47. package/dist/cli/repo-cmd.js +763 -0
  48. package/dist/cli/repo.js +101 -0
  49. package/dist/cli/run-cmd.js +438 -0
  50. package/dist/cli/target.js +54 -0
  51. package/dist/cli-api.js +84 -0
  52. package/dist/compare.js +111 -0
  53. package/dist/config.js +212 -0
  54. package/dist/credentials.js +235 -0
  55. package/dist/deploy-cli.js +859 -0
  56. package/dist/deploy-runner-cli.js +592 -0
  57. package/dist/diff.js +171 -0
  58. package/dist/discussion.js +253 -0
  59. package/dist/egress.js +559 -0
  60. package/dist/filecache.js +68 -0
  61. package/dist/find.js +162 -0
  62. package/dist/forms.js +737 -0
  63. package/dist/git.js +547 -0
  64. package/dist/githttp.js +428 -0
  65. package/dist/html.js +87 -0
  66. package/dist/icons.js +101 -0
  67. package/dist/import-cli.js +316 -0
  68. package/dist/index.js +752 -0
  69. package/dist/issues.js +308 -0
  70. package/dist/issueweb.js +447 -0
  71. package/dist/job-cli.js +197 -0
  72. package/dist/jobtoken.js +96 -0
  73. package/dist/languages.js +383 -0
  74. package/dist/layout.js +100 -0
  75. package/dist/lfs.js +438 -0
  76. package/dist/lfsstore.js +425 -0
  77. package/dist/limit.js +259 -0
  78. package/dist/logo.js +61 -0
  79. package/dist/markdown.js +382 -0
  80. package/dist/migrate.js +334 -0
  81. package/dist/multipart.js +90 -0
  82. package/dist/ops.js +869 -0
  83. package/dist/pagescript.js +465 -0
  84. package/dist/perms.js +370 -0
  85. package/dist/pointer.js +55 -0
  86. package/dist/profile.js +106 -0
  87. package/dist/pulls.js +320 -0
  88. package/dist/pullweb.js +461 -0
  89. package/dist/redirects.js +455 -0
  90. package/dist/releases.js +435 -0
  91. package/dist/render.js +233 -0
  92. package/dist/runner/actions.js +448 -0
  93. package/dist/runner/client.js +428 -0
  94. package/dist/runner/context.js +247 -0
  95. package/dist/runner/docker.js +197 -0
  96. package/dist/runner/externals.js +175 -0
  97. package/dist/runner/job.js +290 -0
  98. package/dist/runner/manual-run.js +272 -0
  99. package/dist/runner/overrides.js +554 -0
  100. package/dist/runner/steps.js +571 -0
  101. package/dist/runner/wake.js +84 -0
  102. package/dist/runner-cli.js +405 -0
  103. package/dist/scan.js +231 -0
  104. package/dist/server.js +424 -0
  105. package/dist/session.js +267 -0
  106. package/dist/site.js +259 -0
  107. package/dist/siteshost.js +94 -0
  108. package/dist/source.js +90 -0
  109. package/dist/style.js +1295 -0
  110. package/dist/themes.js +369 -0
  111. package/dist/vault.js +442 -0
  112. package/dist/version.js +88 -0
  113. package/dist/views.js +1007 -0
  114. package/dist/web.js +182 -0
  115. package/dist/webops.js +1402 -0
  116. package/package.json +71 -0
package/dist/ci/api.js ADDED
@@ -0,0 +1,829 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.registerCiApi = registerCiApi;
40
+ const express_1 = __importDefault(require("express"));
41
+ const fs = __importStar(require("fs"));
42
+ const path = __importStar(require("path"));
43
+ const scan_1 = require("../scan");
44
+ const site_1 = require("../site");
45
+ const perms_1 = require("../perms");
46
+ const vault_1 = require("../vault");
47
+ const web_1 = require("../web");
48
+ const artifacts_1 = require("./artifacts");
49
+ const manual_1 = require("./manual");
50
+ const runners_1 = require("./runners");
51
+ const wake_1 = require("./wake");
52
+ // The runner-facing API and the admin API for runner registration.
53
+ //
54
+ // Runner endpoints are authenticated by a runner token (Bearer), never by a
55
+ // session cookie and never by a user token. Registration endpoints are the
56
+ // mirror image: a user token with standing over the runner, never a runner token. The two
57
+ // credential kinds do not overlap at any endpoint.
58
+ const ACQUIRE_TIMEOUT_MS = 25 * 1000;
59
+ const MAX_LOG_BODY = 4 * 1024 * 1024;
60
+ function registerCiApi(app, root, engine, authLimiter) {
61
+ const json = express_1.default.json({ limit: '1mb' });
62
+ function apiError(res, status, message) {
63
+ res.status(status).json({ error: message });
64
+ }
65
+ /**
66
+ * Where a repository's site is served, which only the vault knows: with a
67
+ * sites hostname configured each site has an origin of its own and sits at
68
+ * its root, and without one it is a path under the forge host. A runner
69
+ * computing this from the server URL gets the second answer always, and a
70
+ * build told the wrong base path produces a site whose every asset URL is
71
+ * wrong, so the answer travels with the job rather than being guessed.
72
+ */
73
+ function siteOf(req, collection, repo) {
74
+ const own = (0, site_1.siteHostUrl)(root, req, collection, repo);
75
+ if (own)
76
+ return { url: `${own}/`, basePath: '/' };
77
+ const p = `/${encodeURIComponent(collection)}/${encodeURIComponent(repo)}/site`;
78
+ return { url: `${(0, web_1.baseUrlOf)(req)}${p}/`, basePath: p };
79
+ }
80
+ // A missing header is not a failed attempt and is not charged; a wrong token
81
+ // is. Nothing here throttles a working credential, which matters because the
82
+ // runner calls these endpoints continuously with a valid one.
83
+ function denyTooMany(res, retryAfter) {
84
+ res.setHeader('Retry-After', String(retryAfter));
85
+ apiError(res, 429, 'too many failed authentication attempts; try again later');
86
+ }
87
+ function requireAdmin(req, res) {
88
+ const state = (0, vault_1.loadVault)(root);
89
+ if (state.status !== 'ok') {
90
+ apiError(res, 500, 'vault unavailable');
91
+ return null;
92
+ }
93
+ const m = (req.get('authorization') ?? '').match(/^bearer\s+(.+)$/i);
94
+ if (!m) {
95
+ apiError(res, 401, 'missing bearer token: send Authorization: Bearer <token>');
96
+ return null;
97
+ }
98
+ const allowed = authLimiter.allow(req, null);
99
+ if (!allowed.ok) {
100
+ denyTooMany(res, allowed.retryAfter);
101
+ return null;
102
+ }
103
+ const auth = (0, vault_1.authenticateToken)(state.vault, m[1].trim());
104
+ if (!auth) {
105
+ authLimiter.fail(req, null);
106
+ apiError(res, 401, 'invalid token');
107
+ return null;
108
+ }
109
+ return auth;
110
+ }
111
+ function requireRunner(req, res) {
112
+ const m = (req.get('authorization') ?? '').match(/^bearer\s+(.+)$/i);
113
+ if (!m) {
114
+ apiError(res, 401, 'missing runner token');
115
+ return null;
116
+ }
117
+ const allowed = authLimiter.allow(req, null);
118
+ if (!allowed.ok) {
119
+ denyTooMany(res, allowed.retryAfter);
120
+ return null;
121
+ }
122
+ const token = m[1].trim();
123
+ // A manual session speaks these endpoints too, once it holds a job: the
124
+ // lease plus the runner name `manual:<id>` scope it to exactly that job,
125
+ // the same way a registered runner is scoped, so the endpoints need not
126
+ // know which kind is talking.
127
+ if ((0, manual_1.looksLikeSessionToken)(token)) {
128
+ const session = engine.authenticateManualSession(token);
129
+ if (!session) {
130
+ authLimiter.fail(req, null);
131
+ apiError(res, 401, 'this manual session is no longer recognized; the run has likely finished');
132
+ return null;
133
+ }
134
+ return {
135
+ name: (0, manual_1.sessionRunnerName)(session.grant),
136
+ runner: {
137
+ hash: '',
138
+ labels: [],
139
+ allow: [`${session.collection}/${session.repo}`],
140
+ createdBy: session.grant.mintedBy,
141
+ createdAt: session.grant.mintedAt,
142
+ },
143
+ };
144
+ }
145
+ const auth = (0, runners_1.authenticateRunner)(root, token);
146
+ if (!auth) {
147
+ authLimiter.fail(req, null);
148
+ apiError(res, 401, 'invalid runner token');
149
+ return null;
150
+ }
151
+ // Every runner endpoint passes through here, so this is the one place that
152
+ // sees a runner alive, whether it is polling for work or reporting on a
153
+ // job it already has.
154
+ (0, runners_1.noteRunnerSeen)(auth.name);
155
+ return auth;
156
+ }
157
+ // ---- runner registration (admin) ----
158
+ app.get('/api/runners', json, (req, res) => {
159
+ const auth = requireAdmin(req, res);
160
+ if (!auth)
161
+ return;
162
+ // A site admin sees every runner; anyone else sees the runners they could
163
+ // administer, which are the ones confined to collections they own.
164
+ const registry = (0, runners_1.loadRunners)(root);
165
+ const visible = Object.entries(registry.runners).filter(([, r]) => (0, perms_1.isSiteAdmin)(auth) || (0, perms_1.canAdminRunnerGlobs)(root, auth, r.allow));
166
+ const load = engine.runnerLoad();
167
+ res.json({
168
+ runners: visible.map(([name, r]) => ({
169
+ name,
170
+ labels: r.labels,
171
+ allow: r.allow,
172
+ createdBy: r.createdBy,
173
+ createdAt: r.createdAt,
174
+ // Registration says what a runner may do; these two say whether it is
175
+ // there at all and what it is doing, which is what a caller looking at
176
+ // a run that has not started actually wants to know.
177
+ lastSeen: (0, runners_1.runnerLastSeen)(name),
178
+ running: load.running[name] ?? null,
179
+ // The address, never the secret: a caller deciding whether a runner
180
+ // can be started needs to know that it can be, not what to send.
181
+ wakeUrl: r.wakeUrl ?? null,
182
+ })),
183
+ queued: load.queued,
184
+ });
185
+ });
186
+ /**
187
+ * The wake address in a request body, or a message saying what is wrong.
188
+ *
189
+ * Both halves or neither: an address with no secret is one the vault cannot
190
+ * authenticate itself to, and a secret with no address is nowhere to send
191
+ * it. The URL is checked for being a URL and for being HTTP, which is as
192
+ * far as this can go: where it points is the administrator's business, and
193
+ * they are already trusted with a runner that executes repository code.
194
+ */
195
+ function wakeFrom(body) {
196
+ const url = body.wakeUrl;
197
+ const secret = body.wakeSecret;
198
+ if (url === undefined && secret === undefined)
199
+ return { wake: null };
200
+ if (typeof url !== 'string' || typeof secret !== 'string' || !url || !secret) {
201
+ return { error: '"wakeUrl" and "wakeSecret" must be given together, as non-empty strings' };
202
+ }
203
+ if (secret.length > 500)
204
+ return { error: '"wakeSecret" is too long' };
205
+ let parsed;
206
+ try {
207
+ parsed = new URL(url);
208
+ }
209
+ catch {
210
+ return { error: `"wakeUrl" is not a URL: ${url}` };
211
+ }
212
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
213
+ return { error: '"wakeUrl" must be an http or https URL' };
214
+ }
215
+ return { wake: { url, secret } };
216
+ }
217
+ app.post('/api/runners', json, (req, res) => {
218
+ const auth = requireAdmin(req, res);
219
+ if (!auth)
220
+ return;
221
+ const body = (req.body ?? {});
222
+ const name = typeof body.name === 'string' ? body.name : '';
223
+ if (!(0, scan_1.isValidName)(name)) {
224
+ apiError(res, 400, 'a valid "name" is required');
225
+ return;
226
+ }
227
+ const strings = (v) => {
228
+ if (v === undefined)
229
+ return [];
230
+ if (Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0 && s.length < 200)) {
231
+ return v;
232
+ }
233
+ return null;
234
+ };
235
+ const labels = strings(body.labels);
236
+ const allow = strings(body.allow);
237
+ if (!labels || !allow) {
238
+ apiError(res, 400, '"labels" and "allow" must be lists of strings');
239
+ return;
240
+ }
241
+ if (allow.length === 0) {
242
+ apiError(res, 400, 'a runner needs at least one --allow glob saying which repositories it serves');
243
+ return;
244
+ }
245
+ if (labels.includes(manual_1.MANUAL_LABEL)) {
246
+ apiError(res, 400, `'${manual_1.MANUAL_LABEL}' is a reserved label: a job that names it runs only through a command pasted from its run page, never on a registered runner`);
247
+ return;
248
+ }
249
+ // A runner may take jobs for every repository its allow list covers, and
250
+ // those jobs execute repository-controlled code on the runner's machine.
251
+ // Registering one therefore demands ownership of every collection in that set.
252
+ if (!(0, perms_1.canAdminRunnerGlobs)(root, auth, allow)) {
253
+ apiError(res, 403, 'you must own every collection this runner serves; a site admin may manage any runner');
254
+ return;
255
+ }
256
+ if ((0, runners_1.loadRunners)(root).runners[name]) {
257
+ apiError(res, 409, `a runner named ${name} is already registered; remove it first`);
258
+ return;
259
+ }
260
+ const wake = wakeFrom(body);
261
+ if ('error' in wake) {
262
+ apiError(res, 400, wake.error);
263
+ return;
264
+ }
265
+ const { token, runner } = (0, runners_1.registerRunner)(root, name, {
266
+ labels: labels.length ? labels : ['ubuntu-latest'],
267
+ allow,
268
+ createdBy: auth.username,
269
+ wake: wake.wake,
270
+ });
271
+ res.json({ name, token, labels: runner.labels, allow: runner.allow, wakeUrl: runner.wakeUrl ?? null });
272
+ });
273
+ /**
274
+ * Issue a new token for a runner, invalidating the one it had.
275
+ *
276
+ * The web interface has had this since runners did, and the API not having
277
+ * it meant that the one way to give a runner a token nobody holds any more
278
+ * was a browser. `mochi deploy fly runner` needs exactly that: a runner
279
+ * registered by an earlier deploy has a token that only the machine knows,
280
+ * and a machine being rebuilt has to be given one it can hold.
281
+ */
282
+ app.post('/api/runners/:name/token', json, (req, res) => {
283
+ const auth = requireAdmin(req, res);
284
+ if (!auth)
285
+ return;
286
+ const name = req.params.name;
287
+ const existing = (0, runners_1.loadRunners)(root).runners[name];
288
+ if (!existing) {
289
+ apiError(res, 404, `no runner named ${name}`);
290
+ return;
291
+ }
292
+ if (!(0, perms_1.canAdminRunnerGlobs)(root, auth, existing.allow)) {
293
+ apiError(res, 403, 'you must own every collection this runner serves; a site admin may manage any runner');
294
+ return;
295
+ }
296
+ const issued = (0, runners_1.regenerateRunnerToken)(root, name);
297
+ if (!issued) {
298
+ apiError(res, 404, `no runner named ${name}`);
299
+ return;
300
+ }
301
+ res.json({ name, token: issued.token, labels: issued.runner.labels, allow: issued.runner.allow });
302
+ });
303
+ /**
304
+ * Point a runner's wake address somewhere, or clear it with an empty body.
305
+ *
306
+ * Separate from registration because the app that will run a runner usually
307
+ * does not exist until after the runner is registered: `mochi deploy fly
308
+ * runner` needs the token to put in the machine's secrets before it can know
309
+ * the URL that starts the machine.
310
+ */
311
+ app.put('/api/runners/:name/wake', json, (req, res) => {
312
+ const auth = requireAdmin(req, res);
313
+ if (!auth)
314
+ return;
315
+ const name = req.params.name;
316
+ const existing = (0, runners_1.loadRunners)(root).runners[name];
317
+ if (!existing) {
318
+ apiError(res, 404, `no runner named ${name}`);
319
+ return;
320
+ }
321
+ if (!(0, perms_1.canAdminRunnerGlobs)(root, auth, existing.allow)) {
322
+ apiError(res, 403, 'you must own every collection this runner serves; a site admin may manage any runner');
323
+ return;
324
+ }
325
+ const wake = wakeFrom((req.body ?? {}));
326
+ if ('error' in wake) {
327
+ apiError(res, 400, wake.error);
328
+ return;
329
+ }
330
+ const runner = (0, runners_1.setRunnerWake)(root, name, wake.wake);
331
+ res.json({ name, wakeUrl: runner?.wakeUrl ?? null });
332
+ });
333
+ /**
334
+ * Send this runner's wake request now, and report what came back.
335
+ *
336
+ * The vault sends it rather than the caller because the vault is the only
337
+ * party that has the secret; and it is worth being able to ask for, since
338
+ * the alternative way to test a wake address is to queue a job and watch
339
+ * whether anything happens.
340
+ */
341
+ app.post('/api/runners/:name/wake', json, async (req, res) => {
342
+ const auth = requireAdmin(req, res);
343
+ if (!auth)
344
+ return;
345
+ const name = req.params.name;
346
+ const existing = (0, runners_1.loadRunners)(root).runners[name];
347
+ if (!existing) {
348
+ apiError(res, 404, `no runner named ${name}`);
349
+ return;
350
+ }
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');
353
+ return;
354
+ }
355
+ const wake = (0, wake_1.wakeOf)(existing);
356
+ if (!wake) {
357
+ apiError(res, 400, `runner ${name} has no wake address, so there is nothing to start it`);
358
+ return;
359
+ }
360
+ const started = Date.now();
361
+ try {
362
+ await (0, wake_1.sendWake)(wake);
363
+ }
364
+ catch (e) {
365
+ apiError(res, 502, `${wake.url} did not answer: ${e instanceof Error ? e.message : String(e)}`);
366
+ return;
367
+ }
368
+ res.json({ name, wakeUrl: wake.url, woke: true, seconds: Math.round((Date.now() - started) / 1000) });
369
+ });
370
+ app.delete('/api/runners/:name', (req, res) => {
371
+ const auth = requireAdmin(req, res);
372
+ if (!auth)
373
+ return;
374
+ const name = req.params.name;
375
+ const registry = (0, runners_1.loadRunners)(root);
376
+ const existing = registry.runners[name];
377
+ if (!existing) {
378
+ apiError(res, 404, `no runner named ${name}`);
379
+ return;
380
+ }
381
+ if (!(0, perms_1.canAdminRunnerGlobs)(root, auth, existing.allow)) {
382
+ apiError(res, 403, 'you must own every collection this runner serves; a site admin may manage any runner');
383
+ return;
384
+ }
385
+ (0, runners_1.removeRunner)(root, name);
386
+ res.json({ name, removed: true });
387
+ });
388
+ // ---- the runner protocol ----
389
+ app.post('/api/runner/acquire', json, async (req, res) => {
390
+ const auth = requireRunner(req, res);
391
+ if (!auth)
392
+ return;
393
+ const body = (req.body ?? {});
394
+ const requested = Array.isArray(body.labels) ? body.labels.filter((l) => typeof l === 'string') : [];
395
+ // A runner may narrow its registered labels per call but never widen
396
+ // them: the registry is the authority on what it may claim to be.
397
+ const labels = requested.filter((l) => auth.runner.labels.includes(l));
398
+ if (labels.length === 0) {
399
+ res.status(204).end();
400
+ return;
401
+ }
402
+ // Detect a runner that hangs up while we hold the poll open. Note that
403
+ // this must watch the response, not the request: a request whose body has
404
+ // been fully read emits 'close' immediately, long before the client goes
405
+ // away, and treating that as a disconnect would cancel every job at the
406
+ // moment it was leased.
407
+ let closed = false;
408
+ const gone = new AbortController();
409
+ res.on('close', () => {
410
+ if (!res.writableEnded) {
411
+ closed = true;
412
+ gone.abort();
413
+ }
414
+ });
415
+ const spec = await engine.waitForJob(auth.name, labels, auth.runner.allow, (0, web_1.baseUrlOf)(req), ACQUIRE_TIMEOUT_MS, gone.signal);
416
+ if (!spec) {
417
+ if (!closed)
418
+ res.status(204).end();
419
+ return;
420
+ }
421
+ spec.site = siteOf(req, spec.address.collection, spec.address.repo);
422
+ if (closed) {
423
+ // The runner hung up while we were leasing; release it immediately so
424
+ // the job does not wait out a lease expiry with nobody running it.
425
+ engine.reportStatus(spec.address.collection, spec.address.repo, spec.address.run, spec.address.job, {
426
+ lease: spec.lease,
427
+ runner: auth.name,
428
+ status: 'completed',
429
+ conclusion: 'cancelled',
430
+ });
431
+ return;
432
+ }
433
+ res.json(spec);
434
+ });
435
+ // ---- the manual protocol ----
436
+ //
437
+ // Three endpoints carry what is particular to a pasted command: redeeming
438
+ // it, acquiring the run's manual jobs, and handing one back unrun. The rest
439
+ // of a manual session's traffic (heartbeats, logs, status, artifacts) goes
440
+ // through the job endpoints below, authenticated by the session token that
441
+ // requireRunner also accepts.
442
+ function runSummary(run) {
443
+ if (!run)
444
+ return null;
445
+ return {
446
+ number: run.number,
447
+ workflowName: run.workflowName,
448
+ status: run.status,
449
+ conclusion: run.conclusion ?? null,
450
+ };
451
+ }
452
+ app.post('/api/manual/redeem', json, (req, res) => {
453
+ const body = (req.body ?? {});
454
+ const token = typeof body.token === 'string' ? body.token.trim() : '';
455
+ const host = typeof body.host === 'string' ? body.host : '';
456
+ if (!token) {
457
+ apiError(res, 400, 'a "token" is required: the one the pasted command carries');
458
+ return;
459
+ }
460
+ const allowed = authLimiter.allow(req, null);
461
+ if (!allowed.ok) {
462
+ denyTooMany(res, allowed.retryAfter);
463
+ return;
464
+ }
465
+ const redeemed = engine.redeemManual(token, host);
466
+ if (!redeemed) {
467
+ authLimiter.fail(req, null);
468
+ apiError(res, 401, 'this command has expired, was already used, or its run has finished; mint a fresh one from the run page');
469
+ return;
470
+ }
471
+ res.json({
472
+ sessionToken: redeemed.sessionToken,
473
+ collection: redeemed.collection,
474
+ repo: redeemed.repo,
475
+ run: {
476
+ number: redeemed.run.number,
477
+ workflowName: redeemed.run.workflowName,
478
+ refName: redeemed.run.refName,
479
+ sha: redeemed.run.sha,
480
+ status: redeemed.run.status,
481
+ },
482
+ jobs: redeemed.jobs.map((j) => ({
483
+ id: j.id,
484
+ key: j.key,
485
+ name: j.name,
486
+ status: j.status,
487
+ conclusion: j.conclusion ?? null,
488
+ runsOn: j.runsOn,
489
+ })),
490
+ });
491
+ });
492
+ function requireManualSession(req, res) {
493
+ const m = (req.get('authorization') ?? '').match(/^bearer\s+(.+)$/i);
494
+ if (!m) {
495
+ apiError(res, 401, 'missing session token');
496
+ return null;
497
+ }
498
+ const allowed = authLimiter.allow(req, null);
499
+ if (!allowed.ok) {
500
+ denyTooMany(res, allowed.retryAfter);
501
+ return null;
502
+ }
503
+ const session = engine.authenticateManualSession(m[1].trim());
504
+ if (!session) {
505
+ authLimiter.fail(req, null);
506
+ apiError(res, 401, 'this manual session is no longer recognized; the run has likely finished');
507
+ return null;
508
+ }
509
+ return session;
510
+ }
511
+ app.post('/api/manual/acquire', json, async (req, res) => {
512
+ const session = requireManualSession(req, res);
513
+ if (!session)
514
+ return;
515
+ const body = (req.body ?? {});
516
+ const filter = typeof body.job === 'string' && body.job !== '' ? body.job : null;
517
+ if (!session.active) {
518
+ res.status(410).json({
519
+ reason: session.run?.status === 'completed'
520
+ ? `the run finished: ${session.run.conclusion ?? 'completed'}`
521
+ : 'the run no longer exists',
522
+ run: runSummary(session.run),
523
+ });
524
+ return;
525
+ }
526
+ // Same disconnect-watching as the runner acquire above, released the
527
+ // manual way: a job leased to a session that hung up before hearing about
528
+ // it goes back to waiting for a command, since nothing has executed.
529
+ let closed = false;
530
+ const gone = new AbortController();
531
+ res.on('close', () => {
532
+ if (!res.writableEnded) {
533
+ closed = true;
534
+ gone.abort();
535
+ }
536
+ });
537
+ const result = await engine.waitForManualJob(session.collection, session.repo, session.n, session.grant, filter, (0, web_1.baseUrlOf)(req), ACQUIRE_TIMEOUT_MS, gone.signal);
538
+ if (result.kind === 'wait') {
539
+ if (!closed)
540
+ res.status(204).end();
541
+ return;
542
+ }
543
+ if (result.kind === 'done') {
544
+ if (!closed)
545
+ res.status(410).json({ reason: result.reason, run: runSummary(result.run) });
546
+ return;
547
+ }
548
+ const spec = result.spec;
549
+ spec.site = siteOf(req, spec.address.collection, spec.address.repo);
550
+ if (closed) {
551
+ engine.releaseManualJob(spec.address.collection, spec.address.repo, spec.address.run, spec.address.job, spec.lease, (0, manual_1.sessionRunnerName)(session.grant));
552
+ return;
553
+ }
554
+ res.json(spec);
555
+ });
556
+ app.post('/api/manual/jobs/:collection/:repo/:run/:job/release', json, (req, res) => {
557
+ const session = requireManualSession(req, res);
558
+ if (!session)
559
+ return;
560
+ const a = addressOf(req);
561
+ if (!a) {
562
+ apiError(res, 400, 'invalid job address');
563
+ return;
564
+ }
565
+ const ok = engine.releaseManualJob(a.collection, a.repo, a.run, a.job, leaseOf(req), (0, manual_1.sessionRunnerName)(session.grant));
566
+ if (!ok) {
567
+ apiError(res, 409, 'the lease on this job is no longer valid');
568
+ return;
569
+ }
570
+ res.json({ released: true });
571
+ });
572
+ // Every job-scoped endpoint checks the lease token, so a runner can only
573
+ // touch the job it currently holds, whatever else it is allowed to run.
574
+ function addressOf(req) {
575
+ const collection = req.params.collection;
576
+ const repo = req.params.repo;
577
+ const run = parseInt(req.params.run, 10);
578
+ const job = req.params.job;
579
+ if (!(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(repo))
580
+ return null;
581
+ if (!Number.isInteger(run) || run <= 0)
582
+ return null;
583
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_-]*$/.test(job))
584
+ return null;
585
+ return { collection, repo, run, job };
586
+ }
587
+ function leaseOf(req) {
588
+ const v = req.get('x-mochi-lease');
589
+ return typeof v === 'string' ? v : '';
590
+ }
591
+ app.post('/api/runner/jobs/:collection/:repo/:run/:job/heartbeat', json, (req, res) => {
592
+ const auth = requireRunner(req, res);
593
+ if (!auth)
594
+ return;
595
+ const a = addressOf(req);
596
+ if (!a) {
597
+ apiError(res, 400, 'invalid job address');
598
+ return;
599
+ }
600
+ const result = engine.heartbeat(a.collection, a.repo, a.run, a.job, leaseOf(req), auth.name);
601
+ if (!result) {
602
+ apiError(res, 409, 'the lease on this job is no longer valid');
603
+ return;
604
+ }
605
+ res.json(result);
606
+ });
607
+ app.post('/api/runner/jobs/:collection/:repo/:run/:job/logs', express_1.default.text({ type: '*/*', limit: MAX_LOG_BODY }), (req, res) => {
608
+ const auth = requireRunner(req, res);
609
+ if (!auth)
610
+ return;
611
+ const a = addressOf(req);
612
+ if (!a) {
613
+ apiError(res, 400, 'invalid job address');
614
+ return;
615
+ }
616
+ const body = typeof req.body === 'string' ? req.body : '';
617
+ // Validate every line before appending: the log file is read back as
618
+ // ndjson by the UI, so a malformed line would corrupt the stream.
619
+ const lines = [];
620
+ for (const raw of body.split('\n')) {
621
+ if (raw.trim() === '')
622
+ continue;
623
+ let parsed;
624
+ try {
625
+ parsed = JSON.parse(raw);
626
+ }
627
+ catch {
628
+ continue;
629
+ }
630
+ const p = parsed;
631
+ if (typeof p.l !== 'string' || typeof p.s !== 'number')
632
+ continue;
633
+ lines.push(JSON.stringify({ s: p.s, t: typeof p.t === 'string' ? p.t : new Date().toISOString(), l: p.l }));
634
+ }
635
+ if (lines.length === 0) {
636
+ res.json({ ok: true });
637
+ return;
638
+ }
639
+ const ok = engine.appendLogs(a.collection, a.repo, a.run, a.job, leaseOf(req), auth.name, lines.join('\n') + '\n');
640
+ if (!ok) {
641
+ apiError(res, 409, 'the lease on this job is no longer valid');
642
+ return;
643
+ }
644
+ res.json({ ok: true });
645
+ });
646
+ app.post('/api/runner/jobs/:collection/:repo/:run/:job/status', json, (req, res) => {
647
+ const auth = requireRunner(req, res);
648
+ if (!auth)
649
+ return;
650
+ const a = addressOf(req);
651
+ if (!a) {
652
+ apiError(res, 400, 'invalid job address');
653
+ return;
654
+ }
655
+ const body = (req.body ?? {});
656
+ const status = body.status === 'completed' ? 'completed' : 'running';
657
+ const conclusion = typeof body.conclusion === 'string' &&
658
+ ['success', 'failure', 'cancelled', 'skipped'].includes(body.conclusion)
659
+ ? body.conclusion
660
+ : undefined;
661
+ const stepStates = Array.isArray(body.stepStates) ? body.stepStates : undefined;
662
+ const outputs = typeof body.outputs === 'object' && body.outputs !== null
663
+ ? Object.fromEntries(Object.entries(body.outputs)
664
+ .filter(([, v]) => typeof v === 'string')
665
+ .map(([k, v]) => [k, v]))
666
+ : undefined;
667
+ const summaries = Array.isArray(body.summaries)
668
+ ? body.summaries.filter((s) => typeof s === 'string')
669
+ : undefined;
670
+ const ok = engine.reportStatus(a.collection, a.repo, a.run, a.job, {
671
+ lease: leaseOf(req),
672
+ runner: auth.name,
673
+ status,
674
+ conclusion,
675
+ stepStates,
676
+ outputs,
677
+ summaries,
678
+ });
679
+ if (!ok) {
680
+ apiError(res, 409, 'the lease on this job is no longer valid');
681
+ return;
682
+ }
683
+ res.json({ ok: true });
684
+ });
685
+ // ---- artifacts ----
686
+ //
687
+ // A job uploads under a name and a later job in the same run downloads by
688
+ // that name. Authorization is the job's lease, so an artifact can only be
689
+ // written by a job that is actually running, and only into its own run.
690
+ app.put('/api/runner/jobs/:collection/:repo/:run/:job/artifacts/:name', (req, res) => {
691
+ const auth = requireRunner(req, res);
692
+ if (!auth)
693
+ return;
694
+ const a = addressOf(req);
695
+ if (!a) {
696
+ apiError(res, 400, 'invalid job address');
697
+ return;
698
+ }
699
+ const name = req.params.name;
700
+ if (!(0, artifacts_1.isValidArtifactName)(name)) {
701
+ apiError(res, 400, 'invalid artifact name');
702
+ return;
703
+ }
704
+ if (!engine.heartbeat(a.collection, a.repo, a.run, a.job, leaseOf(req), auth.name)) {
705
+ apiError(res, 409, 'the lease on this job is no longer valid');
706
+ return;
707
+ }
708
+ const dir = (0, artifacts_1.artifactsDir)(root, a.collection, a.repo, a.run);
709
+ const file = (0, artifacts_1.artifactPath)(root, a.collection, a.repo, a.run, name);
710
+ if (!dir || !file) {
711
+ apiError(res, 400, 'invalid artifact target');
712
+ return;
713
+ }
714
+ const limit = engine.artifactLimitBytes();
715
+ fs.mkdirSync(dir, { recursive: true });
716
+ const tmp = `${file}.tmp-${process.pid}`;
717
+ const out = fs.createWriteStream(tmp);
718
+ let written = 0;
719
+ let failed = false;
720
+ const abort = (status, message) => {
721
+ if (failed)
722
+ return;
723
+ failed = true;
724
+ out.destroy();
725
+ fs.rmSync(tmp, { force: true });
726
+ req.unpipe(out);
727
+ req.resume();
728
+ apiError(res, status, message);
729
+ };
730
+ req.on('data', (chunk) => {
731
+ written += chunk.length;
732
+ if (written > limit) {
733
+ abort(413, `artifact ${name} exceeds the ${Math.round(limit / (1024 * 1024))} MB limit for this vault`);
734
+ }
735
+ });
736
+ req.on('error', () => abort(400, 'the upload was interrupted'));
737
+ out.on('error', () => abort(500, 'could not store the artifact'));
738
+ out.on('finish', () => {
739
+ if (failed)
740
+ return;
741
+ try {
742
+ fs.renameSync(tmp, file);
743
+ }
744
+ catch {
745
+ apiError(res, 500, 'could not store the artifact');
746
+ return;
747
+ }
748
+ res.json({ name, size: written });
749
+ });
750
+ req.pipe(out);
751
+ });
752
+ app.get('/api/runner/jobs/:collection/:repo/:run/:job/artifacts/:name', (req, res) => {
753
+ const auth = requireRunner(req, res);
754
+ if (!auth)
755
+ return;
756
+ const a = addressOf(req);
757
+ if (!a) {
758
+ apiError(res, 400, 'invalid job address');
759
+ return;
760
+ }
761
+ if (!engine.heartbeat(a.collection, a.repo, a.run, a.job, leaseOf(req), auth.name)) {
762
+ apiError(res, 409, 'the lease on this job is no longer valid');
763
+ return;
764
+ }
765
+ const file = (0, artifacts_1.artifactPath)(root, a.collection, a.repo, a.run, req.params.name);
766
+ if (!file || !fs.existsSync(file)) {
767
+ apiError(res, 404, `no artifact named ${req.params.name} in this run`);
768
+ return;
769
+ }
770
+ res.type('application/x-tar').sendFile(path.resolve(file));
771
+ });
772
+ app.get('/api/runner/jobs/:collection/:repo/:run/:job/artifacts', (req, res) => {
773
+ const auth = requireRunner(req, res);
774
+ if (!auth)
775
+ return;
776
+ const a = addressOf(req);
777
+ if (!a) {
778
+ apiError(res, 400, 'invalid job address');
779
+ return;
780
+ }
781
+ if (!engine.heartbeat(a.collection, a.repo, a.run, a.job, leaseOf(req), auth.name)) {
782
+ apiError(res, 409, 'the lease on this job is no longer valid');
783
+ return;
784
+ }
785
+ res.json({ artifacts: (0, artifacts_1.listArtifacts)(root, a.collection, a.repo, a.run) });
786
+ });
787
+ // Publishing an artifact as the repository's site. The extraction happens
788
+ // here rather than on the runner because the site directory is vault
789
+ // state; artifacts.ts treats the archive as untrusted.
790
+ app.post('/api/runner/jobs/:collection/:repo/:run/:job/site', json, async (req, res) => {
791
+ const auth = requireRunner(req, res);
792
+ if (!auth)
793
+ return;
794
+ const a = addressOf(req);
795
+ if (!a) {
796
+ apiError(res, 400, 'invalid job address');
797
+ return;
798
+ }
799
+ if (!engine.heartbeat(a.collection, a.repo, a.run, a.job, leaseOf(req), auth.name)) {
800
+ apiError(res, 409, 'the lease on this job is no longer valid');
801
+ return;
802
+ }
803
+ const body = (req.body ?? {});
804
+ const name = typeof body.artifact === 'string' ? body.artifact : 'github-pages';
805
+ if (!(0, artifacts_1.isValidArtifactName)(name)) {
806
+ apiError(res, 400, 'invalid artifact name');
807
+ return;
808
+ }
809
+ try {
810
+ const result = await (0, artifacts_1.deploySite)(root, a.collection, a.repo, a.run, name);
811
+ res.json({
812
+ deployed: true,
813
+ files: result.files,
814
+ url: siteOf(req, a.collection, a.repo).url,
815
+ });
816
+ }
817
+ catch (e) {
818
+ apiError(res, e instanceof artifacts_1.ArtifactError ? 400 : 500, e instanceof Error ? e.message : String(e));
819
+ }
820
+ });
821
+ // Runner-side liveness check, so `mochi runner run` can fail fast with a
822
+ // clear message rather than long-polling against a bad token or host.
823
+ app.get('/api/runner/whoami', (req, res) => {
824
+ const auth = requireRunner(req, res);
825
+ if (!auth)
826
+ return;
827
+ res.json({ name: auth.name, labels: auth.runner.labels, allow: auth.runner.allow });
828
+ });
829
+ }