@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
@@ -43,6 +43,7 @@ const cli_api_1 = require("./cli-api");
43
43
  const cli_api_2 = require("./cli-api");
44
44
  const wake_1 = require("./ci/wake");
45
45
  const deploy_cli_1 = require("./deploy-cli");
46
+ const runners_1 = require("./ci/runners");
46
47
  const scan_1 = require("./scan");
47
48
  // `mochi deploy fly runner`: put a workflow runner on Fly.io, beside a
48
49
  // vault that is already there or anywhere else.
@@ -81,12 +82,14 @@ function parseArgs(args, usage) {
81
82
  name: null,
82
83
  allow: [],
83
84
  labels: [],
85
+ jobTimeout: null,
84
86
  region: null,
85
87
  volumeGb: null,
86
88
  vmSize: null,
87
89
  memory: null,
88
90
  idle: null,
89
91
  image: null,
92
+ imageOnly: false,
90
93
  fromSource: false,
91
94
  localBuild: false,
92
95
  org: null,
@@ -105,6 +108,16 @@ function parseArgs(args, usage) {
105
108
  out.allow.push(...list(args[++i] ?? ''));
106
109
  else if (a === '--labels' || a === '--label')
107
110
  out.labels.push(...list(args[++i] ?? ''));
111
+ else if (a === '--job-timeout') {
112
+ const raw = (args[++i] ?? '').trim();
113
+ const m = /^(\d+)\s*(m|h)?$/i.exec(raw);
114
+ if (!m)
115
+ (0, deploy_cli_1.die)('--job-timeout takes minutes, or a duration like 45m or 2h');
116
+ const n = parseInt(m[1], 10) * ((m[2] ?? 'm').toLowerCase() === 'h' ? 60 : 1);
117
+ if (!(0, runners_1.isUsableJobTimeout)(n))
118
+ (0, deploy_cli_1.die)(`--job-timeout takes 1 to ${runners_1.MAX_JOB_TIMEOUT_MINUTES} minutes`);
119
+ out.jobTimeout = n;
120
+ }
108
121
  else if (a === '--region')
109
122
  out.region = args[++i];
110
123
  else if (a === '--volume') {
@@ -121,6 +134,8 @@ function parseArgs(args, usage) {
121
134
  out.idle = args[++i];
122
135
  else if (a === '--image')
123
136
  out.image = args[++i];
137
+ else if (a === '--image-only')
138
+ out.imageOnly = true;
124
139
  else if (a === '--from-source')
125
140
  out.fromSource = true;
126
141
  else if (a === '--local-build')
@@ -161,6 +176,7 @@ function rejectShapingFlags(a, usage, allowYes) {
161
176
  ['--vm-memory', a.memory !== null],
162
177
  ['--idle', a.idle !== null],
163
178
  ['--image', a.image !== null],
179
+ ['--image-only', a.imageOnly],
164
180
  ['--from-source', a.fromSource],
165
181
  ['--local-build', a.localBuild],
166
182
  ['--org', a.org !== null],
@@ -188,7 +204,8 @@ async function liveSettings(app) {
188
204
  out.region = vol.region;
189
205
  out.volumeGb = vol.size_gb;
190
206
  }
191
- const guest = (await (0, deploy_cli_1.machines)(app)).find((m) => m.config?.guest)?.config?.guest;
207
+ const config = (await (0, deploy_cli_1.machines)(app)).find((m) => m.config)?.config;
208
+ const guest = config?.guest;
192
209
  if (guest) {
193
210
  if (guest.cpu_kind)
194
211
  out.cpuKind = guest.cpu_kind;
@@ -197,6 +214,11 @@ async function liveSettings(app) {
197
214
  if (guest.memory_mb)
198
215
  out.memory = `${guest.memory_mb}mb`;
199
216
  }
217
+ // The machine's own idle timeout, so a redeploy keeps a tuned --idle rather
218
+ // than quietly resetting it to the default.
219
+ const idle = config?.env?.MOCHI_IDLE;
220
+ if (idle)
221
+ out.idle = idle;
200
222
  return out;
201
223
  }
202
224
  function resolveSettings(a, live) {
@@ -269,6 +291,60 @@ async function registeredRunners(target) {
269
291
  const data = await (0, cli_api_1.api)(target, 'GET', '/api/runners');
270
292
  return (data.runners ?? []) ?? [];
271
293
  }
294
+ /**
295
+ * The fly deploy itself, shared by the full deploy and --image-only. Exits
296
+ * the process on failure, after saying what to try.
297
+ */
298
+ async function flyDeploy(app, a, settings, buildRoot, image) {
299
+ const config = writeTempConfig(app, settings);
300
+ if (buildRoot !== null) {
301
+ console.log(`==> Building ${buildRoot} ${a.localBuild ? 'with the local Docker' : "on Fly's builder"}`);
302
+ }
303
+ else {
304
+ console.log(`==> Deploying ${image}`);
305
+ }
306
+ const code = await (0, deploy_cli_1.flyStream)([
307
+ 'deploy',
308
+ '--app',
309
+ app,
310
+ '--config',
311
+ config,
312
+ ...(buildRoot !== null
313
+ ? ['--dockerfile', path.join(buildRoot, 'Dockerfile.runner'), ...(a.localBuild ? ['--local-only'] : [])]
314
+ : ['--image', image]),
315
+ '--ha=false',
316
+ '--yes',
317
+ ], buildRoot ?? undefined);
318
+ fs.rmSync(path.dirname(config), { recursive: true, force: true });
319
+ if (code !== 0) {
320
+ console.error('');
321
+ console.error('The deploy failed. The app, the volume, and the registration survive, so fix the');
322
+ console.error('cause and run the same command again.');
323
+ if (buildRoot === null && a.image === null) {
324
+ // Named as the likely cause rather than as one possibility among many,
325
+ // because it is the one every early deploy hits: the runner image is
326
+ // published per release like the vault's, so a version of the CLI newer
327
+ // than the newest release has no image to pull. Fly reports a package
328
+ // that is absent, and one that is private, with the same message about
329
+ // authentication, and a package newly published to GHCR is private
330
+ // until someone says otherwise.
331
+ console.error('');
332
+ console.error(`If Fly could not fetch ${image}:`);
333
+ console.error('');
334
+ console.error(' - It may not be published. The runner image is built per release, so a CLI');
335
+ console.error(' newer than the newest release asks for a tag that does not exist yet.');
336
+ console.error(' - It may be private. A package newly published to GHCR is private until it');
337
+ console.error(' is made public, and Fly reports that as an authentication error too.');
338
+ console.error('');
339
+ console.error('Either way, this deploys the checkout you are running instead:');
340
+ console.error('');
341
+ console.error(` mochi deploy fly runner ${app} --from-source`);
342
+ console.error('');
343
+ console.error('or --image <ref> deploys some other tag.');
344
+ }
345
+ process.exit(1);
346
+ }
347
+ }
272
348
  async function deployFlyRunnerCmd(args, usage) {
273
349
  const a = parseArgs(args, usage);
274
350
  if (!a.app) {
@@ -292,6 +368,46 @@ async function deployFlyRunnerCmd(args, usage) {
292
368
  }
293
369
  const buildRoot = a.fromSource ? (0, deploy_cli_1.sourceRoot)() : null;
294
370
  const image = buildRoot === null ? a.image ?? `${RUNNER_IMAGE_REPO}:${(0, deploy_cli_1.ownVersion)()}` : null;
371
+ if (image !== null && a.image === null)
372
+ await (0, deploy_cli_1.requirePublishedImage)(image, true);
373
+ // --image-only: move the machine to a new image and touch nothing else (no
374
+ // registration, no token, no wake rewrite, no secret staging), so it needs
375
+ // flyctl and no vault login. That is the shape a pipeline wants: the runner
376
+ // image is built beside the vault's, a runner and the vault it serves speak
377
+ // one protocol, and a job holding only a Fly credential can do exactly this
378
+ // much and no more.
379
+ if (a.imageOnly) {
380
+ if (a.allow.length > 0 || a.labels.length > 0) {
381
+ (0, deploy_cli_1.die)(`${a.allow.length > 0 ? '--allow' : '--labels'} changes the registration, which lives in the vault, and --image-only\n` +
382
+ 'deploys without touching the vault. Drop one or the other.');
383
+ }
384
+ await (0, deploy_cli_1.requireFly)();
385
+ if (!(await (0, deploy_cli_1.appExists)(app))) {
386
+ (0, deploy_cli_1.die)(`No Fly app named '${app}' that you can see, and --image-only updates one that\n` +
387
+ 'already exists. The full deploy creates it:\n\n' +
388
+ ` mochi deploy fly runner ${app} --allow '<globs>'\n`);
389
+ }
390
+ const have = await (0, deploy_cli_1.secretNames)(app);
391
+ const missing = [HOST_SECRET, TOKEN_SECRET, WAKE_SECRET].filter((n) => !have.includes(n));
392
+ if (missing.length > 0) {
393
+ (0, deploy_cli_1.die)(`The app is missing ${missing.join(', ')}, so the machine deployed\n` +
394
+ 'here could not serve any vault. The full deploy sets them:\n\n' +
395
+ ` mochi deploy fly runner ${app}\n`);
396
+ }
397
+ if (!(await (0, deploy_cli_1.namedVolume)(app, VOLUME_NAME))) {
398
+ (0, deploy_cli_1.die)(`The app has no '${VOLUME_NAME}' volume. The full deploy creates it:\n\n mochi deploy fly runner ${app}\n`);
399
+ }
400
+ const settings = resolveSettings(a, await liveSettings(app));
401
+ console.log(`==> Updating '${app}' (${(0, deploy_cli_1.appUrl)(app)}), touching nothing but the machine`);
402
+ await flyDeploy(app, a, settings, buildRoot, image);
403
+ console.log('');
404
+ console.log(`==> Deployed: ${(0, deploy_cli_1.appUrl)(app)}`);
405
+ console.log('');
406
+ console.log('The registration, the runner token, and the wake address were left as they');
407
+ console.log(`were. It stops after ${settings.idle} with no job, and the vault starts it again`);
408
+ console.log('when one is waiting.');
409
+ return;
410
+ }
295
411
  // The vault first: a runner with no vault to serve is nothing, and finding
296
412
  // out that the login is missing after creating a Fly app would be a poor
297
413
  // trade. This is the same login every other admin command uses.
@@ -379,6 +495,7 @@ async function deployFlyRunnerCmd(args, usage) {
379
495
  name,
380
496
  labels: a.labels.length ? a.labels : ['ubuntu-latest'],
381
497
  allow: a.allow,
498
+ ...(a.jobTimeout === null ? {} : { jobTimeoutMinutes: a.jobTimeout }),
382
499
  wakeUrl: wake.url,
383
500
  wakeSecret: wake.secret,
384
501
  });
@@ -391,6 +508,12 @@ async function deployFlyRunnerCmd(args, usage) {
391
508
  const data = await (0, cli_api_1.api)(target, 'POST', `/api/runners/${encodeURIComponent(name)}/token`, {});
392
509
  runnerToken = data.token;
393
510
  }
511
+ // A timeout named by a flag applies to a runner that was already registered
512
+ // too, on the terms the rest of this command sets: a flag changes the thing
513
+ // it names, and a setting left unnamed keeps whatever the vault has.
514
+ if (a.jobTimeout !== null && registered) {
515
+ await (0, cli_api_1.api)(target, 'PATCH', `/api/runners/${encodeURIComponent(name)}`, { jobTimeoutMinutes: a.jobTimeout });
516
+ }
394
517
  // The wake address is rewritten on every deploy, not only on the first. The
395
518
  // secret is this run's, and the app it points at is this app; a redeploy
396
519
  // that left an older secret in place would leave the vault sending one the
@@ -409,54 +532,7 @@ async function deployFlyRunnerCmd(args, usage) {
409
532
  const set = await (0, deploy_cli_1.fly)(['secrets', 'import', '-a', app, '--stage'], staged.join('\n') + '\n');
410
533
  if (set.code !== 0)
411
534
  (0, deploy_cli_1.die)(`Could not set the secrets:\n${set.stderr.trim() || set.stdout.trim()}`);
412
- const config = writeTempConfig(app, settings);
413
- if (buildRoot !== null) {
414
- console.log(`==> Building ${buildRoot} ${a.localBuild ? 'with the local Docker' : "on Fly's builder"}`);
415
- }
416
- else {
417
- console.log(`==> Deploying ${image}`);
418
- }
419
- const code = await (0, deploy_cli_1.flyStream)([
420
- 'deploy',
421
- '--app',
422
- app,
423
- '--config',
424
- config,
425
- ...(buildRoot !== null
426
- ? ['--dockerfile', path.join(buildRoot, 'Dockerfile.runner'), ...(a.localBuild ? ['--local-only'] : [])]
427
- : ['--image', image]),
428
- '--ha=false',
429
- '--yes',
430
- ], buildRoot ?? undefined);
431
- fs.rmSync(path.dirname(config), { recursive: true, force: true });
432
- if (code !== 0) {
433
- console.error('');
434
- console.error('The deploy failed. The app, the volume, and the registration survive, so fix the');
435
- console.error('cause and run the same command again.');
436
- if (buildRoot === null && a.image === null) {
437
- // Named as the likely cause rather than as one possibility among many,
438
- // because it is the one every early deploy hits: the runner image is
439
- // published per release like the vault's, so a version of the CLI newer
440
- // than the newest release has no image to pull. Fly reports a package
441
- // that is absent, and one that is private, with the same message about
442
- // authentication, and a package newly published to GHCR is private
443
- // until someone says otherwise.
444
- console.error('');
445
- console.error(`If Fly could not fetch ${image}:`);
446
- console.error('');
447
- console.error(' - It may not be published. The runner image is built per release, so a CLI');
448
- console.error(' newer than the newest release asks for a tag that does not exist yet.');
449
- console.error(' - It may be private. A package newly published to GHCR is private until it');
450
- console.error(' is made public, and Fly reports that as an authentication error too.');
451
- console.error('');
452
- console.error('Either way, this deploys the checkout you are running instead:');
453
- console.error('');
454
- console.error(` mochi deploy fly runner ${app} --from-source`);
455
- console.error('');
456
- console.error('or --image <ref> deploys some other tag.');
457
- }
458
- process.exit(1);
459
- }
535
+ await flyDeploy(app, a, settings, buildRoot, image);
460
536
  // A machine is started by the deploy and will stop itself once it has been
461
537
  // idle for the configured time. Saying so is worth a line, because a
462
538
  // `deploy fly` that ends with a stopped machine looks like a failure to
@@ -0,0 +1,259 @@
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
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DOMAINS_FILE = void 0;
37
+ exports.domainsFilePath = domainsFilePath;
38
+ exports.loadDomains = loadDomains;
39
+ exports.domainRepoFor = domainRepoFor;
40
+ exports.repoDomain = repoDomain;
41
+ exports.isSiteRequest = isSiteRequest;
42
+ exports.setRepoDomain = setRepoDomain;
43
+ exports.clearRepoDomain = clearRepoDomain;
44
+ exports.moveRepoDomains = moveRepoDomains;
45
+ exports.moveCollectionDomains = moveCollectionDomains;
46
+ exports.dropRepoDomains = dropRepoDomains;
47
+ exports.dropCollectionDomains = dropCollectionDomains;
48
+ const fs = __importStar(require("fs"));
49
+ const path = __importStar(require("path"));
50
+ const atomic_1 = require("./atomic");
51
+ const config_1 = require("./config");
52
+ const filecache_1 = require("./filecache");
53
+ const scan_1 = require("./scan");
54
+ const siteshost_1 = require("./siteshost");
55
+ // Custom domains for repository sites, in <vault>/domains.json beside
56
+ // config.json: a map from a hostname the operator points at the vault to the
57
+ // `<collection>/<repo>` whose site answers there.
58
+ //
59
+ // Vault state rather than repository state, deliberately. The server decides
60
+ // per request whether a Host header names a site, so the answer has to come
61
+ // from one stat-cached file the way config.json does, not from a scan of every
62
+ // repository. It also matches who may write it: attaching a hostname is the
63
+ // operator's act, since the operator is who points DNS at the vault, covers
64
+ // the name with a certificate, and answers for what the server serves under
65
+ // it. A repository admin enables the site and may pick a label on the sites
66
+ // host; a custom domain takes a site admin, through the API or the CLI, and
67
+ // the file is hand-editable like everything else in a vault.
68
+ //
69
+ // One domain maps to one repository, and each repository holds at most one
70
+ // domain: setRepoDomain replaces whatever the repository had. A domain equal
71
+ // to the sites host, or under it, is refused, since those names already have a
72
+ // meaning; nothing can check the forge's own hostname, which the server never
73
+ // knows, so mapping that is the one mistake this cannot catch and the
74
+ // documentation warns about.
75
+ exports.DOMAINS_FILE = 'domains.json';
76
+ function domainsFilePath(root) {
77
+ return path.join(root, exports.DOMAINS_FILE);
78
+ }
79
+ function normalize(parsed) {
80
+ const out = {};
81
+ if (typeof parsed !== 'object' || parsed === null)
82
+ return out;
83
+ const domains = parsed.domains;
84
+ if (typeof domains !== 'object' || domains === null)
85
+ return out;
86
+ for (const [rawHost, target] of Object.entries(domains)) {
87
+ const host = (0, siteshost_1.normalizeHostname)(rawHost);
88
+ if (!(0, config_1.isPlausibleHostname)(host) || typeof target !== 'string')
89
+ continue;
90
+ const slash = target.indexOf('/');
91
+ if (slash <= 0)
92
+ continue;
93
+ const collection = target.slice(0, slash);
94
+ const repo = target.slice(slash + 1);
95
+ if (!(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(repo))
96
+ continue;
97
+ out[host] = `${collection}/${repo}`;
98
+ }
99
+ return out;
100
+ }
101
+ const EMPTY = {};
102
+ const cache = (0, filecache_1.fileCache)({
103
+ read: (file) => {
104
+ try {
105
+ return normalize(JSON.parse(fs.readFileSync(file, 'utf8')));
106
+ }
107
+ catch (e) {
108
+ // A file that cannot be read maps nothing: the custom domains stop
109
+ // answering rather than answering wrongly, and the forge is unaffected.
110
+ console.error(`domains.json could not be read: ${e instanceof Error ? e.message : e}`);
111
+ return EMPTY;
112
+ }
113
+ },
114
+ missing: () => EMPTY,
115
+ });
116
+ /** Every custom domain, as hostname -> `<collection>/<repo>`. */
117
+ function loadDomains(root) {
118
+ return cache.get(domainsFilePath(root));
119
+ }
120
+ /** The repository whose custom domain this hostname is, or null. */
121
+ function domainRepoFor(root, hostname) {
122
+ const target = loadDomains(root)[(0, siteshost_1.normalizeHostname)(hostname)];
123
+ if (target === undefined)
124
+ return null;
125
+ const slash = target.indexOf('/');
126
+ return { collection: target.slice(0, slash), repo: target.slice(slash + 1) };
127
+ }
128
+ /** The custom domain a repository holds, or null. */
129
+ function repoDomain(root, collection, repo) {
130
+ const full = `${collection}/${repo}`;
131
+ for (const [host, target] of Object.entries(loadDomains(root))) {
132
+ if (target === full)
133
+ return host;
134
+ }
135
+ return null;
136
+ }
137
+ /**
138
+ * Whether a request's hostname is a site rather than the forge: under the
139
+ * sites host, or a custom domain. This is the question the session, the rate
140
+ * exemptions, and the egress exemptions each ask, so it is one function; on a
141
+ * hostname it answers true for, no session is resolved and no path is exempt
142
+ * from any limit, because every path there belongs to the site being served.
143
+ */
144
+ function isSiteRequest(root, hostname) {
145
+ if ((0, siteshost_1.isUnderSitesHost)((0, config_1.loadConfig)(root).sites.host, hostname))
146
+ return true;
147
+ return loadDomains(root)[(0, siteshost_1.normalizeHostname)(hostname)] !== undefined;
148
+ }
149
+ function write(root, domains) {
150
+ const file = domainsFilePath(root);
151
+ if (Object.keys(domains).length === 0) {
152
+ // Nothing mapped, so nothing on disk, the same way redirects.json goes.
153
+ fs.rmSync(file, { force: true });
154
+ }
155
+ else {
156
+ (0, atomic_1.writeFileAtomic)(file, JSON.stringify({ domains }, null, 2) + '\n');
157
+ }
158
+ cache.invalidate(file);
159
+ }
160
+ function edit(root, fn) {
161
+ const file = domainsFilePath(root);
162
+ (0, atomic_1.withFileLock)(`${file}.lock`, () => {
163
+ cache.invalidate(file);
164
+ const domains = { ...loadDomains(root) };
165
+ fn(domains);
166
+ write(root, domains);
167
+ });
168
+ }
169
+ /**
170
+ * Attach a domain to a repository, replacing any domain the repository already
171
+ * held. Returns the problem, or null on success; the kind tells a surface
172
+ * whether the caller's value was wrong or merely taken, so each words its own
173
+ * refusal with the right status.
174
+ */
175
+ function setRepoDomain(root, collection, repo, domain) {
176
+ const host = (0, siteshost_1.normalizeHostname)(domain);
177
+ if (!(0, config_1.isPlausibleHostname)(host)) {
178
+ return { kind: 'invalid', message: 'a domain is a hostname of at least two labels, like docs.example.org' };
179
+ }
180
+ const sitesHost = (0, config_1.loadConfig)(root).sites.host;
181
+ if (sitesHost && (host === sitesHost || host.endsWith(`.${sitesHost}`))) {
182
+ return {
183
+ kind: 'invalid',
184
+ message: `a name under the sites host ${sitesHost} already has a meaning; use the repository's site label instead`,
185
+ };
186
+ }
187
+ const full = `${collection}/${repo}`;
188
+ let conflict = null;
189
+ edit(root, (domains) => {
190
+ const holder = domains[host];
191
+ if (holder !== undefined && holder !== full) {
192
+ conflict = holder;
193
+ return;
194
+ }
195
+ for (const [h, target] of Object.entries(domains)) {
196
+ if (target === full)
197
+ delete domains[h];
198
+ }
199
+ domains[host] = full;
200
+ });
201
+ return conflict ? { kind: 'conflict', message: `${host} already serves the site of ${conflict}` } : null;
202
+ }
203
+ /** Detach whatever domain a repository holds; holding none is fine. */
204
+ function clearRepoDomain(root, collection, repo) {
205
+ const full = `${collection}/${repo}`;
206
+ if (repoDomain(root, collection, repo) === null)
207
+ return;
208
+ edit(root, (domains) => {
209
+ for (const [host, target] of Object.entries(domains)) {
210
+ if (target === full)
211
+ delete domains[host];
212
+ }
213
+ });
214
+ }
215
+ /** A repository moved; its domain, if any, follows it. Called by renameRepo. */
216
+ function moveRepoDomains(root, collection, repo, toCollection, toName) {
217
+ const from = `${collection}/${repo}`;
218
+ if (repoDomain(root, collection, repo) === null)
219
+ return;
220
+ edit(root, (domains) => {
221
+ for (const [host, target] of Object.entries(domains)) {
222
+ if (target === from)
223
+ domains[host] = `${toCollection}/${toName}`;
224
+ }
225
+ });
226
+ }
227
+ /** A collection moved; every domain into it follows. Called by renameCollection. */
228
+ function moveCollectionDomains(root, collection, toName) {
229
+ const prefix = `${collection}/`;
230
+ const map = loadDomains(root);
231
+ if (!Object.values(map).some((t) => t.startsWith(prefix)))
232
+ return;
233
+ edit(root, (domains) => {
234
+ for (const [host, target] of Object.entries(domains)) {
235
+ if (target.startsWith(prefix))
236
+ domains[host] = `${toName}/${target.slice(prefix.length)}`;
237
+ }
238
+ });
239
+ }
240
+ /**
241
+ * A repository or collection is gone; its domains go with it, so a repository
242
+ * created later under the name does not inherit a hostname somebody attached
243
+ * to what was deleted. Called by deleteRepo and deleteCollection.
244
+ */
245
+ function dropRepoDomains(root, collection, repo) {
246
+ clearRepoDomain(root, collection, repo);
247
+ }
248
+ function dropCollectionDomains(root, collection) {
249
+ const prefix = `${collection}/`;
250
+ const map = loadDomains(root);
251
+ if (!Object.values(map).some((t) => t.startsWith(prefix)))
252
+ return;
253
+ edit(root, (domains) => {
254
+ for (const [host, target] of Object.entries(domains)) {
255
+ if (target.startsWith(prefix))
256
+ delete domains[host];
257
+ }
258
+ });
259
+ }
package/dist/egress.js CHANGED
@@ -41,9 +41,10 @@ const fs = __importStar(require("fs"));
41
41
  const path = __importStar(require("path"));
42
42
  const atomic_1 = require("./atomic");
43
43
  const config_1 = require("./config");
44
+ const domains_1 = require("./domains");
44
45
  const redirects_1 = require("./redirects");
45
46
  const scan_1 = require("./scan");
46
- const siteshost_1 = require("./siteshost");
47
+ const sitesettings_1 = require("./sitesettings");
47
48
  // Outgoing bytes, counted and capped.
48
49
  //
49
50
  // A host that bills for egress and does not cap it -- Fly is the one this was
@@ -198,15 +199,17 @@ function readDays(file) {
198
199
  * program, and reading the number is what an operator does before changing it;
199
200
  * the stylesheet and the icon because the admin page is unreadable without them.
200
201
  *
201
- * None of it applies on a sites hostname, where these paths belong to the site
202
- * being served and are ordinary traffic, exactly as isRateExempt has it.
202
+ * None of it applies on a sites hostname or a custom domain, where these paths
203
+ * belong to the site being served and are ordinary traffic, exactly as
204
+ * isRateExempt has it.
203
205
  */
204
206
  function isEgressExempt(root, req) {
205
- if ((0, siteshost_1.isUnderSitesHost)((0, config_1.loadConfig)(root).sites.host, req.hostname))
207
+ if ((0, domains_1.isSiteRequest)(root, req.hostname))
206
208
  return false;
207
209
  const p = req.path;
208
- if (p === '/login' || p === '/logout' || p === '/api/config' || p === '/api/egress')
210
+ if (p === '/login' || p.startsWith('/login/') || p === '/logout' || p === '/api/config' || p === '/api/egress') {
209
211
  return true;
212
+ }
210
213
  if (p === '/admin' || p.startsWith('/admin/'))
211
214
  return true;
212
215
  return p.startsWith('/assets/') || p === '/favicon.svg' || p === '/favicon.ico';
@@ -276,6 +279,18 @@ function normalizeKeys(root, keys) {
276
279
  }
277
280
  return changed ? out : null;
278
281
  }
282
+ /**
283
+ * The repository a site hostname names, or null for a hostname that serves no
284
+ * site: a custom domain out of domains.json, or a name under the sites host,
285
+ * resolved exactly as the site handler resolves it so that a request's bytes
286
+ * are attributed to whatever answered it.
287
+ */
288
+ function siteHostRepo(root, hostname) {
289
+ const domain = (0, domains_1.domainRepoFor)(root, hostname);
290
+ if (domain)
291
+ return domain;
292
+ return (0, sitesettings_1.findRepoBySiteHostname)(root, (0, config_1.loadConfig)(root).sites.host, hostname);
293
+ }
279
294
  /**
280
295
  * Which row a request's bytes belong to.
281
296
  *
@@ -287,11 +302,13 @@ function normalizeKeys(root, keys) {
287
302
  * to none is one unmatched row rather than a row of its own.
288
303
  */
289
304
  function keyFor(root, req) {
290
- const site = (0, siteshost_1.parseSiteHost)((0, config_1.loadConfig)(root).sites.host, req.hostname);
305
+ const site = siteHostRepo(root, req.hostname);
291
306
  if (site) {
292
307
  const resolved = resolveRepoKey(root, site.collection, site.repo);
293
308
  return resolved === null ? UNMATCHED_KEY : `${resolved}${SITE_SUFFIX}`;
294
309
  }
310
+ if ((0, domains_1.isSiteRequest)(root, req.hostname))
311
+ return VAULT_KEY;
295
312
  const m = /^\/([^/]+)\/([^/]+)(?:\/(.*))?$/.exec(req.path);
296
313
  if (!m)
297
314
  return VAULT_KEY;