@magland/mochi 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +108 -0
  3. package/dist/ansi.js +174 -0
  4. package/dist/api/admin.js +416 -0
  5. package/dist/api/auth.js +166 -0
  6. package/dist/api/backup.js +598 -0
  7. package/dist/api/ci.js +336 -0
  8. package/dist/api/contents.js +339 -0
  9. package/dist/api/issues.js +165 -0
  10. package/dist/api/pulls.js +244 -0
  11. package/dist/api/releases.js +83 -0
  12. package/dist/api/repos.js +156 -0
  13. package/dist/api/write.js +518 -0
  14. package/dist/api.js +326 -0
  15. package/dist/assets.js +29 -0
  16. package/dist/atom.js +32 -0
  17. package/dist/atomic.js +171 -0
  18. package/dist/avatar.js +81 -0
  19. package/dist/browse.js +630 -0
  20. package/dist/build-info.json +4 -0
  21. package/dist/ci/actionref.js +86 -0
  22. package/dist/ci/api.js +829 -0
  23. package/dist/ci/artifacts.js +201 -0
  24. package/dist/ci/dispatch.js +30 -0
  25. package/dist/ci/engine.js +1321 -0
  26. package/dist/ci/expr.js +526 -0
  27. package/dist/ci/manual.js +199 -0
  28. package/dist/ci/present.js +82 -0
  29. package/dist/ci/protocol.js +6 -0
  30. package/dist/ci/runners.js +256 -0
  31. package/dist/ci/runs.js +208 -0
  32. package/dist/ci/trigger.js +28 -0
  33. package/dist/ci/views.js +441 -0
  34. package/dist/ci/wake.js +194 -0
  35. package/dist/ci/web.js +617 -0
  36. package/dist/ci/workflow.js +436 -0
  37. package/dist/cli/admin-cmd.js +324 -0
  38. package/dist/cli/api-cmd.js +128 -0
  39. package/dist/cli/backup-cmd.js +1500 -0
  40. package/dist/cli/exit.js +69 -0
  41. package/dist/cli/input.js +64 -0
  42. package/dist/cli/issue-cmd.js +243 -0
  43. package/dist/cli/output.js +93 -0
  44. package/dist/cli/parse.js +317 -0
  45. package/dist/cli/pr-cmd.js +289 -0
  46. package/dist/cli/release-cmd.js +171 -0
  47. package/dist/cli/repo-cmd.js +763 -0
  48. package/dist/cli/repo.js +101 -0
  49. package/dist/cli/run-cmd.js +438 -0
  50. package/dist/cli/target.js +54 -0
  51. package/dist/cli-api.js +84 -0
  52. package/dist/compare.js +111 -0
  53. package/dist/config.js +212 -0
  54. package/dist/credentials.js +235 -0
  55. package/dist/deploy-cli.js +859 -0
  56. package/dist/deploy-runner-cli.js +592 -0
  57. package/dist/diff.js +171 -0
  58. package/dist/discussion.js +253 -0
  59. package/dist/egress.js +559 -0
  60. package/dist/filecache.js +68 -0
  61. package/dist/find.js +162 -0
  62. package/dist/forms.js +737 -0
  63. package/dist/git.js +547 -0
  64. package/dist/githttp.js +428 -0
  65. package/dist/html.js +87 -0
  66. package/dist/icons.js +101 -0
  67. package/dist/import-cli.js +316 -0
  68. package/dist/index.js +752 -0
  69. package/dist/issues.js +308 -0
  70. package/dist/issueweb.js +447 -0
  71. package/dist/job-cli.js +197 -0
  72. package/dist/jobtoken.js +96 -0
  73. package/dist/languages.js +383 -0
  74. package/dist/layout.js +100 -0
  75. package/dist/lfs.js +438 -0
  76. package/dist/lfsstore.js +425 -0
  77. package/dist/limit.js +259 -0
  78. package/dist/logo.js +61 -0
  79. package/dist/markdown.js +382 -0
  80. package/dist/migrate.js +334 -0
  81. package/dist/multipart.js +90 -0
  82. package/dist/ops.js +869 -0
  83. package/dist/pagescript.js +465 -0
  84. package/dist/perms.js +370 -0
  85. package/dist/pointer.js +55 -0
  86. package/dist/profile.js +106 -0
  87. package/dist/pulls.js +320 -0
  88. package/dist/pullweb.js +461 -0
  89. package/dist/redirects.js +455 -0
  90. package/dist/releases.js +435 -0
  91. package/dist/render.js +233 -0
  92. package/dist/runner/actions.js +448 -0
  93. package/dist/runner/client.js +428 -0
  94. package/dist/runner/context.js +247 -0
  95. package/dist/runner/docker.js +197 -0
  96. package/dist/runner/externals.js +175 -0
  97. package/dist/runner/job.js +290 -0
  98. package/dist/runner/manual-run.js +272 -0
  99. package/dist/runner/overrides.js +554 -0
  100. package/dist/runner/steps.js +571 -0
  101. package/dist/runner/wake.js +84 -0
  102. package/dist/runner-cli.js +405 -0
  103. package/dist/scan.js +231 -0
  104. package/dist/server.js +424 -0
  105. package/dist/session.js +267 -0
  106. package/dist/site.js +259 -0
  107. package/dist/siteshost.js +94 -0
  108. package/dist/source.js +90 -0
  109. package/dist/style.js +1295 -0
  110. package/dist/themes.js +369 -0
  111. package/dist/vault.js +442 -0
  112. package/dist/version.js +88 -0
  113. package/dist/views.js +1007 -0
  114. package/dist/web.js +182 -0
  115. package/dist/webops.js +1402 -0
  116. package/package.json +71 -0
package/dist/api.js ADDED
@@ -0,0 +1,326 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.registerApi = registerApi;
40
+ const fs = __importStar(require("fs"));
41
+ const express_1 = __importDefault(require("express"));
42
+ const ops = __importStar(require("./ops"));
43
+ const ops_1 = require("./ops");
44
+ const layout_1 = require("./layout");
45
+ const perms_1 = require("./perms");
46
+ const scan_1 = require("./scan");
47
+ const vault_1 = require("./vault");
48
+ const auth_1 = require("./api/auth");
49
+ const contents_1 = require("./api/contents");
50
+ const issues_1 = require("./api/issues");
51
+ const pulls_1 = require("./api/pulls");
52
+ const repos_1 = require("./api/repos");
53
+ const admin_1 = require("./api/admin");
54
+ const backup_1 = require("./api/backup");
55
+ const ci_1 = require("./api/ci");
56
+ const releases_1 = require("./api/releases");
57
+ const write_1 = require("./api/write");
58
+ // The bearer-token JSON API used by the mochi CLI. Only Bearer tokens are
59
+ // accepted; session cookies never authorize API calls.
60
+ function registerApi(app, root, authLimiter, gates, lfs = null, engine, egress) {
61
+ // A write route carries a file in its body, so the limit is the one the upload
62
+ // route already applies rather than express's 100 kB default. Anything larger
63
+ // belongs in a push, or in Git LFS.
64
+ app.use('/api', express_1.default.json({ limit: ops_1.MAX_UPLOAD_SIZE }));
65
+ // The routes are split by subject, mirroring the split the HTML modules
66
+ // already have, so that no one file grows unmanageable. Each of them calls the
67
+ // same domain functions the web handlers call and the same authorization
68
+ // helpers, so the duplication between the two transports is transport only.
69
+ (0, repos_1.registerRepoApi)(app, root, authLimiter);
70
+ (0, contents_1.registerContentsApi)(app, root, authLimiter, gates);
71
+ (0, issues_1.registerIssueApi)(app, root, authLimiter);
72
+ (0, pulls_1.registerPullApi)(app, root, authLimiter, engine);
73
+ (0, write_1.registerWriteApi)(app, root, authLimiter, lfs, engine);
74
+ // The engine is constructed in createApp and already handed to registerCiApi,
75
+ // so these routes take it the same way. A vault serving with no engine has no
76
+ // workflows to answer about.
77
+ if (engine)
78
+ (0, ci_1.registerCiRunApi)(app, root, authLimiter, engine);
79
+ (0, releases_1.registerReleaseApi)(app, root, authLimiter);
80
+ (0, admin_1.registerAdminApi)(app, root, authLimiter, lfs, engine, egress);
81
+ // Reading a whole vault out over HTTP, for `mochi backup`. Admin over the
82
+ // whole vault, and behind the same gate a file listing holds.
83
+ (0, backup_1.registerBackupApi)(app, root, authLimiter, gates);
84
+ // Both helpers live in src/api/auth.ts now that more than one file of routes
85
+ // uses them; this closure only saves passing root at every call site.
86
+ const requireApiAuth = (req, res) => (0, auth_1.requireApiAuth)(root, authLimiter, req, res);
87
+ function sanitizeGlobs(v) {
88
+ if (v === undefined || v === null)
89
+ return undefined;
90
+ if (Array.isArray(v) && v.every((x) => typeof x === 'string' && x.length > 0 && x.length < 200)) {
91
+ return v;
92
+ }
93
+ return null;
94
+ }
95
+ // What a caller may reach, filtered to their eyes: a private repository the
96
+ // caller has no role on is left out, here and in every listing.
97
+ const visibleRepos = (auth, collection) => (0, scan_1.listRepoDirs)(root, collection).filter((dirName) => (0, perms_1.repoRole)(root, auth, {
98
+ collection,
99
+ name: (0, scan_1.displayName)(dirName),
100
+ dir: (0, layout_1.repoPath)(root, collection, dirName),
101
+ }) !== null);
102
+ app.get('/api/whoami', (req, res) => {
103
+ const auth = requireApiAuth(req, res);
104
+ if (!auth)
105
+ return;
106
+ res.json({
107
+ username: auth.username,
108
+ siteAdmin: auth.user.siteAdmin === true,
109
+ ownedCollections: (0, scan_1.listCollections)(root)
110
+ .map((c) => c.name)
111
+ .filter((c) => c === auth.username || (0, perms_1.collectionOwners)(root, c).includes(auth.username)),
112
+ tokenScope: auth.token.scope ?? null,
113
+ });
114
+ });
115
+ // Collections, for the CLI. `mochi import` asks what is already in a
116
+ // collection before it pushes, and `mochi collection add` makes an empty
117
+ // one, which is the case a push cannot cover: pushing creates the collection
118
+ // it lands in, so a collection with nothing in it yet has to be asked for.
119
+ app.get('/api/collections', (req, res) => {
120
+ const auth = requireApiAuth(req, res);
121
+ if (!auth)
122
+ return;
123
+ res.json({
124
+ collections: (0, scan_1.listCollections)(root).map((c) => ({ name: c.name, repoCount: visibleRepos(auth, c.name).length })),
125
+ });
126
+ });
127
+ app.get('/api/collections/:name', (req, res) => {
128
+ const auth = requireApiAuth(req, res);
129
+ if (!auth)
130
+ return;
131
+ const name = req.params.name;
132
+ let isDir = false;
133
+ try {
134
+ isDir = fs.statSync((0, layout_1.collectionDir)(root, name)).isDirectory();
135
+ }
136
+ catch {
137
+ isDir = false;
138
+ }
139
+ if (!(0, scan_1.isValidName)(name) || !isDir) {
140
+ (0, auth_1.apiError)(res, 404, `no collection ${name} in this vault`);
141
+ return;
142
+ }
143
+ res.json({ name, owners: (0, perms_1.collectionOwners)(root, name), repos: visibleRepos(auth, name).map(scan_1.displayName) });
144
+ });
145
+ app.post('/api/collections', (req, res) => {
146
+ const auth = requireApiAuth(req, res);
147
+ if (!auth)
148
+ return;
149
+ const body = (req.body ?? {});
150
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
151
+ if (!(0, scan_1.isValidName)(name)) {
152
+ (0, auth_1.apiError)(res, 400, 'a valid "name" is required (letters, digits, dot, underscore, dash; not a reserved word)');
153
+ return;
154
+ }
155
+ if (!(0, perms_1.canCreateCollection)(root, auth, name)) {
156
+ (0, auth_1.apiError)(res, 403, `only a site admin can create a collection not named after you`);
157
+ return;
158
+ }
159
+ try {
160
+ ops.createCollection(root, name);
161
+ }
162
+ catch (e) {
163
+ if (e instanceof ops_1.OpError) {
164
+ (0, auth_1.apiError)(res, (0, ops_1.opErrorStatus)(e.kind), e.message);
165
+ return;
166
+ }
167
+ throw e;
168
+ }
169
+ res.json({ name, created: true });
170
+ });
171
+ // ---- collection owners ----
172
+ // Owners manage the owners list, and site admins do; the same rule the
173
+ // collection's settings page applies.
174
+ app.put('/api/collections/:name/owners/:user', (req, res) => {
175
+ const auth = requireApiAuth(req, res);
176
+ if (!auth)
177
+ return;
178
+ const name = req.params.name;
179
+ const username = req.params.user;
180
+ if (!(0, scan_1.isValidName)(name) || !fs.existsSync((0, layout_1.collectionDir)(root, name))) {
181
+ (0, auth_1.apiError)(res, 404, `no collection ${name} in this vault`);
182
+ return;
183
+ }
184
+ if (!(0, perms_1.canAdminCollection)(root, auth, name)) {
185
+ (0, auth_1.apiError)(res, 403, `you are not an owner of ${name}`);
186
+ return;
187
+ }
188
+ const state = (0, vault_1.loadVault)(root);
189
+ if (state.status !== 'ok' || !state.vault.users[username]) {
190
+ (0, auth_1.apiError)(res, 404, `no user ${username} in this vault`);
191
+ return;
192
+ }
193
+ if (username === name) {
194
+ res.json({ name, owners: (0, perms_1.collectionOwners)(root, name), note: `${username} owns ${name} by name already` });
195
+ return;
196
+ }
197
+ (0, perms_1.addCollectionOwner)(root, name, username);
198
+ res.json({ name, owners: (0, perms_1.collectionOwners)(root, name) });
199
+ });
200
+ app.delete('/api/collections/:name/owners/:user', (req, res) => {
201
+ const auth = requireApiAuth(req, res);
202
+ if (!auth)
203
+ return;
204
+ const name = req.params.name;
205
+ const username = req.params.user;
206
+ if (!(0, scan_1.isValidName)(name) || !fs.existsSync((0, layout_1.collectionDir)(root, name))) {
207
+ (0, auth_1.apiError)(res, 404, `no collection ${name} in this vault`);
208
+ return;
209
+ }
210
+ if (!(0, perms_1.canAdminCollection)(root, auth, name)) {
211
+ (0, auth_1.apiError)(res, 403, `you are not an owner of ${name}`);
212
+ return;
213
+ }
214
+ if (!(0, perms_1.collectionOwners)(root, name).includes(username)) {
215
+ (0, auth_1.apiError)(res, 404, `${username} is not an explicit owner of ${name}`);
216
+ return;
217
+ }
218
+ (0, perms_1.removeCollectionOwner)(root, name, username);
219
+ res.json({ name, owners: (0, perms_1.collectionOwners)(root, name) });
220
+ });
221
+ // Users are the site admin's business, as they are on GitHub: creating one,
222
+ // listing them, and the site-admin bit itself. What a user may reach is not
223
+ // set here at all; it lives with the collections and repositories that
224
+ // grant it.
225
+ app.get('/api/users', (req, res) => {
226
+ const auth = requireApiAuth(req, res);
227
+ if (!auth)
228
+ return;
229
+ if (!(0, perms_1.isSiteAdmin)(auth)) {
230
+ (0, auth_1.apiError)(res, 403, 'site admin required (with an unrestricted token)');
231
+ return;
232
+ }
233
+ const state = (0, vault_1.loadVault)(root);
234
+ if (state.status !== 'ok') {
235
+ (0, auth_1.apiError)(res, 500, 'vault unavailable');
236
+ return;
237
+ }
238
+ res.json({
239
+ users: Object.entries(state.vault.users).map(([name, u]) => ({
240
+ name,
241
+ siteAdmin: u.siteAdmin === true,
242
+ tokens: u.tokens.length,
243
+ })),
244
+ });
245
+ });
246
+ app.post('/api/users', (req, res) => {
247
+ const auth = requireApiAuth(req, res);
248
+ if (!auth)
249
+ return;
250
+ const body = (req.body ?? {});
251
+ const username = typeof body.username === 'string' ? body.username : '';
252
+ if (!(0, scan_1.isValidUserName)(username)) {
253
+ (0, auth_1.apiError)(res, 400, 'a valid "username" is required');
254
+ return;
255
+ }
256
+ const tokenScope = sanitizeGlobs(body.tokenScope);
257
+ if (tokenScope === null) {
258
+ (0, auth_1.apiError)(res, 400, '"tokenScope" must be a list of strings');
259
+ return;
260
+ }
261
+ // Refused loudly rather than ignored: an older client sending the glob
262
+ // fields would otherwise create a user without the access its operator
263
+ // asked for, and silence is the worst way to deliver that.
264
+ if (body.scope !== undefined || body.admin !== undefined) {
265
+ (0, auth_1.apiError)(res, 400, '"scope" and "admin" are gone: a user owns the collection named after them, and anything more is ' +
266
+ 'granted where it applies (repository collaborators, collection owners) or with "siteAdmin"');
267
+ return;
268
+ }
269
+ if (body.siteAdmin !== undefined && typeof body.siteAdmin !== 'boolean') {
270
+ (0, auth_1.apiError)(res, 400, '"siteAdmin" must be a boolean');
271
+ return;
272
+ }
273
+ if (!(0, perms_1.isSiteAdmin)(auth)) {
274
+ (0, auth_1.apiError)(res, 403, 'site admin required (with an unrestricted token)');
275
+ return;
276
+ }
277
+ const state = (0, vault_1.loadVault)(root);
278
+ if (state.status !== 'ok') {
279
+ (0, auth_1.apiError)(res, 500, 'vault unavailable');
280
+ return;
281
+ }
282
+ const existing = state.vault.users[username];
283
+ if (existing && body.siteAdmin !== undefined) {
284
+ (0, auth_1.apiError)(res, 409, `user ${username} already exists; use 'mochi user grant' to change the site-admin bit`);
285
+ return;
286
+ }
287
+ const result = (0, vault_1.addUserToken)(root, username, {
288
+ siteAdmin: body.siteAdmin === true,
289
+ tokenScope: tokenScope ?? undefined,
290
+ });
291
+ res.json({
292
+ username,
293
+ created: result.created,
294
+ token: result.token,
295
+ siteAdmin: result.user.siteAdmin === true,
296
+ });
297
+ });
298
+ app.post('/api/users/:name/grant', (req, res) => {
299
+ const auth = requireApiAuth(req, res);
300
+ if (!auth)
301
+ return;
302
+ const username = req.params.name;
303
+ if (!(0, scan_1.isValidName)(username)) {
304
+ (0, auth_1.apiError)(res, 400, 'invalid username');
305
+ return;
306
+ }
307
+ const body = (req.body ?? {});
308
+ if (typeof body.siteAdmin !== 'boolean') {
309
+ (0, auth_1.apiError)(res, 400, 'provide "siteAdmin": true or false; per-repository access is granted on the repository (collaborators) or the collection (owners)');
310
+ return;
311
+ }
312
+ if (!(0, perms_1.isSiteAdmin)(auth)) {
313
+ (0, auth_1.apiError)(res, 403, 'site admin required (with an unrestricted token)');
314
+ return;
315
+ }
316
+ let user;
317
+ try {
318
+ user = (0, vault_1.setSiteAdmin)(root, username, body.siteAdmin);
319
+ }
320
+ catch (e) {
321
+ (0, auth_1.apiError)(res, 404, e instanceof Error ? e.message : String(e));
322
+ return;
323
+ }
324
+ res.json({ username, siteAdmin: user.siteAdmin === true });
325
+ });
326
+ }
package/dist/assets.js ADDED
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.styleSheet = styleSheet;
4
+ const crypto_1 = require("crypto");
5
+ const style_1 = require("./style");
6
+ const themes_1 = require("./themes");
7
+ /**
8
+ * The forge's stylesheet, with a tag naming this exact body.
9
+ *
10
+ * The sheet is 76 KB and every page links it, so what it costs is decided by
11
+ * whether the browser may keep it. It used to say `no-cache`, which bought a
12
+ * conditional request on every navigation -- 304 at best, and a round trip
13
+ * before anything could be painted. The tag ends that: it is a hash of the
14
+ * bytes, so a changed theme or an edited style.ts changes the URL, and any URL
15
+ * that carries the right tag can be kept for good.
16
+ *
17
+ * Built once per theme rather than per request, which also stops the 76 KB
18
+ * concatenation from happening on every hit.
19
+ */
20
+ const sheets = new Map();
21
+ function styleSheet(theme) {
22
+ const made = sheets.get(theme.name);
23
+ if (made)
24
+ return made;
25
+ const body = (0, themes_1.allThemeVarsCss)(theme) + style_1.CSS;
26
+ const sheet = { body, tag: (0, crypto_1.createHash)('sha256').update(body).digest('hex').slice(0, 12) };
27
+ sheets.set(theme.name, sheet);
28
+ return sheet;
29
+ }
package/dist/atom.js ADDED
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.atomFeed = atomFeed;
4
+ const render_1 = require("./render");
5
+ function iso(date) {
6
+ const d = new Date(date);
7
+ return isNaN(d.getTime()) ? new Date(0).toISOString() : d.toISOString();
8
+ }
9
+ function atomFeed(feed) {
10
+ // The feed's own timestamp is that of its newest entry, and the epoch when
11
+ // it has none, so a reader can tell a quiet feed from a broken one.
12
+ const updated = feed.entries.length ? iso(feed.entries[0].updated) : new Date(0).toISOString();
13
+ const entries = feed.entries
14
+ .map((e) => ` <entry>
15
+ <id>${(0, render_1.esc)(e.id)}</id>
16
+ <title>${(0, render_1.esc)(e.title)}</title>
17
+ <updated>${(0, render_1.esc)(iso(e.updated))}</updated>
18
+ <link rel="alternate" type="text/html" href="${(0, render_1.esc)(e.link)}"/>
19
+ <author><name>${(0, render_1.esc)(e.author)}</name></author>${e.summary ? `\n <content type="text">${(0, render_1.esc)(e.summary)}</content>` : ''}
20
+ </entry>`)
21
+ .join('\n');
22
+ return `<?xml version="1.0" encoding="utf-8"?>
23
+ <feed xmlns="http://www.w3.org/2005/Atom">
24
+ <id>${(0, render_1.esc)(feed.id)}</id>
25
+ <title>${(0, render_1.esc)(feed.title)}</title>
26
+ <updated>${(0, render_1.esc)(updated)}</updated>
27
+ <link rel="self" type="application/atom+xml" href="${(0, render_1.esc)(feed.selfLink)}"/>
28
+ <link rel="alternate" type="text/html" href="${(0, render_1.esc)(feed.htmlLink)}"/>
29
+ ${entries}
30
+ </feed>
31
+ `;
32
+ }
package/dist/atomic.js ADDED
@@ -0,0 +1,171 @@
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.writeFileAtomic = writeFileAtomic;
37
+ exports.withFileLock = withFileLock;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ /**
41
+ * Replace a file's contents in one step, durably.
42
+ *
43
+ * A vault is a directory of plain files and nothing else, so the files that
44
+ * hold its state are the state. Six places wrote one of them by the same
45
+ * three lines, writing a sibling `.tmp` and renaming it over the target, which
46
+ * is what stops a reader from seeing half a file: a rename within a directory
47
+ * is atomic, so a concurrent read sees the old contents or the new ones.
48
+ *
49
+ * What those six lines did not do is reach the disk. A rename can be recorded
50
+ * before the data the renamed file points at, so a machine that loses power at
51
+ * the wrong moment can come back to a `vault.json` that exists, parses as
52
+ * nothing, and locks every user out of a vault whose repositories are all
53
+ * still there. That is the one failure here worth paying two fsyncs to avoid:
54
+ * one on the contents before the rename, and one on the directory afterwards,
55
+ * which is what makes the rename itself survive.
56
+ *
57
+ * The temporary name carries the process id, so two servers pointed at one
58
+ * vault cannot write over each other's half-written file. It does not by itself
59
+ * make the larger read-modify-write safe; `withFileLock` below is what does
60
+ * that, and a caller that reads a state file, changes it, and writes it back
61
+ * belongs inside one.
62
+ */
63
+ function writeFileAtomic(file, data, opts = {}) {
64
+ const tmp = `${file}.tmp-${process.pid}`;
65
+ const fd = fs.openSync(tmp, 'w', opts.mode ?? 0o666);
66
+ try {
67
+ fs.writeFileSync(fd, data);
68
+ fs.fsyncSync(fd);
69
+ }
70
+ finally {
71
+ fs.closeSync(fd);
72
+ }
73
+ try {
74
+ fs.renameSync(tmp, file);
75
+ }
76
+ catch (e) {
77
+ fs.rmSync(tmp, { force: true });
78
+ throw e;
79
+ }
80
+ // The rename is what has to survive, and it lives in the directory rather
81
+ // than in either file. Not every platform lets a directory be opened, and a
82
+ // vault on one that does not is better served slightly less durably than not
83
+ // at all, so this is attempted rather than required.
84
+ let dir = null;
85
+ try {
86
+ dir = fs.openSync(path.dirname(file), 'r');
87
+ fs.fsyncSync(dir);
88
+ }
89
+ catch {
90
+ // no directory handle to sync on this platform
91
+ }
92
+ finally {
93
+ if (dir !== null)
94
+ fs.closeSync(dir);
95
+ }
96
+ }
97
+ // How long a lock may be held before another holder assumes its owner died.
98
+ // The critical sections here are a small read, an edit in memory, and a write,
99
+ // so anything approaching this is a crashed process rather than a slow one.
100
+ const LOCK_STALE_MS = 10 * 1000;
101
+ // How long to wait for a lock before giving up. Well beyond any honest holder,
102
+ // and beyond the staleness threshold, so a wait that ends in a throw means
103
+ // something is wrong rather than merely busy.
104
+ const LOCK_TIMEOUT_MS = 15 * 1000;
105
+ const LOCK_RETRY_MS = 20;
106
+ /** Sleep without yielding, which is what a synchronous critical section needs. */
107
+ function sleepSync(ms) {
108
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
109
+ }
110
+ /**
111
+ * Run `fn` while holding an exclusive lock on `lockPath`, so that a read, an
112
+ * edit, and a write back are one operation against other processes as well as
113
+ * against other requests.
114
+ *
115
+ * The state files at a vault's root are read-modify-written by whoever has the
116
+ * directory: the server answering `POST /api/users`, and `mochi user add`
117
+ * run against the same path from a shell. Both were reading, editing in memory,
118
+ * and writing the whole file back, so two of them overlapping lost one edit
119
+ * entirely - a user created and then gone, or a token revoked and then back.
120
+ * Node's single thread makes each of these safe within one process, since none
121
+ * of them awaits; it says nothing about two.
122
+ *
123
+ * `open(O_CREAT|O_EXCL)` is the lock: it is one atomic step on every platform
124
+ * this runs on, and it needs no dependency. A lock older than LOCK_STALE_MS is
125
+ * taken to belong to a process that died holding it and is broken, because the
126
+ * alternative is a vault that cannot be administered until someone finds a
127
+ * stray file. The lock is advisory in the sense that matters: hand-editing
128
+ * `vault.json` while the server runs still works, exactly as the documentation
129
+ * promises, and is still the operator's own business to time.
130
+ */
131
+ function withFileLock(lockPath, fn) {
132
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
133
+ let fd = null;
134
+ for (;;) {
135
+ try {
136
+ fd = fs.openSync(lockPath, 'wx');
137
+ break;
138
+ }
139
+ catch (e) {
140
+ if (e.code !== 'EEXIST')
141
+ throw e;
142
+ let age = 0;
143
+ try {
144
+ age = Date.now() - fs.statSync(lockPath).mtimeMs;
145
+ }
146
+ catch {
147
+ // It went away between the open and the stat; just retry.
148
+ continue;
149
+ }
150
+ if (age > LOCK_STALE_MS) {
151
+ fs.rmSync(lockPath, { force: true });
152
+ continue;
153
+ }
154
+ if (Date.now() > deadline) {
155
+ throw new Error(`timed out waiting for the lock at ${lockPath}; remove it if no mochi process is running`);
156
+ }
157
+ sleepSync(LOCK_RETRY_MS);
158
+ }
159
+ }
160
+ try {
161
+ fs.writeSync(fd, String(process.pid));
162
+ fs.closeSync(fd);
163
+ fd = null;
164
+ return fn();
165
+ }
166
+ finally {
167
+ if (fd !== null)
168
+ fs.closeSync(fd);
169
+ fs.rmSync(lockPath, { force: true });
170
+ }
171
+ }
package/dist/avatar.js ADDED
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ // Identicons.
3
+ //
4
+ // GitHub puts a face beside every name, and a name without one reads as
5
+ // unfinished next to it. A vault has no uploaded pictures and no gravatar
6
+ // lookups (that would be a request to a third party on every page), so the
7
+ // picture is drawn from the name itself: the same name always draws the same
8
+ // mark, and two different names almost never draw the same one.
9
+ //
10
+ // The drawing is GitHub's own identicon construction. A 5 x 5 grid of cells
11
+ // is filled from the bits of a hash of the name, mirrored about the middle
12
+ // column so the result reads as a face rather than as noise, and painted in a
13
+ // single hue taken from the same hash. Lightness and saturation are fixed at
14
+ // values that hold their contrast against both a near-white and a near-black
15
+ // page, so one drawing serves every theme.
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.identicon = identicon;
51
+ exports.avatar = avatar;
52
+ const crypto = __importStar(require("crypto"));
53
+ const html_1 = require("./html");
54
+ /** The identicon for a name, as an inline SVG of the given pixel size. */
55
+ function identicon(name, size = 20) {
56
+ const hash = crypto.createHash('sha256').update(name.trim().toLowerCase()).digest();
57
+ const hue = Math.round((hash[0] / 256) * 360);
58
+ const fill = `hsl(${hue} 58% 46%)`;
59
+ // Cells are addressed by the left three columns; bit i of the hash body
60
+ // decides cell i, and columns 3 and 4 mirror columns 1 and 0.
61
+ const cells = [];
62
+ for (let col = 0; col < 3; col++) {
63
+ for (let row = 0; row < 5; row++) {
64
+ if ((hash[1 + col * 5 + row] & 1) === 0)
65
+ continue;
66
+ for (const c of col === 2 ? [2] : [col, 4 - col]) {
67
+ cells.push(`<rect x="${c}" y="${row}" width="1" height="1"/>`);
68
+ }
69
+ }
70
+ }
71
+ return (0, html_1.raw)(`<svg class="identicon" width="${size}" height="${size}" viewBox="0 0 5 5" role="img" aria-label="${escapeAttr(name)}" fill="${fill}">${cells.join('')}</svg>`);
72
+ }
73
+ /** The identicon in the frame the interface puts round it. */
74
+ function avatar(name, size = 20, cls = '') {
75
+ return (0, html_1.raw)(`<span class="avatar${cls ? ` ${cls}` : ''}" style="width:${size}px;height:${size}px">${identicon(name, size)}</span>`);
76
+ }
77
+ // Local rather than imported from render.ts: this file is about drawing, and
78
+ // the only thing it interpolates is the name in a label.
79
+ function escapeAttr(s) {
80
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
81
+ }