@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
@@ -0,0 +1,420 @@
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.COLLECTION_SITE_FILE = exports.SITE_SETTINGS_FILE = exports.SITE_SOURCES = void 0;
37
+ exports.isSiteSource = isSiteSource;
38
+ exports.isUsableSitePath = isUsableSitePath;
39
+ exports.normalizeSitePath = normalizeSitePath;
40
+ exports.isUsableSiteLabel = isUsableSiteLabel;
41
+ exports.siteSettings = siteSettings;
42
+ exports.editSiteSettings = editSiteSettings;
43
+ exports.findRepoBySiteLabel = findRepoBySiteLabel;
44
+ exports.siteLabelConflict = siteLabelConflict;
45
+ exports.listSiteLabels = listSiteLabels;
46
+ exports.isUsableCollectionAlias = isUsableCollectionAlias;
47
+ exports.storedCollectionAlias = storedCollectionAlias;
48
+ exports.setCollectionAlias = setCollectionAlias;
49
+ exports.collectionSiteAliases = collectionSiteAliases;
50
+ exports.collectionSiteAlias = collectionSiteAlias;
51
+ exports.collectionForSiteAlias = collectionForSiteAlias;
52
+ exports.collectionAliasConflict = collectionAliasConflict;
53
+ exports.siteHostFor = siteHostFor;
54
+ exports.findRepoBySiteHostname = findRepoBySiteHostname;
55
+ const fs = __importStar(require("fs"));
56
+ const path = __importStar(require("path"));
57
+ const atomic_1 = require("./atomic");
58
+ const filecache_1 = require("./filecache");
59
+ const layout_1 = require("./layout");
60
+ const scan_1 = require("./scan");
61
+ const siteshost_1 = require("./siteshost");
62
+ exports.SITE_SOURCES = ['copy', 'actions', 'repository'];
63
+ function isSiteSource(value) {
64
+ return typeof value === 'string' && exports.SITE_SOURCES.includes(value);
65
+ }
66
+ /**
67
+ * Whether a string may be stored as the directory a 'repository' site is
68
+ * published from: empty for the root, or relative path segments of ordinary
69
+ * filename characters, so `docs` and `build/site` but never `..`, an absolute
70
+ * path, or a segment that starts with a dot or a dash. The path is later handed to
71
+ * `git archive` as `<branch>:<path>`, and this grammar is what keeps it a
72
+ * tree path and nothing git would read as an option or a revision.
73
+ */
74
+ function isUsableSitePath(value) {
75
+ if (value === '')
76
+ return true;
77
+ if (value.length > 200)
78
+ return false;
79
+ return /^[A-Za-z0-9_][A-Za-z0-9._-]*(\/[A-Za-z0-9_][A-Za-z0-9._-]*)*$/.test(value);
80
+ }
81
+ /** A directory field as typed by a person, normalized to what isUsableSitePath accepts. */
82
+ function normalizeSitePath(value) {
83
+ return value.trim().replace(/^\/+|\/+$/g, '').replace(/\/{2,}/g, '/');
84
+ }
85
+ exports.SITE_SETTINGS_FILE = 'site.json';
86
+ /**
87
+ * Whether a string may be stored as a custom site label: a single DNS label,
88
+ * never with a double hyphen, and not one of the names the operator keeps. The
89
+ * reserved check is here rather than in the grammar because it is a question
90
+ * about who may claim a name, not about what a hostname may look like: a
91
+ * request arriving for `www.<sites host>` is parsed as usual and then answers
92
+ * to nothing, which is what makes the name free for the operator to point
93
+ * elsewhere.
94
+ */
95
+ function isUsableSiteLabel(label) {
96
+ return ((0, siteshost_1.isSiteLabelSafe)(label) &&
97
+ !label.includes('--') &&
98
+ label.length <= siteshost_1.MAX_SITE_LABEL &&
99
+ !(0, siteshost_1.isReservedSiteLabel)(label));
100
+ }
101
+ function defaults() {
102
+ return { enabled: false, source: 'copy', label: '', path: '' };
103
+ }
104
+ function normalizeSiteSettings(parsed) {
105
+ const out = defaults();
106
+ if (typeof parsed !== 'object' || parsed === null)
107
+ return out;
108
+ const rec = parsed;
109
+ if (rec.enabled === true)
110
+ out.enabled = true;
111
+ if (rec.source === 'actions' || rec.source === 'repository')
112
+ out.source = rec.source;
113
+ if (typeof rec.label === 'string' && isUsableSiteLabel(rec.label))
114
+ out.label = rec.label;
115
+ if (typeof rec.path === 'string' && isUsableSitePath(rec.path))
116
+ out.path = rec.path;
117
+ return out;
118
+ }
119
+ const settingsCache = (0, filecache_1.fileCache)({
120
+ read: (file) => {
121
+ try {
122
+ return normalizeSiteSettings(JSON.parse(fs.readFileSync(file, 'utf8')));
123
+ }
124
+ catch {
125
+ return defaults();
126
+ }
127
+ },
128
+ missing: () => defaults(),
129
+ });
130
+ /** The site settings of the repository whose bare directory this is. */
131
+ function siteSettings(repoDir) {
132
+ return settingsCache.get(path.join(repoDir, exports.SITE_SETTINGS_FILE));
133
+ }
134
+ /**
135
+ * Read-modify-write on a repository's site settings, under a lock, the same
136
+ * arrangement mochi.json edits use in src/perms.ts. The cache is invalidated
137
+ * explicitly because a write landing within the mtime granularity would
138
+ * otherwise be invisible to it.
139
+ */
140
+ function editSiteSettings(repoDir, fn) {
141
+ const file = path.join(repoDir, exports.SITE_SETTINGS_FILE);
142
+ return (0, atomic_1.withFileLock)(`${file}.lock`, () => {
143
+ let settings = defaults();
144
+ if (fs.existsSync(file)) {
145
+ try {
146
+ settings = normalizeSiteSettings(JSON.parse(fs.readFileSync(file, 'utf8')));
147
+ }
148
+ catch {
149
+ // An unreadable file is edited as the defaults it already reads as.
150
+ }
151
+ }
152
+ fn(settings);
153
+ (0, atomic_1.writeFileAtomic)(file, JSON.stringify(settings, null, 2) + '\n');
154
+ settingsCache.invalidate(file);
155
+ return settings;
156
+ });
157
+ }
158
+ /**
159
+ * The repository whose custom label this is, or null. A scan rather than an
160
+ * index, like every other lookup over a vault's repositories: the answer is a
161
+ * stat-cached read per repository, and it is only asked for a hostname that is
162
+ * under the sites host but is not a derived <repo>--<alias> name.
163
+ */
164
+ function findRepoBySiteLabel(root, label) {
165
+ if (!isUsableSiteLabel(label))
166
+ return null;
167
+ for (const { name: collection } of (0, scan_1.listCollections)(root)) {
168
+ for (const dirName of (0, scan_1.listRepoDirs)(root, collection)) {
169
+ if (siteSettings((0, layout_1.repoPath)(root, collection, dirName)).label === label) {
170
+ return { collection, repo: (0, scan_1.displayName)(dirName) };
171
+ }
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+ /**
177
+ * The `<collection>/<repo>` already using a label, or null when the label is
178
+ * free. Asked where a label is being set; the repository setting it does not
179
+ * conflict with itself. A label cannot collide with any derived label, since a
180
+ * derived label always contains `--` and a custom one never may.
181
+ */
182
+ function siteLabelConflict(root, label, collection, repo) {
183
+ const holder = findRepoBySiteLabel(root, label);
184
+ if (!holder)
185
+ return null;
186
+ if (holder.collection === collection && holder.repo === repo)
187
+ return null;
188
+ return `${holder.collection}/${holder.repo}`;
189
+ }
190
+ /**
191
+ * Every custom label in the vault, with the repository holding it, sorted by
192
+ * label. The same scan findRepoBySiteLabel does, kept apart from it because
193
+ * this one answers a different question: what is already taken. Nothing on a
194
+ * request path asks it; the vault settings page does, so that the flat
195
+ * namespace of claimed labels can be read rather than discovered one 409 at a
196
+ * time.
197
+ */
198
+ function listSiteLabels(root) {
199
+ const out = [];
200
+ for (const { name: collection } of (0, scan_1.listCollections)(root)) {
201
+ for (const dirName of (0, scan_1.listRepoDirs)(root, collection)) {
202
+ const label = siteSettings((0, layout_1.repoPath)(root, collection, dirName)).label;
203
+ if (label)
204
+ out.push({ label, collection, repo: (0, scan_1.displayName)(dirName) });
205
+ }
206
+ }
207
+ return out.sort((a, b) => a.label.localeCompare(b.label));
208
+ }
209
+ // ---- the collection half: collections/<name>/site.json ----
210
+ //
211
+ // A derived site hostname is `<repo>--<alias>`, and the alias is not always the
212
+ // collection's name. Collection names are as permissive as repository names, so
213
+ // a collection called `simulated_instruments` has no name a hostname label may
214
+ // carry, and every repository in it would have been left with no derived
215
+ // hostname at all: the tier that exists precisely to be the guaranteed one.
216
+ // Claiming a custom label was the only way out, which put the guaranteed name
217
+ // behind a first-come namespace.
218
+ //
219
+ // So each collection has a site alias, decided in three tiers:
220
+ //
221
+ // 1. the alias stored in collections/<name>/site.json, when one is set. It is
222
+ // checked against every other collection where it is written, so it is
223
+ // unique by construction.
224
+ // 2. otherwise the collection's own name, when that name is already a usable
225
+ // label. This tier can never be taken away by anything: a vault serving
226
+ // `webapp--alice` keeps serving it whatever else is created.
227
+ // 3. otherwise the name rewritten as a label (`simulated-instruments`), and
228
+ // only when no other collection holds that label at any tier. The rewrite
229
+ // is not injective, so `a_b` and `a.b` would both want `a-b`; neither gets
230
+ // it, symmetrically, rather than one silently winning. An owner breaks the
231
+ // tie by storing an alias at tier 1.
232
+ //
233
+ // A separate file rather than a key in collection.json: that file is written
234
+ // through a normalizer that keeps only the owners it knows, so an alias stored
235
+ // there would be dropped by the next owner change. It is the same reason the
236
+ // per-repository settings above are not in mochi.json.
237
+ exports.COLLECTION_SITE_FILE = 'site.json';
238
+ /** Whether a string may be stored as a collection's site alias. */
239
+ function isUsableCollectionAlias(alias) {
240
+ return (0, siteshost_1.isSiteLabelSafe)(alias) && !alias.includes('--') && alias.length <= siteshost_1.MAX_SITE_LABEL;
241
+ }
242
+ function collectionSiteFile(root, collection) {
243
+ return path.join((0, layout_1.collectionDir)(root, collection), exports.COLLECTION_SITE_FILE);
244
+ }
245
+ function normalizeCollectionSite(parsed) {
246
+ if (typeof parsed !== 'object' || parsed === null)
247
+ return '';
248
+ const alias = parsed.alias;
249
+ return typeof alias === 'string' && isUsableCollectionAlias(alias) ? alias : '';
250
+ }
251
+ const aliasCache = (0, filecache_1.fileCache)({
252
+ read: (file) => {
253
+ try {
254
+ return normalizeCollectionSite(JSON.parse(fs.readFileSync(file, 'utf8')));
255
+ }
256
+ catch {
257
+ // An unreadable file stores no alias, which leaves the collection on the
258
+ // tiers below rather than taking its derived hostnames away.
259
+ return '';
260
+ }
261
+ },
262
+ missing: () => '',
263
+ });
264
+ /** The alias stored for a collection, or '' when it is on a lower tier. */
265
+ function storedCollectionAlias(root, collection) {
266
+ return aliasCache.get(collectionSiteFile(root, collection));
267
+ }
268
+ /**
269
+ * Store a collection's site alias, or clear it with ''. Under a lock and with
270
+ * the cache invalidated, on the same terms as editSiteSettings above; the file
271
+ * is removed rather than left holding an empty alias, so a collection that has
272
+ * never had one and one that had it cleared look the same on disk. The caller
273
+ * checks collectionAliasConflict first: this writes what it is given.
274
+ */
275
+ function setCollectionAlias(root, collection, alias) {
276
+ const file = collectionSiteFile(root, collection);
277
+ (0, atomic_1.withFileLock)(`${file}.lock`, () => {
278
+ if (alias === '')
279
+ fs.rmSync(file, { force: true });
280
+ else
281
+ (0, atomic_1.writeFileAtomic)(file, JSON.stringify({ alias }, null, 2) + '\n');
282
+ aliasCache.invalidate(file);
283
+ });
284
+ }
285
+ /**
286
+ * Every collection's site alias, resolved together because the tiers are
287
+ * decided against each other. Sorted by collection name, as listCollections
288
+ * returns them.
289
+ */
290
+ function collectionSiteAliases(root) {
291
+ const rows = [];
292
+ // Tiers 1 and 2 first: both are unambiguous on their own, and they are what
293
+ // tier 3 has to give way to.
294
+ const claimed = new Map();
295
+ const pending = new Map();
296
+ for (const { name } of (0, scan_1.listCollections)(root)) {
297
+ const stored = storedCollectionAlias(root, name);
298
+ if (stored) {
299
+ rows.push({ collection: name, alias: stored, stored: true, taken: '' });
300
+ if (!claimed.has(stored))
301
+ claimed.set(stored, name);
302
+ }
303
+ else if ((0, siteshost_1.isSiteLabelSafe)(name)) {
304
+ rows.push({ collection: name, alias: name, stored: false, taken: '' });
305
+ if (!claimed.has(name))
306
+ claimed.set(name, name);
307
+ }
308
+ else {
309
+ rows.push({ collection: name, alias: null, stored: false, taken: '' });
310
+ const wanted = (0, siteshost_1.sanitizedSiteLabel)(name);
311
+ if (wanted)
312
+ pending.set(name, wanted);
313
+ }
314
+ }
315
+ // Then tier 3, refused where the label is claimed above or wanted twice.
316
+ const wantedTwice = new Set();
317
+ const seen = new Set();
318
+ for (const wanted of pending.values()) {
319
+ if (seen.has(wanted))
320
+ wantedTwice.add(wanted);
321
+ seen.add(wanted);
322
+ }
323
+ for (const row of rows) {
324
+ const wanted = pending.get(row.collection);
325
+ if (!wanted)
326
+ continue;
327
+ if (claimed.has(wanted) || wantedTwice.has(wanted))
328
+ row.taken = wanted;
329
+ else
330
+ row.alias = wanted;
331
+ }
332
+ return rows;
333
+ }
334
+ /**
335
+ * One collection's site alias, or null when it has none and its repositories
336
+ * therefore have no derived hostname.
337
+ *
338
+ * The two common cases answer without listing the vault's collections at all,
339
+ * since tiers 1 and 2 are decided by the collection alone; only a name that
340
+ * needs rewriting pays for the full resolution.
341
+ */
342
+ function collectionSiteAlias(root, collection) {
343
+ const stored = storedCollectionAlias(root, collection);
344
+ if (stored)
345
+ return stored;
346
+ if ((0, siteshost_1.isSiteLabelSafe)(collection))
347
+ return collection;
348
+ return collectionSiteAliases(root).find((r) => r.collection === collection)?.alias ?? null;
349
+ }
350
+ /**
351
+ * The collection a derived hostname's alias names, or null when no collection
352
+ * answers to it. The fast path is the same shortcut collectionSiteAlias takes:
353
+ * a collection named exactly the alias, with nothing stored, holds it at tier 2
354
+ * and nothing can have taken it.
355
+ */
356
+ function collectionForSiteAlias(root, alias) {
357
+ if (!isUsableCollectionAlias(alias))
358
+ return null;
359
+ if ((0, scan_1.isValidName)(alias) && isCollectionDir(root, alias) && storedCollectionAlias(root, alias) === '')
360
+ return alias;
361
+ return collectionSiteAliases(root).find((r) => r.alias === alias)?.collection ?? null;
362
+ }
363
+ function isCollectionDir(root, collection) {
364
+ try {
365
+ return fs.statSync((0, layout_1.collectionDir)(root, collection)).isDirectory();
366
+ }
367
+ catch {
368
+ return false;
369
+ }
370
+ }
371
+ /**
372
+ * The collection already holding an alias, or null when it is free. Asked where
373
+ * an alias is being stored; the collection storing it does not conflict with
374
+ * itself. Every tier is checked, so storing an alias can neither shadow a
375
+ * collection that is reached by its own name nor take a rewritten label from
376
+ * the collection using it.
377
+ */
378
+ function collectionAliasConflict(root, alias, collection) {
379
+ const holder = collectionSiteAliases(root).find((r) => r.alias === alias && r.collection !== collection);
380
+ return holder ? holder.collection : null;
381
+ }
382
+ /**
383
+ * The full hostname a repository's site would be derived to, or null when the
384
+ * pair has no derived hostname: the naming rule and the vault's collections in
385
+ * one call, which is what every caller outside this file wants.
386
+ */
387
+ function siteHostFor(root, sitesHost, collection, repo) {
388
+ if (!sitesHost)
389
+ return null;
390
+ const alias = collectionSiteAlias(root, collection);
391
+ return alias === null ? null : (0, siteshost_1.derivedSiteHost)(sitesHost, alias, repo);
392
+ }
393
+ /**
394
+ * The collection and repository a hostname under the sites host names, or
395
+ * null: the same question in the other direction, and the one both request
396
+ * paths that see such a hostname ask (serving the site, and counting its
397
+ * bytes).
398
+ *
399
+ * A label with a doubled hyphen is a derived name, and one without is a label
400
+ * some repository claimed; the two cannot collide, since a claimed label may
401
+ * never contain the separator. The names are returned as the hostname carries
402
+ * them, resolved to a repository that exists by the caller, as usual: an alias
403
+ * naming no collection is read as a collection name instead, which is what
404
+ * keeps a renamed collection's published hostnames redirecting rather than
405
+ * answering nothing.
406
+ */
407
+ function findRepoBySiteHostname(root, sitesHost, hostname) {
408
+ const label = (0, siteshost_1.siteHostLabel)(sitesHost, hostname);
409
+ if (label === null)
410
+ return null;
411
+ if (!label.includes('--'))
412
+ return findRepoBySiteLabel(root, label);
413
+ const parsed = (0, siteshost_1.parseSiteHost)(sitesHost, hostname);
414
+ if (!parsed)
415
+ return null;
416
+ return {
417
+ collection: collectionForSiteAlias(root, parsed.collectionAlias) ?? parsed.collectionAlias,
418
+ repo: parsed.repo,
419
+ };
420
+ }
package/dist/siteshost.js CHANGED
@@ -1,16 +1,28 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_SITE_LABEL = void 0;
3
4
  exports.isSiteLabelSafe = isSiteLabelSafe;
5
+ exports.isReservedSiteLabel = isReservedSiteLabel;
6
+ exports.reservedSiteLabels = reservedSiteLabels;
7
+ exports.sanitizedSiteLabel = sanitizedSiteLabel;
4
8
  exports.normalizeHostname = normalizeHostname;
5
- exports.siteHostFor = siteHostFor;
9
+ exports.derivedSiteHost = derivedSiteHost;
10
+ exports.siteHostLabel = siteHostLabel;
6
11
  exports.parseSiteHost = parseSiteHost;
7
12
  exports.isUnderSitesHost = isUnderSitesHost;
8
13
  const scan_1 = require("./scan");
9
14
  // The grammar of a per-site hostname, in both directions, so that the parser
10
15
  // and the link builder cannot drift apart. A site served from
11
- // <repo>--<collection>.<sitesHost> has an origin of its own, which is what gives
16
+ // <repo>--<alias>.<sitesHost> has an origin of its own, which is what gives
12
17
  // it back the cookies, storage, and service workers that the sandbox on the
13
18
  // forge host takes away.
19
+ //
20
+ // The `alias` half is the collection's site alias rather than its name: a
21
+ // collection may be called something no hostname label can be, and resolving
22
+ // the two is a question about the vault's collections, which this file cannot
23
+ // see. Everything here is a pure function of the strings it is given, so the
24
+ // grammar stays testable and cheap; src/sitesettings.ts holds the half that
25
+ // reads the vault.
14
26
  /**
15
27
  * Whether a collection or repository name may appear in a site hostname. The
16
28
  * regex is the whole definition: lowercase letters, digits, and single interior
@@ -20,8 +32,67 @@ const scan_1 = require("./scan");
20
32
  function isSiteLabelSafe(name) {
21
33
  return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(name);
22
34
  }
23
- /** The DNS limit on one label, which <repo>--<collection> has to fit inside. */
24
- const MAX_LABEL = 63;
35
+ /** The DNS limit on one label, which <repo>--<alias> has to fit inside. */
36
+ exports.MAX_SITE_LABEL = 63;
37
+ /**
38
+ * Labels under the sites host that no repository may claim.
39
+ *
40
+ * The sites host is a name the operator will want to use for other things:
41
+ * `www` for a landing page, `api` for something in front of the vault, the
42
+ * mail and nameserver names a registrar's defaults create. A repository that
43
+ * had claimed one of those would quietly own it, and taking it back would mean
44
+ * finding the repository holding it. Reserving them is the cheap direction.
45
+ *
46
+ * The set applies only to the labels a repository claims for itself, not to a
47
+ * collection's site alias: an alias appears in a hostname only after
48
+ * `<repo>--`, where it can shadow nothing. Note that `xn--anything` needs no
49
+ * reserving, since a claimed label may not contain a double hyphen at all.
50
+ */
51
+ const RESERVED_SITE_LABELS = new Set([
52
+ 'admin',
53
+ 'api',
54
+ 'assets',
55
+ 'cdn',
56
+ 'forge',
57
+ 'git',
58
+ 'localhost',
59
+ 'mail',
60
+ 'ns1',
61
+ 'ns2',
62
+ 'sites',
63
+ 'smtp',
64
+ 'static',
65
+ 'vault',
66
+ 'www',
67
+ ]);
68
+ /** Whether a label is one the operator keeps, so no repository may claim it. */
69
+ function isReservedSiteLabel(label) {
70
+ return RESERVED_SITE_LABELS.has(label);
71
+ }
72
+ /** The reserved labels, sorted, for the settings page to name them. */
73
+ function reservedSiteLabels() {
74
+ return [...RESERVED_SITE_LABELS].sort();
75
+ }
76
+ /**
77
+ * A name rewritten as a hostname label, or '' when nothing usable is left.
78
+ *
79
+ * Every run of characters a label may not contain becomes a single hyphen and
80
+ * the ends are trimmed, so `Simulated_Instruments` becomes
81
+ * `simulated-instruments`. Collapsing the runs is what keeps a double hyphen
82
+ * out of the result, which the `<repo>--<alias>` grammar depends on.
83
+ *
84
+ * The rewrite is not injective: `a_b` and `a.b` both come out as `a-b`. It is
85
+ * therefore only ever used as a fallback that is dropped when two collections
86
+ * would land on the same label; see collectionSiteAliases in
87
+ * src/sitesettings.ts.
88
+ */
89
+ function sanitizedSiteLabel(name) {
90
+ const label = name
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9]+/g, '-')
93
+ .replace(/^-+|-+$/g, '');
94
+ return isSiteLabelSafe(label) && label.length <= exports.MAX_SITE_LABEL ? label : '';
95
+ }
25
96
  /**
26
97
  * The one normalization a hostname gets, wherever one is read. Exported because
27
98
  * the configured sites host and the request's own hostname are compared against
@@ -32,36 +103,39 @@ function normalizeHostname(hostname) {
32
103
  return hostname.trim().toLowerCase().replace(/\.$/, '');
33
104
  }
34
105
  /**
35
- * The full hostname for a repository's site, or null if it is not eligible.
106
+ * The full hostname for a repository's site under a collection's site alias, or
107
+ * null if the pair is not eligible.
36
108
  *
37
- * Repository and collection names are more permissive than a hostname label
38
- * (isValidName allows '.', '_', and uppercase), so not every repository is
39
- * eligible, and an ineligible one keeps being served on the forge host. Refusing
40
- * rather than mangling is deliberate: lowercasing `Webapp1` would collide with a
41
- * `webapp1` beside it, since hostnames are case-insensitive and both names are
42
- * legal on disk.
109
+ * Repository names are more permissive than a hostname label (isValidName
110
+ * allows '.', '_', and uppercase), so not every repository is eligible, and an
111
+ * ineligible one keeps being served on the forge host unless its admin claims a
112
+ * label that is usable. Refusing rather than mangling is deliberate here:
113
+ * lowercasing `Webapp1` would collide with a `webapp1` beside it, since
114
+ * hostnames are case-insensitive and both names are legal on disk. A
115
+ * collection's alias is where the mangling is done instead, because it can be
116
+ * checked against every other collection first, which a repository name inside
117
+ * one collection cannot be.
43
118
  */
44
- function siteHostFor(sitesHost, collection, repo) {
119
+ function derivedSiteHost(sitesHost, collectionAlias, repo) {
45
120
  if (!sitesHost)
46
121
  return null;
47
122
  const name = (0, scan_1.displayName)(repo);
48
- if (!isSiteLabelSafe(name) || !isSiteLabelSafe(collection))
123
+ if (!isSiteLabelSafe(name) || !isSiteLabelSafe(collectionAlias))
49
124
  return null;
50
- const label = `${name}--${collection}`;
51
- if (label.length > MAX_LABEL)
125
+ const label = `${name}--${collectionAlias}`;
126
+ if (label.length > exports.MAX_SITE_LABEL)
52
127
  return null;
53
128
  return `${label}.${sitesHost}`;
54
129
  }
55
130
  /**
56
- * The collection and repository a request's hostname names, or null.
57
- *
58
- * The double hyphen is an unambiguous separator precisely because neither half
59
- * may contain one, so `a--b` in collection `c` cannot be confused with `a` in
60
- * collection `b--c`. It also means no label can begin `xn--`, which a browser
61
- * would read as punycode. No filesystem access happens here; the caller resolves
62
- * the names with findRepo as usual.
131
+ * The single label a hostname adds under the sites host, or null: null for a
132
+ * hostname elsewhere, for the bare sites host, and for a deeper name such as
133
+ * a.b.<sitesHost>, which is refused and is not covered by a single wildcard
134
+ * certificate anyway. What the label means is the caller's question: one
135
+ * containing `--` is a derived <repo>--<alias> name for parseSiteHost below,
136
+ * and one without is a custom label a repository may have claimed.
63
137
  */
64
- function parseSiteHost(sitesHost, hostname) {
138
+ function siteHostLabel(sitesHost, hostname) {
65
139
  if (!sitesHost)
66
140
  return null;
67
141
  const host = normalizeHostname(hostname);
@@ -69,17 +143,31 @@ function parseSiteHost(sitesHost, hostname) {
69
143
  if (!host.endsWith(suffix))
70
144
  return null;
71
145
  const label = host.slice(0, -suffix.length);
72
- // Exactly one remaining label. A deeper name such as a.b.<sitesHost> is
73
- // refused, and is not covered by a single wildcard certificate anyway.
74
146
  if (label === '' || label.includes('.'))
75
147
  return null;
148
+ return label;
149
+ }
150
+ /**
151
+ * The collection alias and repository a request's hostname names, or null.
152
+ *
153
+ * The double hyphen is an unambiguous separator precisely because neither half
154
+ * may contain one, so `a--b` under alias `c` cannot be confused with `a` under
155
+ * alias `b--c`. It also means no label can begin `xn--`, which a browser would
156
+ * read as punycode. No filesystem access happens here: the alias is turned into
157
+ * a collection by collectionForSiteAlias, and the repository resolved with
158
+ * findRepo, as usual.
159
+ */
160
+ function parseSiteHost(sitesHost, hostname) {
161
+ const label = siteHostLabel(sitesHost, hostname);
162
+ if (label === null)
163
+ return null;
76
164
  const parts = label.split('--');
77
165
  if (parts.length !== 2)
78
166
  return null;
79
- const [repo, collection] = parts;
80
- if (!isSiteLabelSafe(repo) || !isSiteLabelSafe(collection))
167
+ const [repo, collectionAlias] = parts;
168
+ if (!isSiteLabelSafe(repo) || !isSiteLabelSafe(collectionAlias))
81
169
  return null;
82
- return { collection, repo };
170
+ return { collectionAlias, repo };
83
171
  }
84
172
  /**
85
173
  * Whether a hostname belongs to the sites host at all, including the bare name
package/dist/source.js CHANGED
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.parseSource = parseSource;
37
+ exports.parseUpstream = parseUpstream;
37
38
  const fs = __importStar(require("fs"));
38
39
  const path = __importStar(require("path"));
39
40
  // Where an import reads from. `mochi import` accepts an https git URL, an
@@ -88,3 +89,22 @@ function parseSource(input) {
88
89
  const name = nameOf(url);
89
90
  return name === '' ? null : { url, name, github: githubOf(url) };
90
91
  }
92
+ function parseUpstream(input) {
93
+ if (typeof input !== 'string' || input === '')
94
+ return null;
95
+ const github = githubOf(input);
96
+ if (HTTPS_SOURCE.test(input)) {
97
+ const label = input.replace(/^https:\/\//, '').replace(/\.git$/i, '').replace(/\/+$/, '');
98
+ return { url: input, label, web: `https://${label}`, github };
99
+ }
100
+ if (SSH_SOURCE.test(input)) {
101
+ const label = input.replace(/^git@/, '').replace(':', '/').replace(/\.git$/i, '');
102
+ return {
103
+ url: input,
104
+ label,
105
+ web: github ? `https://github.com/${github.owner}/${github.repo}` : null,
106
+ github,
107
+ };
108
+ }
109
+ return null;
110
+ }