@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,518 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerWriteApi = registerWriteApi;
4
+ const trigger_1 = require("../ci/trigger");
5
+ const git_1 = require("../git");
6
+ const ops_1 = require("../ops");
7
+ const perms_1 = require("../perms");
8
+ const scan_1 = require("../scan");
9
+ const vault_1 = require("../vault");
10
+ const auth_1 = require("./auth");
11
+ function writeBody(body, fallbackMessage) {
12
+ const branch = (0, auth_1.stringField)(body, 'branch');
13
+ if (branch === null || !(0, git_1.isValidRefName)(branch))
14
+ return '"branch" is required and must be a usable branch name';
15
+ const newBranch = (0, auth_1.stringField)(body, 'newBranch');
16
+ if (newBranch !== null && !(0, git_1.isValidRefName)(newBranch))
17
+ return '"newBranch" is not a usable branch name';
18
+ const expectedSha = (0, auth_1.stringField)(body, 'expectedSha');
19
+ return {
20
+ branch,
21
+ message: (0, auth_1.stringField)(body, 'message')?.trim() || fallbackMessage,
22
+ expectedSha,
23
+ newBranch,
24
+ };
25
+ }
26
+ /**
27
+ * Decode a file's content. base64 for anything that is not text, which is how a
28
+ * caller sends an image or a fixture through a JSON body.
29
+ */
30
+ function decodeContent(body, res) {
31
+ const content = (0, auth_1.stringField)(body, 'content');
32
+ if (content === null) {
33
+ (0, auth_1.apiError)(res, 400, '"content" is required');
34
+ return null;
35
+ }
36
+ const encoding = (0, auth_1.stringField)(body, 'encoding') ?? 'utf-8';
37
+ if (encoding !== 'utf-8' && encoding !== 'base64') {
38
+ (0, auth_1.apiError)(res, 400, '"encoding" must be "utf-8" or "base64"');
39
+ return null;
40
+ }
41
+ const buf = Buffer.from(content, encoding === 'base64' ? 'base64' : 'utf8');
42
+ if (buf.length > ops_1.MAX_EDIT_SIZE) {
43
+ (0, auth_1.apiError)(res, 413, `a single file may be at most ${ops_1.MAX_EDIT_SIZE} bytes; push anything larger, or use Git LFS`);
44
+ return null;
45
+ }
46
+ return buf;
47
+ }
48
+ /** The wildcard tail of a route path. */
49
+ function tail(req) {
50
+ return req.params['0'] ?? '';
51
+ }
52
+ /**
53
+ * The branch tip the write will be guarded against. When the caller supplied an
54
+ * expectedSha it is theirs; otherwise the write is unconditional and takes
55
+ * whatever the branch is at now, which is what an unguarded write means.
56
+ */
57
+ async function baseFor(ctx, body) {
58
+ if (body.expectedSha) {
59
+ // A sha that names no commit here cannot be what the caller last saw, so it
60
+ // is refused as a stale expectation rather than handed to git, which would
61
+ // fail in a way that reads as the server breaking.
62
+ if (!(await ctx.repo.resolve(body.expectedSha))) {
63
+ throw new ops_1.OpError(`no commit ${body.expectedSha} in this repository; re-read the file and try again`, 'conflict');
64
+ }
65
+ return body.expectedSha;
66
+ }
67
+ const branches = await ctx.repo.listRefs('heads');
68
+ return branches.find((b) => b.name === body.branch)?.sha ?? null;
69
+ }
70
+ function reportWriteError(res, e) {
71
+ // A branch that could not be made is not a file that could not be written.
72
+ if (e instanceof ops_1.NewBranchError) {
73
+ (0, auth_1.apiError)(res, (0, ops_1.opErrorStatus)(e.kind), e.message);
74
+ return;
75
+ }
76
+ (0, auth_1.sendOpError)(res, e, 'the write failed');
77
+ }
78
+ function registerWriteApi(app, root, limiter, lfs = null, engine) {
79
+ // As in registerWebOps: one context for the operations that move or remove a
80
+ // repository, so both surfaces carry the same things across.
81
+ const repoCtx = { lfs: lfs?.store, runs: engine };
82
+ const fire = (ctx, branch, before, after, actor) => (0, trigger_1.firePush)(root, engine, { collection: ctx.repo.collection, name: ctx.repo.name }, branch, before, after, actor.username);
83
+ // ---- files ----
84
+ // PUT creates or updates, because a caller that has read a file and means to
85
+ // change it should not have to know which of the two it is doing. The action
86
+ // kind is decided from whether the path is there now.
87
+ app.put('/api/repos/:collection/:repo/contents/*', async (req, res) => {
88
+ const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
89
+ if (!ctx)
90
+ return;
91
+ const filePath = tail(req);
92
+ if (filePath === '' || !(0, git_1.isValidRepoPath)(filePath)) {
93
+ (0, auth_1.apiError)(res, 400, 'that is not a usable path');
94
+ return;
95
+ }
96
+ const body = (0, auth_1.bodyOf)(req);
97
+ const parsed = writeBody(body, `Update ${filePath.split('/').pop()}`);
98
+ if (typeof parsed === 'string') {
99
+ (0, auth_1.apiError)(res, 400, parsed);
100
+ return;
101
+ }
102
+ const content = decodeContent(body, res);
103
+ if (!content)
104
+ return;
105
+ try {
106
+ const base = await baseFor(ctx, parsed);
107
+ const exists = base !== null && (await ctx.repo.entryType(base, filePath)) === 'blob';
108
+ const action = exists ? { kind: 'edit', content } : { kind: 'create', content };
109
+ const written = await (0, ops_1.writeFile)(ctx.repo.dir, {
110
+ branch: parsed.branch,
111
+ newBranch: parsed.newBranch,
112
+ filePath,
113
+ message: parsed.message,
114
+ author: { name: ctx.actor.username, email: ctx.actor.email },
115
+ expectedHead: base,
116
+ action,
117
+ });
118
+ fire(ctx, written.branch, written.before, written.sha, ctx.actor);
119
+ res.json({ branch: written.branch, sha: written.sha, path: filePath, created: !exists });
120
+ }
121
+ catch (e) {
122
+ reportWriteError(res, e);
123
+ }
124
+ });
125
+ app.delete('/api/repos/:collection/:repo/contents/*', async (req, res) => {
126
+ const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
127
+ if (!ctx)
128
+ return;
129
+ const filePath = tail(req);
130
+ if (filePath === '' || !(0, git_1.isValidRepoPath)(filePath)) {
131
+ (0, auth_1.apiError)(res, 400, 'that is not a usable path');
132
+ return;
133
+ }
134
+ const body = (0, auth_1.bodyOf)(req);
135
+ const parsed = writeBody(body, `Delete ${filePath.split('/').pop()}`);
136
+ if (typeof parsed === 'string') {
137
+ (0, auth_1.apiError)(res, 400, parsed);
138
+ return;
139
+ }
140
+ try {
141
+ const written = await (0, ops_1.writeFile)(ctx.repo.dir, {
142
+ branch: parsed.branch,
143
+ newBranch: parsed.newBranch,
144
+ filePath,
145
+ message: parsed.message,
146
+ author: { name: ctx.actor.username, email: ctx.actor.email },
147
+ expectedHead: await baseFor(ctx, parsed),
148
+ action: { kind: 'delete' },
149
+ });
150
+ fire(ctx, written.branch, written.before, written.sha, ctx.actor);
151
+ res.json({ branch: written.branch, sha: written.sha, path: filePath, deleted: true });
152
+ }
153
+ catch (e) {
154
+ reportWriteError(res, e);
155
+ }
156
+ });
157
+ // Several files as one commit, which is what a caller changing three files as
158
+ // one logical edit wants, and what the upload route already does underneath.
159
+ app.post('/api/repos/:collection/:repo/commits', async (req, res) => {
160
+ const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
161
+ if (!ctx)
162
+ return;
163
+ const body = (0, auth_1.bodyOf)(req);
164
+ const parsed = writeBody(body, 'Update files');
165
+ if (typeof parsed === 'string') {
166
+ (0, auth_1.apiError)(res, 400, parsed);
167
+ return;
168
+ }
169
+ const rawFiles = body.files;
170
+ if (!Array.isArray(rawFiles) || rawFiles.length === 0) {
171
+ (0, auth_1.apiError)(res, 400, '"files" must be a non-empty list');
172
+ return;
173
+ }
174
+ const writes = [];
175
+ const removals = [];
176
+ let total = 0;
177
+ for (const raw of rawFiles) {
178
+ if (typeof raw !== 'object' || raw === null) {
179
+ (0, auth_1.apiError)(res, 400, 'each entry of "files" must be an object');
180
+ return;
181
+ }
182
+ const entry = raw;
183
+ const filePath = (0, auth_1.stringField)(entry, 'path');
184
+ if (filePath === null || filePath === '' || !(0, git_1.isValidRepoPath)(filePath)) {
185
+ (0, auth_1.apiError)(res, 400, `"${String(entry.path)}" is not a usable path`);
186
+ return;
187
+ }
188
+ if (entry.delete === true) {
189
+ removals.push(filePath);
190
+ continue;
191
+ }
192
+ const content = decodeContent(entry, res);
193
+ if (!content)
194
+ return;
195
+ total += content.length;
196
+ if (total > ops_1.MAX_UPLOAD_SIZE) {
197
+ (0, auth_1.apiError)(res, 413, `one commit may carry at most ${ops_1.MAX_UPLOAD_SIZE} bytes; push anything larger`);
198
+ return;
199
+ }
200
+ writes.push({ path: filePath, content });
201
+ }
202
+ try {
203
+ let branch = parsed.branch;
204
+ const base = await baseFor(ctx, parsed);
205
+ if (parsed.newBranch) {
206
+ if (base === null) {
207
+ (0, auth_1.apiError)(res, 400, 'there is nothing to branch from yet; commit to this branch first');
208
+ return;
209
+ }
210
+ await (0, ops_1.createBranch)(ctx.repo.dir, parsed.newBranch, base);
211
+ branch = parsed.newBranch;
212
+ }
213
+ const sha = await (0, ops_1.commitFiles)(ctx.repo.dir, {
214
+ branch,
215
+ message: parsed.message,
216
+ author: { name: ctx.actor.username, email: ctx.actor.email },
217
+ expectedHead: base,
218
+ files: writes,
219
+ removals,
220
+ });
221
+ fire(ctx, branch, base, sha, ctx.actor);
222
+ res.json({ branch, sha, files: writes.length, removed: removals.length });
223
+ }
224
+ catch (e) {
225
+ reportWriteError(res, e);
226
+ }
227
+ });
228
+ // ---- branches and tags ----
229
+ //
230
+ // Deletion takes the name as a wildcard, because a ref name may contain
231
+ // slashes and one path segment could not hold `release/1.0`.
232
+ app.post('/api/repos/:collection/:repo/branches', async (req, res) => {
233
+ const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
234
+ if (!ctx)
235
+ return;
236
+ const body = (0, auth_1.bodyOf)(req);
237
+ const name = (0, auth_1.stringField)(body, 'name');
238
+ const from = (0, auth_1.stringField)(body, 'from');
239
+ if (name === null || from === null) {
240
+ (0, auth_1.apiError)(res, 400, '"name" and "from" are required');
241
+ return;
242
+ }
243
+ try {
244
+ await (0, ops_1.createBranch)(ctx.repo.dir, name, from);
245
+ const sha = await ctx.repo.resolve(name);
246
+ res.status(201).json({ name, sha });
247
+ }
248
+ catch (e) {
249
+ (0, auth_1.sendOpError)(res, e, 'could not create the branch');
250
+ }
251
+ });
252
+ app.delete('/api/repos/:collection/:repo/branches/*', async (req, res) => {
253
+ const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
254
+ if (!ctx)
255
+ return;
256
+ const name = tail(req);
257
+ if (!(0, git_1.isValidRefName)(name)) {
258
+ (0, auth_1.apiError)(res, 400, 'that is not a usable branch name');
259
+ return;
260
+ }
261
+ try {
262
+ await (0, ops_1.deleteBranch)(ctx.repo.dir, name);
263
+ res.json({ deleted: name });
264
+ }
265
+ catch (e) {
266
+ (0, auth_1.sendOpError)(res, e, 'could not delete the branch');
267
+ }
268
+ });
269
+ app.post('/api/repos/:collection/:repo/tags', async (req, res) => {
270
+ const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
271
+ if (!ctx)
272
+ return;
273
+ const body = (0, auth_1.bodyOf)(req);
274
+ const name = (0, auth_1.stringField)(body, 'name');
275
+ const at = (0, auth_1.stringField)(body, 'at');
276
+ if (name === null || at === null) {
277
+ (0, auth_1.apiError)(res, 400, '"name" and "at" are required');
278
+ return;
279
+ }
280
+ try {
281
+ await (0, ops_1.createTag)(ctx.repo.dir, name, at);
282
+ res.status(201).json({ name, sha: await ctx.repo.resolve(name) });
283
+ }
284
+ catch (e) {
285
+ (0, auth_1.sendOpError)(res, e, 'could not create the tag');
286
+ }
287
+ });
288
+ app.delete('/api/repos/:collection/:repo/tags/*', async (req, res) => {
289
+ const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
290
+ if (!ctx)
291
+ return;
292
+ const name = tail(req);
293
+ if (!(0, git_1.isValidRefName)(name)) {
294
+ (0, auth_1.apiError)(res, 400, 'that is not a usable tag name');
295
+ return;
296
+ }
297
+ try {
298
+ await (0, ops_1.deleteTag)(ctx.repo.dir, name);
299
+ res.json({ deleted: name });
300
+ }
301
+ catch (e) {
302
+ (0, auth_1.sendOpError)(res, e, 'could not delete the tag');
303
+ }
304
+ });
305
+ // ---- the repository itself ----
306
+ app.post('/api/repos', async (req, res) => {
307
+ const auth = (0, auth_1.requireApiAuth)(root, limiter, req, res);
308
+ if (!auth)
309
+ return;
310
+ const body = (0, auth_1.bodyOf)(req);
311
+ const collection = (0, auth_1.stringField)(body, 'collection');
312
+ const name = (0, auth_1.stringField)(body, 'name');
313
+ if (collection === null || name === null || !(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(name)) {
314
+ (0, auth_1.apiError)(res, 400, 'a valid "collection" and "name" are required (letters, digits, dot, underscore, dash)');
315
+ return;
316
+ }
317
+ const reserved = (0, scan_1.reservedRepoSuffix)(name);
318
+ if (reserved) {
319
+ (0, auth_1.apiError)(res, 400, `a repository name may not end in ${reserved}, which is reserved for the directories a repository keeps beside it`);
320
+ return;
321
+ }
322
+ if (!(0, perms_1.canCreateRepo)(root, auth, collection, name)) {
323
+ (0, auth_1.apiError)(res, 403, `you are not allowed to create repositories in ${collection}`);
324
+ return;
325
+ }
326
+ if ((0, scan_1.findRepo)(root, collection, name)) {
327
+ (0, auth_1.apiError)(res, 409, `repository ${collection}/${name} already exists`);
328
+ return;
329
+ }
330
+ if (body.private !== undefined && typeof body.private !== 'boolean') {
331
+ (0, auth_1.apiError)(res, 400, '"private" must be a boolean');
332
+ return;
333
+ }
334
+ const host = (req.get('host') ?? 'localhost').replace(/:\d+$/, '');
335
+ try {
336
+ const created = await (0, ops_1.createRepoWithReadme)(root, collection, name, {
337
+ description: (0, auth_1.stringField)(body, 'description') ?? '',
338
+ readme: body.initReadme === true,
339
+ private: body.private === true,
340
+ author: { name: auth.username, email: `${auth.username}@noreply.${host}` },
341
+ });
342
+ if (created.sha) {
343
+ (0, trigger_1.firePush)(root, engine, { collection, name }, 'main', null, created.sha, auth.username);
344
+ }
345
+ res.status(201).json({ collection, name, created: true, private: body.private === true, sha: created.sha });
346
+ }
347
+ catch (e) {
348
+ (0, auth_1.sendOpError)(res, e, 'could not create the repository');
349
+ }
350
+ });
351
+ app.post('/api/repos/:collection/:repo/fork', async (req, res) => {
352
+ // Reading the source is what forking needs; creating the fork needs
353
+ // permission to create where it lands, which is checked below rather than
354
+ // here.
355
+ const source = (0, auth_1.requireRepo)(root, limiter, req, res);
356
+ if (!source)
357
+ return;
358
+ const body = (0, auth_1.bodyOf)(req);
359
+ const collection = (0, auth_1.stringField)(body, 'collection');
360
+ const name = (0, auth_1.stringField)(body, 'name') ?? source.repo.name;
361
+ if (collection === null || !(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(name)) {
362
+ (0, auth_1.apiError)(res, 400, 'a valid "collection" is required');
363
+ return;
364
+ }
365
+ if (!(0, perms_1.canCreateRepo)(root, source.auth, collection, name)) {
366
+ (0, auth_1.apiError)(res, 403, `you are not allowed to create repositories in ${collection}`);
367
+ return;
368
+ }
369
+ try {
370
+ await (0, ops_1.forkRepo)(root, source.repo.collection, source.repo.name, collection, name);
371
+ res.status(201).json({ collection, name, forkedFrom: { collection: source.repo.collection, repo: source.repo.name } });
372
+ }
373
+ catch (e) {
374
+ (0, auth_1.sendOpError)(res, e, 'could not fork the repository');
375
+ }
376
+ });
377
+ app.patch('/api/repos/:collection/:repo', async (req, res) => {
378
+ const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
379
+ if (!ctx)
380
+ return;
381
+ const body = (0, auth_1.bodyOf)(req);
382
+ const description = (0, auth_1.stringField)(body, 'description');
383
+ const defaultBranch = (0, auth_1.stringField)(body, 'defaultBranch');
384
+ const priv = body.private;
385
+ if (priv !== undefined && typeof priv !== 'boolean') {
386
+ (0, auth_1.apiError)(res, 400, '"private" must be a boolean');
387
+ return;
388
+ }
389
+ if (description === null && defaultBranch === null && priv === undefined) {
390
+ (0, auth_1.apiError)(res, 400, 'nothing to change; provide "description", "defaultBranch", and/or "private"');
391
+ return;
392
+ }
393
+ // Visibility is the one setting here that takes the admin role rather than
394
+ // write: publishing a repository, or withdrawing one from everybody who
395
+ // could see it, is the repository's own business the way deleting it is.
396
+ if (priv !== undefined && !(0, perms_1.canAdminRepo)(root, ctx.auth, ctx.repo)) {
397
+ (0, auth_1.apiError)(res, 403, `changing visibility needs the admin role on ${ctx.repo.collection}/${ctx.repo.name}`);
398
+ return;
399
+ }
400
+ try {
401
+ if (description !== null)
402
+ (0, ops_1.setDescription)(ctx.repo.dir, description);
403
+ if (defaultBranch !== null)
404
+ await (0, ops_1.setDefaultBranch)(ctx.repo.dir, defaultBranch);
405
+ if (priv !== undefined)
406
+ (0, perms_1.setRepoPrivate)(ctx.repo.dir, priv);
407
+ const branches = await ctx.repo.listRefs('heads');
408
+ res.json({
409
+ collection: ctx.repo.collection,
410
+ name: ctx.repo.name,
411
+ defaultBranch: await ctx.repo.defaultBranch(branches),
412
+ private: (0, perms_1.repoIsPrivate)(ctx.repo.dir),
413
+ changed: true,
414
+ });
415
+ }
416
+ catch (e) {
417
+ (0, auth_1.sendOpError)(res, e, 'could not change the settings');
418
+ }
419
+ });
420
+ // Renaming and deleting are the two operations the web puts behind a typed
421
+ // confirmation, so they take the admin role on the repository rather than write.
422
+ app.post('/api/repos/:collection/:repo/rename', async (req, res) => {
423
+ const ctx = (0, auth_1.requireRepoAdmin)(root, limiter, req, res);
424
+ if (!ctx)
425
+ return;
426
+ const body = (0, auth_1.bodyOf)(req);
427
+ const name = (0, auth_1.stringField)(body, 'name') ?? ctx.repo.name;
428
+ const collection = (0, auth_1.stringField)(body, 'collection') ?? ctx.repo.collection;
429
+ if (!(0, scan_1.isValidName)(name) || !(0, scan_1.isValidName)(collection)) {
430
+ (0, auth_1.apiError)(res, 400, '"name" and "collection" must be usable names');
431
+ return;
432
+ }
433
+ // The same rule the web settings page applies: admin on what is moving
434
+ // (which requireRepoAdmin has established), and for a move to another
435
+ // collection, permission to create over there.
436
+ const blocker = (0, perms_1.repoRenameBlocker)(root, ctx.auth, ctx.repo, collection, name);
437
+ if (blocker) {
438
+ (0, auth_1.apiError)(res, 403, `renaming this repository needs ${blocker}`);
439
+ return;
440
+ }
441
+ try {
442
+ await (0, ops_1.renameRepo)(root, ctx.repo.collection, ctx.repo.name, collection, name, repoCtx);
443
+ res.json({ collection, name, renamed: true });
444
+ }
445
+ catch (e) {
446
+ (0, auth_1.sendOpError)(res, e, 'could not rename the repository');
447
+ }
448
+ });
449
+ // ?confirm= is the API's equivalent of the web's typed confirmation. It costs
450
+ // nothing and it makes an accidental DELETE from a loop over a listing
451
+ // impossible.
452
+ app.delete('/api/repos/:collection/:repo', async (req, res) => {
453
+ const ctx = (0, auth_1.requireRepoAdmin)(root, limiter, req, res);
454
+ if (!ctx)
455
+ return;
456
+ const full = `${ctx.repo.collection}/${ctx.repo.name}`;
457
+ if (String(req.query.confirm ?? '') !== full) {
458
+ (0, auth_1.apiError)(res, 400, `to delete this repository and everything beside it, send ?confirm=${full}`);
459
+ return;
460
+ }
461
+ try {
462
+ await (0, ops_1.deleteRepo)(root, ctx.repo.collection, ctx.repo.name, repoCtx);
463
+ res.json({ deleted: full });
464
+ }
465
+ catch (e) {
466
+ (0, auth_1.sendOpError)(res, e, 'could not delete the repository');
467
+ }
468
+ });
469
+ // ---- collaborators ----
470
+ // All three take the admin role on the repository: who may see or write a
471
+ // repository is decided by the people who administer it, and the list of
472
+ // who those people are is itself not public.
473
+ app.get('/api/repos/:collection/:repo/collaborators', (req, res) => {
474
+ const ctx = (0, auth_1.requireRepoAdmin)(root, limiter, req, res);
475
+ if (!ctx)
476
+ return;
477
+ const access = (0, perms_1.repoAccess)(ctx.repo.dir);
478
+ res.json({
479
+ collaborators: Object.entries(access.collaborators).map(([username, role]) => ({ username, role })),
480
+ owners: (0, perms_1.collectionOwners)(root, ctx.repo.collection),
481
+ private: access.private,
482
+ });
483
+ });
484
+ app.put('/api/repos/:collection/:repo/collaborators/:user', (req, res) => {
485
+ const ctx = (0, auth_1.requireRepoAdmin)(root, limiter, req, res);
486
+ if (!ctx)
487
+ return;
488
+ const username = req.params.user;
489
+ const role = (0, auth_1.stringField)((0, auth_1.bodyOf)(req), 'role') ?? 'write';
490
+ if (role !== 'read' && role !== 'write' && role !== 'admin') {
491
+ (0, auth_1.apiError)(res, 400, '"role" must be "read", "write", or "admin"');
492
+ return;
493
+ }
494
+ // Only a user the vault knows: a collaborator entry for a name nobody
495
+ // holds grants nothing today and, worse, would grant everything it says
496
+ // to whoever is given the name later.
497
+ const state = (0, vault_1.loadVault)(root);
498
+ if (state.status !== 'ok' || !state.vault.users[username]) {
499
+ (0, auth_1.apiError)(res, 404, `no user ${username} in this vault`);
500
+ return;
501
+ }
502
+ (0, perms_1.setCollaborator)(ctx.repo.dir, username, role);
503
+ res.json({ username, role });
504
+ });
505
+ app.delete('/api/repos/:collection/:repo/collaborators/:user', (req, res) => {
506
+ const ctx = (0, auth_1.requireRepoAdmin)(root, limiter, req, res);
507
+ if (!ctx)
508
+ return;
509
+ const username = req.params.user;
510
+ const access = (0, perms_1.repoAccess)(ctx.repo.dir);
511
+ if (access.collaborators[username] === undefined) {
512
+ (0, auth_1.apiError)(res, 404, `${username} is not a collaborator on ${ctx.repo.collection}/${ctx.repo.name}`);
513
+ return;
514
+ }
515
+ (0, perms_1.removeCollaborator)(ctx.repo.dir, username);
516
+ res.json({ removed: username });
517
+ });
518
+ }