@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
@@ -0,0 +1,416 @@
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.registerAdminApi = registerAdminApi;
37
+ const fs = __importStar(require("fs"));
38
+ const config_1 = require("../config");
39
+ const layout_1 = require("../layout");
40
+ const ops_1 = require("../ops");
41
+ const redirects_1 = require("../redirects");
42
+ const scan_1 = require("../scan");
43
+ const siteshost_1 = require("../siteshost");
44
+ const themes_1 = require("../themes");
45
+ const perms_1 = require("../perms");
46
+ const vault_1 = require("../vault");
47
+ const auth_1 = require("./auth");
48
+ // Administration: users, their tokens, collections, and the vault's own settings.
49
+ //
50
+ // Reading a user's tokens never returns a token. Only a SHA-256 hash is stored, so
51
+ // there is nothing to return even if it were a good idea; what a caller gets is an
52
+ // id, a creation time, and a scope, which is enough to revoke one.
53
+ function registerAdminApi(app, root, limiter, lfs = null, engine, egress) {
54
+ // One context for the collection rename, as the other two surfaces build for
55
+ // theirs; see RepoContext in ops.ts.
56
+ const repoCtx = { lfs: lfs?.store, runs: engine };
57
+ /**
58
+ * A site admin, which is what a vault-wide setting takes. Not merely a
59
+ * collection owner: an owner should not restyle the whole vault or remove a
60
+ * collection that is not theirs, which is the same rule canSetTheme applies
61
+ * on the web.
62
+ */
63
+ const requireOwner = (req, res) => {
64
+ const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
65
+ if (!auth)
66
+ return null;
67
+ if (!(0, perms_1.isSiteAdmin)(auth)) {
68
+ (0, auth_1.apiError)(res, 403, 'site admin required (with an unrestricted token)');
69
+ return null;
70
+ }
71
+ return auth;
72
+ };
73
+ // ---- collections ----
74
+ /**
75
+ * Rename a collection, with everything in it. The same operation the web
76
+ * offers on a collection's settings page, and the same question: ownership
77
+ * of the collection. The owners file moves with the collection, so the
78
+ * owners after the rename are the owners before it.
79
+ *
80
+ * Unlike a repository rename this is not offered under a typed
81
+ * confirmation, here or on the web. A rename is undone by renaming back, and
82
+ * the confirmation belongs to deletion.
83
+ */
84
+ app.post('/api/collections/:name/rename', async (req, res) => {
85
+ const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
86
+ if (!auth)
87
+ return;
88
+ const name = req.params.name;
89
+ if (!(0, scan_1.isValidName)(name)) {
90
+ (0, auth_1.apiError)(res, 400, 'that is not a usable collection name');
91
+ return;
92
+ }
93
+ let isDir = false;
94
+ try {
95
+ isDir = fs.statSync((0, layout_1.collectionDir)(root, name)).isDirectory();
96
+ }
97
+ catch {
98
+ isDir = false;
99
+ }
100
+ if (!isDir) {
101
+ (0, auth_1.apiError)(res, 404, `no collection ${name} in this vault`);
102
+ return;
103
+ }
104
+ const repos = (0, scan_1.listRepoDirs)(root, name).map(scan_1.displayName);
105
+ if (!(0, perms_1.canAdminCollection)(root, auth, name)) {
106
+ (0, auth_1.apiError)(res, 403, `you are not an owner of ${name}`);
107
+ return;
108
+ }
109
+ const to = (0, auth_1.stringField)((0, auth_1.bodyOf)(req), 'name')?.trim() ?? '';
110
+ if (!(0, scan_1.isValidName)(to)) {
111
+ (0, auth_1.apiError)(res, 400, 'a valid "name" is required (letters, digits, dot, underscore, dash; not a reserved word)');
112
+ return;
113
+ }
114
+ try {
115
+ await (0, ops_1.renameCollection)(root, name, to, repoCtx);
116
+ res.json({ name: to, renamedFrom: name, repos: repos.length, renamed: true });
117
+ }
118
+ catch (e) {
119
+ (0, auth_1.sendOpError)(res, e, 'could not rename the collection');
120
+ }
121
+ });
122
+ // Only an empty one, and only a directory: a collection is a directory, so
123
+ // removing it is an rmdir and refusing a non-empty one is the filesystem's own
124
+ // rule rather than a policy invented here.
125
+ app.delete('/api/collections/:name', (req, res) => {
126
+ const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
127
+ if (!auth)
128
+ return;
129
+ const name = req.params.name;
130
+ if (!(0, perms_1.canAdminCollection)(root, auth, name)) {
131
+ (0, auth_1.apiError)(res, 403, `you are not an owner of ${name}`);
132
+ return;
133
+ }
134
+ if (!(0, scan_1.isValidName)(name)) {
135
+ (0, auth_1.apiError)(res, 400, 'that is not a usable collection name');
136
+ return;
137
+ }
138
+ const dir = (0, layout_1.collectionDir)(root, name);
139
+ let isDir = false;
140
+ try {
141
+ isDir = fs.statSync(dir).isDirectory();
142
+ }
143
+ catch {
144
+ isDir = false;
145
+ }
146
+ if (!isDir) {
147
+ (0, auth_1.apiError)(res, 404, `no collection ${name} in this vault`);
148
+ return;
149
+ }
150
+ // Empty means the collection holds nothing but its own empty repos
151
+ // directory: no repository, nothing beside one, and nothing of the
152
+ // collection's own.
153
+ const repos = (0, layout_1.reposDir)(root, name);
154
+ const own = fs.readdirSync(dir).filter((n) => n !== layout_1.REPOS_DIR);
155
+ const inRepos = fs.existsSync(repos) ? fs.readdirSync(repos) : [];
156
+ if (own.length > 0 || inRepos.length > 0) {
157
+ (0, auth_1.apiError)(res, 409, `collection ${name} is not empty`);
158
+ return;
159
+ }
160
+ try {
161
+ fs.rmSync(dir, { recursive: true });
162
+ }
163
+ catch (e) {
164
+ (0, auth_1.apiError)(res, 409, `could not remove ${name}: ${e instanceof Error ? e.message : String(e)}`);
165
+ return;
166
+ }
167
+ // Any redirect that led here goes with it. A collection created later
168
+ // under this name would otherwise inherit the traffic a former name of
169
+ // this one still sends.
170
+ (0, redirects_1.forgetCollectionRedirects)(root, name);
171
+ res.json({ deleted: name });
172
+ });
173
+ // ---- users and their tokens ----
174
+ app.get('/api/users/:name', (req, res) => {
175
+ const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
176
+ if (!auth)
177
+ return;
178
+ const state = (0, vault_1.loadVault)(root);
179
+ if (state.status !== 'ok') {
180
+ (0, auth_1.apiError)(res, 500, 'vault unavailable');
181
+ return;
182
+ }
183
+ const user = state.vault.users[req.params.name];
184
+ if (!user) {
185
+ (0, auth_1.apiError)(res, 404, `no user ${req.params.name}`);
186
+ return;
187
+ }
188
+ // A user may read their own record; reading anyone else's takes a site
189
+ // admin.
190
+ if (req.params.name !== auth.username && !(0, perms_1.isSiteAdmin)(auth)) {
191
+ (0, auth_1.apiError)(res, 403, 'site admin required to touch another user');
192
+ return;
193
+ }
194
+ res.json({
195
+ name: req.params.name,
196
+ siteAdmin: user.siteAdmin === true,
197
+ tokens: user.tokens.map((t) => ({ id: (0, vault_1.tokenId)(t), created: t.created ?? null, scope: t.scope ?? null })),
198
+ });
199
+ });
200
+ app.get('/api/users/:name/tokens', (req, res) => {
201
+ const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
202
+ if (!auth)
203
+ return;
204
+ const state = (0, vault_1.loadVault)(root);
205
+ if (state.status !== 'ok') {
206
+ (0, auth_1.apiError)(res, 500, 'vault unavailable');
207
+ return;
208
+ }
209
+ const user = state.vault.users[req.params.name];
210
+ if (!user) {
211
+ (0, auth_1.apiError)(res, 404, `no user ${req.params.name}`);
212
+ return;
213
+ }
214
+ if (req.params.name !== auth.username && !(0, perms_1.isSiteAdmin)(auth)) {
215
+ (0, auth_1.apiError)(res, 403, 'site admin required to touch another user');
216
+ return;
217
+ }
218
+ // Never the token, and never the hash either: an id is what revocation
219
+ // takes, and the hash is a credential-shaped thing with no reason to travel.
220
+ res.json({
221
+ tokens: user.tokens.map((t) => ({ id: (0, vault_1.tokenId)(t), created: t.created ?? null, scope: t.scope ?? null })),
222
+ });
223
+ });
224
+ app.delete('/api/users/:name/tokens/:id', (req, res) => {
225
+ const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
226
+ if (!auth)
227
+ return;
228
+ const state = (0, vault_1.loadVault)(root);
229
+ if (state.status !== 'ok') {
230
+ (0, auth_1.apiError)(res, 500, 'vault unavailable');
231
+ return;
232
+ }
233
+ const user = state.vault.users[req.params.name];
234
+ if (!user) {
235
+ (0, auth_1.apiError)(res, 404, `no user ${req.params.name}`);
236
+ return;
237
+ }
238
+ const ownToken = req.params.name === auth.username && (0, vault_1.tokenId)(auth.token) === req.params.id;
239
+ if (req.params.name !== auth.username && !(0, perms_1.isSiteAdmin)(auth)) {
240
+ (0, auth_1.apiError)(res, 403, 'site admin required to touch another user');
241
+ return;
242
+ }
243
+ let result;
244
+ try {
245
+ result = (0, vault_1.revokeToken)(root, req.params.name, req.params.id);
246
+ }
247
+ catch (e) {
248
+ (0, auth_1.apiError)(res, 500, e instanceof Error ? e.message : String(e));
249
+ return;
250
+ }
251
+ if (!result.revoked) {
252
+ (0, auth_1.apiError)(res, 404, `no token ${req.params.id} for ${req.params.name}`);
253
+ return;
254
+ }
255
+ // Revoking the token in use is allowed. It is reported rather than refused:
256
+ // locking yourself out is your business, and vault.json stays hand-editable.
257
+ res.json({ revoked: req.params.id, remaining: result.remaining, wasThisToken: ownToken });
258
+ });
259
+ app.delete('/api/users/:name', (req, res) => {
260
+ const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
261
+ if (!auth)
262
+ return;
263
+ const state = (0, vault_1.loadVault)(root);
264
+ if (state.status !== 'ok') {
265
+ (0, auth_1.apiError)(res, 500, 'vault unavailable');
266
+ return;
267
+ }
268
+ const user = state.vault.users[req.params.name];
269
+ if (!user) {
270
+ (0, auth_1.apiError)(res, 404, `no user ${req.params.name}`);
271
+ return;
272
+ }
273
+ if (!(0, perms_1.isSiteAdmin)(auth)) {
274
+ (0, auth_1.apiError)(res, 403, 'site admin required to touch another user');
275
+ return;
276
+ }
277
+ // Deleting yourself would leave a vault an owner cannot administer except by
278
+ // hand, and unlike revoking one token it cannot be undone by minting another.
279
+ if (req.params.name === auth.username) {
280
+ (0, auth_1.apiError)(res, 409, 'a user cannot delete themselves; another admin can, or edit vault.json by hand');
281
+ return;
282
+ }
283
+ if (String(req.query.confirm ?? '') !== req.params.name) {
284
+ (0, auth_1.apiError)(res, 400, `to remove this user and every token they hold, send ?confirm=${req.params.name}`);
285
+ return;
286
+ }
287
+ const removed = (0, vault_1.removeUser)(root, req.params.name);
288
+ // Their grants go with them: a collaborator entry or an owners listing
289
+ // left behind would belong to whoever is given this name next.
290
+ if (removed)
291
+ (0, perms_1.removeUserGrants)(root, req.params.name);
292
+ res.json({ deleted: req.params.name, removed });
293
+ });
294
+ // ---- vault settings ----
295
+ app.get('/api/config', (req, res) => {
296
+ const auth = requireOwner(req, res);
297
+ if (!auth)
298
+ return;
299
+ const config = (0, config_1.loadConfig)(root);
300
+ res.json({ ...config, themes: (0, themes_1.themeNames)() });
301
+ });
302
+ app.patch('/api/config', (req, res) => {
303
+ const auth = requireOwner(req, res);
304
+ if (!auth)
305
+ return;
306
+ const body = (0, auth_1.bodyOf)(req);
307
+ const changes = {};
308
+ if (body.theme !== undefined) {
309
+ if (typeof body.theme !== 'string' || !(0, themes_1.findTheme)(body.theme)) {
310
+ (0, auth_1.apiError)(res, 400, `"theme" must be one of: ${(0, themes_1.themeNames)().join(', ')} (default ${themes_1.DEFAULT_THEME})`);
311
+ return;
312
+ }
313
+ changes.theme = body.theme;
314
+ }
315
+ if (body.ci !== undefined) {
316
+ if (typeof body.ci !== 'object' || body.ci === null || Array.isArray(body.ci)) {
317
+ (0, auth_1.apiError)(res, 400, '"ci" must be an object');
318
+ return;
319
+ }
320
+ const ci = body.ci;
321
+ const current = (0, config_1.loadConfig)(root).ci;
322
+ const number = (v, fallback, min) => typeof v === 'number' && Number.isFinite(v) && v >= min ? Math.floor(v) : fallback;
323
+ changes.ci = {
324
+ runs: number(ci.runs, current.runs, 0),
325
+ days: number(ci.days, current.days, 0),
326
+ artifactMb: number(ci.artifactMb, current.artifactMb, 1),
327
+ };
328
+ }
329
+ // The hostname whose subdomains serve repository sites. Every reader of it
330
+ // calls loadConfig per request, so a change here is in effect on the next
331
+ // one; it is the setting a hosted vault most needs to change, and reaching a
332
+ // volume to edit config.json by hand is the worst step in that whole path.
333
+ if (body.sites !== undefined) {
334
+ if (typeof body.sites !== 'object' || body.sites === null || Array.isArray(body.sites)) {
335
+ (0, auth_1.apiError)(res, 400, '"sites" must be an object');
336
+ return;
337
+ }
338
+ const sites = body.sites;
339
+ if (typeof sites.host !== 'string') {
340
+ (0, auth_1.apiError)(res, 400, '"sites" takes a "host" string; send "" to serve sites on the forge host again');
341
+ return;
342
+ }
343
+ const host = (0, siteshost_1.normalizeHostname)(sites.host);
344
+ // loadConfig ignores a value that is not a hostname and uses the default,
345
+ // which is right for a hand-edited file and wrong here: a caller who just
346
+ // asked for a change should be told it was not one, rather than reading
347
+ // back a value they did not send.
348
+ if (host !== '' && !(0, config_1.isPlausibleHostname)(host)) {
349
+ (0, auth_1.apiError)(res, 400, `"${sites.host}" is not a hostname: give at least two labels of letters, digits, and interior hyphens, ` +
350
+ 'with no scheme, port, or path');
351
+ return;
352
+ }
353
+ changes.sites = { host };
354
+ }
355
+ // One field of the limits block is writable, and only the one: the daily
356
+ // egress cap is read per request, so a change to it is in force on the next
357
+ // one. The rest of the block is read at startup, so a route that changed it
358
+ // would report a change the running server had not made.
359
+ //
360
+ // The whole block is written back, merged over what is on disk, because
361
+ // saveConfig replaces a top-level key rather than merging into it.
362
+ if (body.limits !== undefined) {
363
+ if (typeof body.limits !== 'object' || body.limits === null || Array.isArray(body.limits)) {
364
+ (0, auth_1.apiError)(res, 400, '"limits" must be an object');
365
+ return;
366
+ }
367
+ const limits = body.limits;
368
+ const unknown = Object.keys(limits).filter((k) => k !== 'egressGbPerDay');
369
+ if (unknown.length > 0) {
370
+ (0, auth_1.apiError)(res, 400, `only "egressGbPerDay" can be set here; ${unknown.join(', ')} ${unknown.length === 1 ? 'is' : 'are'} read when the server starts, so edit config.json in the vault and restart`);
371
+ return;
372
+ }
373
+ const gb = limits.egressGbPerDay;
374
+ if (typeof gb !== 'number' || !Number.isFinite(gb) || gb < 0) {
375
+ (0, auth_1.apiError)(res, 400, '"egressGbPerDay" must be a number of gigabytes, 0 to send without a daily limit');
376
+ return;
377
+ }
378
+ changes.limits = { ...(0, config_1.loadConfig)(root).limits, egressGbPerDay: gb };
379
+ }
380
+ if (Object.keys(changes).length === 0) {
381
+ (0, auth_1.apiError)(res, 400, 'nothing to change; provide "theme", "ci", "sites", and/or "limits"');
382
+ return;
383
+ }
384
+ // network is deliberately not writable here, and neither is the rest of
385
+ // limits: both are read once at startup, so a route that changed them would
386
+ // report a change the running server had not made. docs/deploying.md says to
387
+ // edit config.json and restart.
388
+ res.json((0, config_1.saveConfig)(root, changes));
389
+ });
390
+ // ---- outgoing bytes ----
391
+ /**
392
+ * What the vault has sent today, per repository, and what it sent on the days
393
+ * before. The same numbers /admin/egress shows, for anyone who would rather
394
+ * watch a bill from a script.
395
+ *
396
+ * Owner scope, like the rest of the vault's own settings: the breakdown says
397
+ * which repositories are being read and how heavily, which is more than a
398
+ * collection administrator is owed about a collection that is not theirs.
399
+ */
400
+ app.get('/api/egress', (req, res) => {
401
+ const auth = requireOwner(req, res);
402
+ if (!auth)
403
+ return;
404
+ if (!egress) {
405
+ (0, auth_1.apiError)(res, 503, 'this server is not counting outgoing bytes');
406
+ return;
407
+ }
408
+ const snap = egress.snapshot();
409
+ res.json({
410
+ ...snap,
411
+ // Said here as well as on the page: a caller adding these numbers up
412
+ // against a hosting bill needs to know what is missing from them.
413
+ lfsBucketExcluded: lfs?.offloaded ?? false,
414
+ });
415
+ });
416
+ }
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.apiError = apiError;
4
+ exports.requireApiAuth = requireApiAuth;
5
+ exports.requireRepo = requireRepo;
6
+ exports.requirePush = requirePush;
7
+ exports.requireRepoAdmin = requireRepoAdmin;
8
+ exports.sendOpError = sendOpError;
9
+ exports.bodyOf = bodyOf;
10
+ exports.stringField = stringField;
11
+ exports.stringsField = stringsField;
12
+ exports.limitParam = limitParam;
13
+ const ops_1 = require("../ops");
14
+ const perms_1 = require("../perms");
15
+ const scan_1 = require("../scan");
16
+ const vault_1 = require("../vault");
17
+ // Authorization for the JSON API, in one place because there is more than one
18
+ // file of routes behind it. Only bearer tokens are accepted: session cookies
19
+ // never authorize an API call, and git's Basic auth never does either.
20
+ //
21
+ // Anonymous reads are deliberately not offered. The web is where anonymous
22
+ // reading lives, and requiring a token on /api keeps one rule for the whole
23
+ // surface.
24
+ //
25
+ // Two transports over one domain layer is two places to forget a role check, so
26
+ // the shape here is not a helper a handler may neglect to call. requireRepo and
27
+ // requirePush are what produce the repository a domain function needs, and they
28
+ // load the repository and the scope together: a handler that skips the check has
29
+ // nothing to pass to the domain function.
30
+ function apiError(res, status, message) {
31
+ res.status(status).json({ error: message });
32
+ }
33
+ /**
34
+ * The caller's identity, or null having already answered with the refusal. A
35
+ * handler that ignores the null is a handler that runs unauthenticated, so the
36
+ * shape to write is `const auth = requireApiAuth(...); if (!auth) return;`.
37
+ */
38
+ function requireApiAuth(root, limiter, req, res) {
39
+ const state = (0, vault_1.loadVault)(root);
40
+ if (state.status === 'missing') {
41
+ apiError(res, 401, 'no vault.json in this vault; restart the server to initialize one');
42
+ return null;
43
+ }
44
+ if (state.status === 'error') {
45
+ apiError(res, 500, `vault.json could not be read: ${state.message}`);
46
+ return null;
47
+ }
48
+ const m = (req.get('authorization') ?? '').match(/^bearer\s+(.+)$/i);
49
+ // A missing Authorization header is not a failed attempt and is not charged: a
50
+ // browser wandering onto an API path with no header would otherwise consume a
51
+ // real client's budget.
52
+ if (!m) {
53
+ apiError(res, 401, 'missing bearer token: send Authorization: Bearer <token>');
54
+ return null;
55
+ }
56
+ // Bearer tokens carry no username, so only the coarse per-address window
57
+ // really applies to them. Checking a credential is a loadVault and a hash, so
58
+ // the cheapest request an attacker can send is one of the more expensive ones
59
+ // this server can answer.
60
+ const allowed = limiter.allow(req, null);
61
+ if (!allowed.ok) {
62
+ res.setHeader('Retry-After', String(allowed.retryAfter));
63
+ apiError(res, 429, 'too many failed authentication attempts; try again later');
64
+ return null;
65
+ }
66
+ const auth = (0, vault_1.authenticateToken)(state.vault, m[1].trim());
67
+ if (!auth) {
68
+ limiter.fail(req, null);
69
+ apiError(res, 401, 'invalid token');
70
+ return null;
71
+ }
72
+ return auth;
73
+ }
74
+ // The address a commit made over the API is attributed to. The host is the
75
+ // vault's own, as it is for a commit made in the browser, so that the two
76
+ // interfaces do not attribute the same person differently.
77
+ function actorFor(req, auth) {
78
+ const host = (req.get('host') ?? 'localhost').replace(/:\d+$/, '');
79
+ return { username: auth.username, email: `${auth.username}@noreply.${host}` };
80
+ }
81
+ /**
82
+ * The repository named by :collection and :repo, for a caller allowed to read
83
+ * it. `:repo` is accepted with or without the .git suffix, as findRepo already
84
+ * allows everywhere else. A private repository the caller has no role on gets
85
+ * the same 404 an absent one gets, so a private name proves nothing by
86
+ * existing.
87
+ */
88
+ function requireRepo(root, limiter, req, res) {
89
+ const auth = requireApiAuth(root, limiter, req, res);
90
+ if (!auth)
91
+ return null;
92
+ const collection = req.params.collection;
93
+ const repo = (0, scan_1.findRepo)(root, collection, req.params.repo);
94
+ if (!repo || !(0, perms_1.canReadRepo)(root, auth, repo)) {
95
+ apiError(res, 404, `no repository ${collection}/${req.params.repo} in this vault`);
96
+ return null;
97
+ }
98
+ return { auth, repo };
99
+ }
100
+ /** The same, for a caller who may also push to it. */
101
+ function requirePush(root, limiter, req, res) {
102
+ const found = requireRepo(root, limiter, req, res);
103
+ if (!found)
104
+ return null;
105
+ if (!(0, perms_1.canWriteRepo)(root, found.auth, found.repo)) {
106
+ apiError(res, 403, `you do not have the write role on ${found.repo.collection}/${found.repo.name}`);
107
+ return null;
108
+ }
109
+ return { ...found, actor: actorFor(req, found.auth) };
110
+ }
111
+ /** The same, for an operation that needs the admin role on the repository. */
112
+ function requireRepoAdmin(root, limiter, req, res) {
113
+ const found = requireRepo(root, limiter, req, res);
114
+ if (!found)
115
+ return null;
116
+ if (!(0, perms_1.canAdminRepo)(root, found.auth, found.repo)) {
117
+ apiError(res, 403, `you do not have the admin role on ${found.repo.collection}/${found.repo.name}`);
118
+ return null;
119
+ }
120
+ return { ...found, actor: actorFor(req, found.auth) };
121
+ }
122
+ /**
123
+ * Turn a domain failure into a response, once, rather than per route.
124
+ *
125
+ * OpError already carries the distinction the caller needs, and mapping it here
126
+ * is what keeps a validation failure from being reported as a 500. `nochange` is
127
+ * a success: the caller asked for a state the vault is already in.
128
+ */
129
+ function sendOpError(res, e, fallback = 'the operation failed') {
130
+ if (!(e instanceof ops_1.OpError)) {
131
+ console.error(e);
132
+ apiError(res, 500, fallback);
133
+ return;
134
+ }
135
+ if (e.kind === 'nochange') {
136
+ res.json({ changed: false, message: e.message });
137
+ return;
138
+ }
139
+ apiError(res, (0, ops_1.opErrorStatus)(e.kind), e.message);
140
+ }
141
+ /** A route body, with the shape checked far enough to read fields off it. */
142
+ function bodyOf(req) {
143
+ const body = req.body;
144
+ return typeof body === 'object' && body !== null && !Array.isArray(body) ? body : {};
145
+ }
146
+ /** A required string field. */
147
+ function stringField(body, name) {
148
+ const v = body[name];
149
+ return typeof v === 'string' ? v : null;
150
+ }
151
+ /** An optional list-of-strings field: undefined when absent, null when malformed. */
152
+ function stringsField(body, name) {
153
+ const v = body[name];
154
+ if (v === undefined || v === null)
155
+ return undefined;
156
+ if (Array.isArray(v) && v.every((x) => typeof x === 'string'))
157
+ return v;
158
+ return null;
159
+ }
160
+ /** A positive integer from a query parameter, within a cap. */
161
+ function limitParam(raw, fallback, max) {
162
+ const n = Number(raw);
163
+ if (!Number.isInteger(n) || n < 1)
164
+ return fallback;
165
+ return Math.min(n, max);
166
+ }