@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.
- package/README.md +7 -6
- package/dist/agefile.js +47 -0
- package/dist/agescript.js +343 -0
- package/dist/api/admin.js +66 -28
- package/dist/api/auth.js +3 -2
- package/dist/api/backup.js +227 -192
- package/dist/api/contents.js +46 -16
- package/dist/api/repos.js +60 -4
- package/dist/api/write.js +138 -8
- package/dist/api.js +34 -1
- package/dist/browse.js +171 -33
- package/dist/build-info.json +2 -2
- package/dist/ci/api.js +138 -4
- package/dist/ci/artifacts.js +82 -2
- package/dist/ci/engine.js +30 -17
- package/dist/ci/expr.js +14 -0
- package/dist/ci/runners.js +60 -1
- package/dist/ci/runs.js +21 -3
- package/dist/ci/trigger.js +6 -4
- package/dist/ci/views.js +24 -3
- package/dist/ci/web.js +48 -0
- package/dist/cli/admin-cmd.js +40 -3
- package/dist/cli/backup-cmd.js +166 -120
- package/dist/cli/gitrun.js +50 -0
- package/dist/cli/pr-cmd.js +155 -0
- package/dist/cli/repo-cmd.js +134 -6
- package/dist/cli/sync-cmd.js +128 -0
- package/dist/cli/target.js +7 -3
- package/dist/config.js +10 -0
- package/dist/credentials.js +9 -8
- package/dist/deploy-cli.js +214 -109
- package/dist/deploy-runner-cli.js +125 -49
- package/dist/domains.js +259 -0
- package/dist/egress.js +23 -6
- package/dist/forms.js +703 -32
- package/dist/git.js +124 -13
- package/dist/githttp.js +80 -23
- package/dist/githubauth.js +185 -0
- package/dist/icons.js +14 -0
- package/dist/import-cli.js +40 -25
- package/dist/index.js +110 -4
- package/dist/issueweb.js +10 -6
- package/dist/lfs.js +9 -2
- package/dist/limit.js +2 -2
- package/dist/logincodes.js +31 -0
- package/dist/maintenance.js +297 -0
- package/dist/markdown.js +193 -3
- package/dist/mdedit.js +29 -0
- package/dist/migrate.js +48 -0
- package/dist/naming.js +25 -0
- package/dist/onetime.js +112 -0
- package/dist/ops.js +293 -16
- package/dist/pagescript.js +355 -24
- package/dist/perms.js +1 -0
- package/dist/profile.js +2 -0
- package/dist/pulls.js +21 -1
- package/dist/pullweb.js +25 -6
- package/dist/releases.js +10 -1
- package/dist/runner/actions.js +12 -1
- package/dist/runner/client.js +40 -2
- package/dist/runner/docker.js +40 -28
- package/dist/runner/download.js +77 -0
- package/dist/runner/externals.js +12 -1
- package/dist/runner/job.js +26 -11
- package/dist/runner/steps.js +42 -7
- package/dist/runner-cli.js +89 -0
- package/dist/scan.js +58 -2
- package/dist/server.js +107 -26
- package/dist/session.js +82 -40
- package/dist/site.js +110 -27
- package/dist/sitepublish.js +177 -0
- package/dist/sitesettings.js +420 -0
- package/dist/siteshost.js +116 -28
- package/dist/source.js +20 -0
- package/dist/style.js +162 -3
- package/dist/topics.js +165 -0
- package/dist/vault.js +399 -6
- package/dist/vendor-age.js +15 -0
- package/dist/views.js +242 -57
- package/dist/web.js +11 -1
- package/dist/webauthn.js +347 -0
- package/dist/webops.js +1263 -33
- package/package.json +4 -4
package/dist/vault.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.VAULT_VERSION = exports.VAULT_FILE = void 0;
|
|
36
|
+
exports.GITHUB_BINDING_PREFIX = exports.PASSKEY_BINDING_PREFIX = exports.VAULT_VERSION = exports.VAULT_FILE = void 0;
|
|
37
37
|
exports.tokenId = tokenId;
|
|
38
38
|
exports.vaultFilePath = vaultFilePath;
|
|
39
39
|
exports.loadVault = loadVault;
|
|
@@ -47,15 +47,31 @@ exports.finishPermsMigration = finishPermsMigration;
|
|
|
47
47
|
exports.setSiteAdmin = setSiteAdmin;
|
|
48
48
|
exports.bootstrapVault = bootstrapVault;
|
|
49
49
|
exports.revokeToken = revokeToken;
|
|
50
|
+
exports.passkeyBinding = passkeyBinding;
|
|
51
|
+
exports.githubBinding = githubBinding;
|
|
52
|
+
exports.authForBinding = authForBinding;
|
|
53
|
+
exports.findPasskey = findPasskey;
|
|
54
|
+
exports.addPasskey = addPasskey;
|
|
55
|
+
exports.removePasskey = removePasskey;
|
|
56
|
+
exports.setPasskeyCounter = setPasskeyCounter;
|
|
50
57
|
exports.setUserEmails = setUserEmails;
|
|
58
|
+
exports.setUserProfile = setUserProfile;
|
|
59
|
+
exports.userExists = userExists;
|
|
51
60
|
exports.accountForEmail = accountForEmail;
|
|
52
61
|
exports.mergeContributors = mergeContributors;
|
|
62
|
+
exports.findGithubAccount = findGithubAccount;
|
|
63
|
+
exports.linkGithub = linkGithub;
|
|
64
|
+
exports.unlinkGithub = unlinkGithub;
|
|
65
|
+
exports.approveGithub = approveGithub;
|
|
66
|
+
exports.unapproveGithub = unapproveGithub;
|
|
67
|
+
exports.resolveGithubSignIn = resolveGithubSignIn;
|
|
53
68
|
exports.removeUser = removeUser;
|
|
54
69
|
const crypto = __importStar(require("crypto"));
|
|
55
70
|
const fs = __importStar(require("fs"));
|
|
56
71
|
const path = __importStar(require("path"));
|
|
57
72
|
const atomic_1 = require("./atomic");
|
|
58
73
|
const filecache_1 = require("./filecache");
|
|
74
|
+
const naming_1 = require("./naming");
|
|
59
75
|
const scan_1 = require("./scan");
|
|
60
76
|
exports.VAULT_FILE = 'vault.json';
|
|
61
77
|
/** How a token is named in a listing or a revocation: its id, or a stand-in for one. */
|
|
@@ -65,7 +81,10 @@ function tokenId(t) {
|
|
|
65
81
|
/** The version written to vault.json since roles replaced glob scopes. */
|
|
66
82
|
exports.VAULT_VERSION = 2;
|
|
67
83
|
function vaultFilePath(root) {
|
|
68
|
-
|
|
84
|
+
// naming.stateFile rather than VAULT_FILE, so an application that renamed
|
|
85
|
+
// the file (see src/naming.ts) reads and writes its own; for mochiforge the
|
|
86
|
+
// two spell the same name.
|
|
87
|
+
return path.join(root, naming_1.naming.stateFile);
|
|
69
88
|
}
|
|
70
89
|
function asStringArray(v) {
|
|
71
90
|
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? v : null;
|
|
@@ -111,6 +130,8 @@ function normalizeVault(parsed) {
|
|
|
111
130
|
rec.id = tRec.id;
|
|
112
131
|
if (typeof tRec.created === 'string' && tRec.created !== '')
|
|
113
132
|
rec.created = tRec.created;
|
|
133
|
+
if (typeof tRec.by === 'string' && tRec.by !== '')
|
|
134
|
+
rec.by = tRec.by;
|
|
114
135
|
if (tRec.scope !== undefined) {
|
|
115
136
|
const ts = asStringArray(tRec.scope);
|
|
116
137
|
if (!ts)
|
|
@@ -125,14 +146,120 @@ function normalizeVault(parsed) {
|
|
|
125
146
|
if (!emails) {
|
|
126
147
|
throw new Error(`user ${name}: "emails" must be a list of strings`);
|
|
127
148
|
}
|
|
149
|
+
// Parsed explicitly, like every other field: normalizeVault builds a fresh
|
|
150
|
+
// object, so a field it did not read would be dropped by the next write.
|
|
151
|
+
const passkeysRaw = rec.passkeys ?? [];
|
|
152
|
+
if (!Array.isArray(passkeysRaw)) {
|
|
153
|
+
throw new Error(`user ${name}: "passkeys" must be a list`);
|
|
154
|
+
}
|
|
155
|
+
const passkeys = passkeysRaw.map((p, i) => {
|
|
156
|
+
if (typeof p !== 'object' ||
|
|
157
|
+
p === null ||
|
|
158
|
+
typeof p.id !== 'string' ||
|
|
159
|
+
typeof p.publicKey !== 'string' ||
|
|
160
|
+
typeof p.alg !== 'number') {
|
|
161
|
+
throw new Error(`user ${name}: passkey ${i} must be an object with "id", "publicKey", and "alg"`);
|
|
162
|
+
}
|
|
163
|
+
const pRec = p;
|
|
164
|
+
const out = {
|
|
165
|
+
id: pRec.id,
|
|
166
|
+
publicKey: pRec.publicKey,
|
|
167
|
+
alg: pRec.alg,
|
|
168
|
+
counter: typeof pRec.counter === 'number' && pRec.counter >= 0 ? Math.floor(pRec.counter) : 0,
|
|
169
|
+
};
|
|
170
|
+
if (typeof pRec.created === 'string' && pRec.created !== '')
|
|
171
|
+
out.created = pRec.created;
|
|
172
|
+
if (typeof pRec.name === 'string' && pRec.name !== '')
|
|
173
|
+
out.name = pRec.name;
|
|
174
|
+
return out;
|
|
175
|
+
});
|
|
176
|
+
const profile = normalizeProfile(rec.profile, name);
|
|
177
|
+
const github = normalizeGithubAccount(rec.github, name);
|
|
128
178
|
users[name] = {
|
|
129
179
|
tokens,
|
|
180
|
+
...(passkeys.length ? { passkeys } : {}),
|
|
130
181
|
...(rec.siteAdmin === true ? { siteAdmin: true } : {}),
|
|
131
182
|
...(emails.length ? { emails } : {}),
|
|
183
|
+
...(profile ? { profile } : {}),
|
|
184
|
+
...(github ? { github } : {}),
|
|
132
185
|
...(legacy ? { legacy: { scope, admin } } : {}),
|
|
133
186
|
};
|
|
134
187
|
}
|
|
135
|
-
|
|
188
|
+
const githubApproved = normalizeGithubApproved(parsed.githubApproved);
|
|
189
|
+
return {
|
|
190
|
+
users,
|
|
191
|
+
...(githubApproved.length ? { githubApproved } : {}),
|
|
192
|
+
...(legacy ? { legacy: true } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// Both GitHub shapes are parsed explicitly for the same reason passkeys are:
|
|
196
|
+
// normalizeVault builds a fresh object, so a field it did not read would be
|
|
197
|
+
// dropped by the next write.
|
|
198
|
+
function normalizeGithubAccount(raw, username) {
|
|
199
|
+
if (raw === undefined || raw === null)
|
|
200
|
+
return null;
|
|
201
|
+
if (typeof raw !== 'object' ||
|
|
202
|
+
typeof raw.id !== 'number' ||
|
|
203
|
+
typeof raw.login !== 'string') {
|
|
204
|
+
throw new Error(`user ${username}: "github" must be an object with a numeric "id" and a "login"`);
|
|
205
|
+
}
|
|
206
|
+
const rec = raw;
|
|
207
|
+
const out = { id: Math.floor(rec.id), login: rec.login };
|
|
208
|
+
if (typeof rec.linked === 'string' && rec.linked !== '')
|
|
209
|
+
out.linked = rec.linked;
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
function normalizeGithubApproved(raw) {
|
|
213
|
+
if (raw === undefined || raw === null)
|
|
214
|
+
return [];
|
|
215
|
+
if (!Array.isArray(raw)) {
|
|
216
|
+
throw new Error('"githubApproved" must be a list');
|
|
217
|
+
}
|
|
218
|
+
return raw.map((a, i) => {
|
|
219
|
+
if (typeof a !== 'object' ||
|
|
220
|
+
a === null ||
|
|
221
|
+
typeof a.id !== 'number' ||
|
|
222
|
+
typeof a.login !== 'string') {
|
|
223
|
+
throw new Error(`githubApproved ${i} must be an object with a numeric "id" and a "login"`);
|
|
224
|
+
}
|
|
225
|
+
const rec = a;
|
|
226
|
+
const out = { id: Math.floor(rec.id), login: rec.login };
|
|
227
|
+
if (typeof rec.added === 'string' && rec.added !== '')
|
|
228
|
+
out.added = rec.added;
|
|
229
|
+
return out;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
// A hand-edited vault.json should not take the vault down over a profile, so
|
|
233
|
+
// the check is a shape check: fields that are not strings are refused, and a
|
|
234
|
+
// profile with nothing in it reads as no profile at all.
|
|
235
|
+
function normalizeProfile(raw, username) {
|
|
236
|
+
if (raw === undefined || raw === null)
|
|
237
|
+
return null;
|
|
238
|
+
if (typeof raw !== 'object') {
|
|
239
|
+
throw new Error(`user ${username}: "profile" must be an object`);
|
|
240
|
+
}
|
|
241
|
+
const rec = raw;
|
|
242
|
+
const profile = {};
|
|
243
|
+
if (rec.name !== undefined) {
|
|
244
|
+
if (typeof rec.name !== 'string')
|
|
245
|
+
throw new Error(`user ${username}: profile "name" must be a string`);
|
|
246
|
+
if (rec.name !== '')
|
|
247
|
+
profile.name = rec.name;
|
|
248
|
+
}
|
|
249
|
+
if (rec.bio !== undefined) {
|
|
250
|
+
if (typeof rec.bio !== 'string')
|
|
251
|
+
throw new Error(`user ${username}: profile "bio" must be a string`);
|
|
252
|
+
if (rec.bio !== '')
|
|
253
|
+
profile.bio = rec.bio;
|
|
254
|
+
}
|
|
255
|
+
if (rec.links !== undefined) {
|
|
256
|
+
const links = asStringArray(rec.links);
|
|
257
|
+
if (!links)
|
|
258
|
+
throw new Error(`user ${username}: profile "links" must be a list of strings`);
|
|
259
|
+
if (links.length)
|
|
260
|
+
profile.links = links;
|
|
261
|
+
}
|
|
262
|
+
return Object.keys(profile).length ? profile : null;
|
|
136
263
|
}
|
|
137
264
|
const cache = (0, filecache_1.fileCache)({
|
|
138
265
|
read: (file) => {
|
|
@@ -152,7 +279,7 @@ function hashToken(token) {
|
|
|
152
279
|
return crypto.createHash('sha256').update(token).digest('hex');
|
|
153
280
|
}
|
|
154
281
|
function mintToken() {
|
|
155
|
-
const token =
|
|
282
|
+
const token = naming_1.naming.tokenPrefix + crypto.randomBytes(32).toString('hex');
|
|
156
283
|
return { token, hash: hashToken(token) };
|
|
157
284
|
}
|
|
158
285
|
function globMatch(pattern, target) {
|
|
@@ -207,7 +334,11 @@ function writeVault(file, vault) {
|
|
|
207
334
|
const { legacy: _legacy, ...rest } = u;
|
|
208
335
|
users[name] = rest;
|
|
209
336
|
}
|
|
210
|
-
(0, atomic_1.writeFileAtomic)(file, JSON.stringify({
|
|
337
|
+
(0, atomic_1.writeFileAtomic)(file, JSON.stringify({
|
|
338
|
+
version: exports.VAULT_VERSION,
|
|
339
|
+
users,
|
|
340
|
+
...(vault.githubApproved?.length ? { githubApproved: vault.githubApproved } : {}),
|
|
341
|
+
}, null, 2) + '\n', { mode: 0o600 });
|
|
211
342
|
}
|
|
212
343
|
/**
|
|
213
344
|
* Every edit to vault.json is a read, a change in memory, and a write back, and
|
|
@@ -218,7 +349,7 @@ function writeVault(file, vault) {
|
|
|
218
349
|
* one, which the atomic rename already guarantees.
|
|
219
350
|
*/
|
|
220
351
|
function editVault(root, fn) {
|
|
221
|
-
return (0, atomic_1.withFileLock)(
|
|
352
|
+
return (0, atomic_1.withFileLock)(`${vaultFilePath(root)}.lock`, () => fn(vaultFilePath(root)));
|
|
222
353
|
}
|
|
223
354
|
// Reread under the lock, refusing a pre-roles file: writeVault writes the
|
|
224
355
|
// current shape, so editing a legacy file in place would silently drop the
|
|
@@ -260,6 +391,8 @@ function addUserTokenLocked(file, username, opts) {
|
|
|
260
391
|
const rec = { hash, id: crypto.randomBytes(4).toString('hex'), created: new Date().toISOString() };
|
|
261
392
|
if (opts.tokenScope && opts.tokenScope.length)
|
|
262
393
|
rec.scope = opts.tokenScope;
|
|
394
|
+
if (opts.by && opts.by !== username)
|
|
395
|
+
rec.by = opts.by;
|
|
263
396
|
user.tokens.push(rec);
|
|
264
397
|
writeVault(file, vault);
|
|
265
398
|
return { token, created, user };
|
|
@@ -363,6 +496,108 @@ function revokeToken(root, username, id) {
|
|
|
363
496
|
return { revoked: true, remaining: user.tokens.length };
|
|
364
497
|
});
|
|
365
498
|
}
|
|
499
|
+
/** How a session bound to a passkey spells it in the cookie payload: never a
|
|
500
|
+
* hex token hash, so the two kinds of binding cannot collide. */
|
|
501
|
+
exports.PASSKEY_BINDING_PREFIX = 'pk:';
|
|
502
|
+
function passkeyBinding(credentialId) {
|
|
503
|
+
return exports.PASSKEY_BINDING_PREFIX + credentialId;
|
|
504
|
+
}
|
|
505
|
+
/** How a session signed in with GitHub spells its binding: the numeric GitHub
|
|
506
|
+
* id behind a prefix, so it can collide with neither a token hash nor a
|
|
507
|
+
* passkey binding. */
|
|
508
|
+
exports.GITHUB_BINDING_PREFIX = 'gh:';
|
|
509
|
+
function githubBinding(id) {
|
|
510
|
+
return exports.GITHUB_BINDING_PREFIX + String(id);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* The AuthResult a session binding resolves to against a live vault: a token
|
|
514
|
+
* binding finds its TokenRecord, while a passkey or GitHub binding finds the
|
|
515
|
+
* credential and stands a synthetic token in for it. The synthetic record
|
|
516
|
+
* carries no scope, deliberately: a passkey or a GitHub sign-in proves the
|
|
517
|
+
* user, not a narrowed token, so a session signed in with one has the user's
|
|
518
|
+
* own rights, exactly as an unscoped token would. Its hash is the binding
|
|
519
|
+
* string, which is not hex and so can never name a real token anywhere else.
|
|
520
|
+
*/
|
|
521
|
+
function authForBinding(vault, username, binding) {
|
|
522
|
+
const user = vault.users[username];
|
|
523
|
+
if (!user)
|
|
524
|
+
return null;
|
|
525
|
+
if (binding.startsWith(exports.PASSKEY_BINDING_PREFIX)) {
|
|
526
|
+
const id = binding.slice(exports.PASSKEY_BINDING_PREFIX.length);
|
|
527
|
+
if (!user.passkeys?.some((p) => p.id === id))
|
|
528
|
+
return null;
|
|
529
|
+
return { username, user, token: { hash: binding } };
|
|
530
|
+
}
|
|
531
|
+
if (binding.startsWith(exports.GITHUB_BINDING_PREFIX)) {
|
|
532
|
+
const id = Number(binding.slice(exports.GITHUB_BINDING_PREFIX.length));
|
|
533
|
+
if (!Number.isInteger(id) || user.github?.id !== id)
|
|
534
|
+
return null;
|
|
535
|
+
return { username, user, token: { hash: binding } };
|
|
536
|
+
}
|
|
537
|
+
const token = user.tokens.find((t) => t.hash === binding);
|
|
538
|
+
return token ? { username, user, token } : null;
|
|
539
|
+
}
|
|
540
|
+
/** The user a credential id belongs to, for a sign-in that only knows the key. */
|
|
541
|
+
function findPasskey(vault, credentialId) {
|
|
542
|
+
for (const [username, user] of Object.entries(vault.users)) {
|
|
543
|
+
const passkey = user.passkeys?.find((p) => p.id === credentialId);
|
|
544
|
+
if (passkey)
|
|
545
|
+
return { username, user, passkey };
|
|
546
|
+
}
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
/** Register a passkey. The credential id must be new to the whole vault: an
|
|
550
|
+
* authenticator names its keys uniquely, so a collision is a replayed
|
|
551
|
+
* registration rather than a coincidence to accommodate. */
|
|
552
|
+
function addPasskey(root, username, rec) {
|
|
553
|
+
return editVault(root, (file) => {
|
|
554
|
+
const vault = readVaultForEdit(file);
|
|
555
|
+
const user = vault.users[username];
|
|
556
|
+
if (!user)
|
|
557
|
+
throw new Error(`no user ${username}`);
|
|
558
|
+
if (findPasskey(vault, rec.id))
|
|
559
|
+
throw new Error('this passkey is already registered');
|
|
560
|
+
const stored = { ...rec, created: new Date().toISOString() };
|
|
561
|
+
user.passkeys = [...(user.passkeys ?? []), stored];
|
|
562
|
+
writeVault(file, vault);
|
|
563
|
+
return stored;
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
/** Remove one passkey, ending any session signed in with it. */
|
|
567
|
+
function removePasskey(root, username, credentialId) {
|
|
568
|
+
return editVault(root, (file) => {
|
|
569
|
+
const vault = readVaultForEdit(file);
|
|
570
|
+
const user = vault.users[username];
|
|
571
|
+
if (!user)
|
|
572
|
+
throw new Error(`no user ${username}`);
|
|
573
|
+
const before = user.passkeys?.length ?? 0;
|
|
574
|
+
const after = (user.passkeys ?? []).filter((p) => p.id !== credentialId);
|
|
575
|
+
if (after.length === before)
|
|
576
|
+
return false;
|
|
577
|
+
if (after.length)
|
|
578
|
+
user.passkeys = after;
|
|
579
|
+
else
|
|
580
|
+
delete user.passkeys;
|
|
581
|
+
writeVault(file, vault);
|
|
582
|
+
return true;
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Record the signature counter an assertion carried. Best effort: the write
|
|
587
|
+
* exists to catch a cloned authenticator replaying an old counter, and a
|
|
588
|
+
* vault.json that cannot be written right now is not worth failing a sign-in
|
|
589
|
+
* whose signature already verified.
|
|
590
|
+
*/
|
|
591
|
+
function setPasskeyCounter(root, username, credentialId, counter) {
|
|
592
|
+
editVault(root, (file) => {
|
|
593
|
+
const vault = readVaultForEdit(file);
|
|
594
|
+
const passkey = vault.users[username]?.passkeys?.find((p) => p.id === credentialId);
|
|
595
|
+
if (!passkey || passkey.counter >= counter)
|
|
596
|
+
return;
|
|
597
|
+
passkey.counter = counter;
|
|
598
|
+
writeVault(file, vault);
|
|
599
|
+
});
|
|
600
|
+
}
|
|
366
601
|
/** Replace the git author emails aliased to a user. An empty list clears them. */
|
|
367
602
|
function setUserEmails(root, username, emails) {
|
|
368
603
|
return editVault(root, (file) => {
|
|
@@ -378,6 +613,38 @@ function setUserEmails(root, username, emails) {
|
|
|
378
613
|
return user;
|
|
379
614
|
});
|
|
380
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Replace a user's profile. Empty fields are dropped rather than stored, and a
|
|
618
|
+
* profile with nothing left is removed outright, so vault.json never carries an
|
|
619
|
+
* empty object for a user who cleared theirs.
|
|
620
|
+
*/
|
|
621
|
+
function setUserProfile(root, username, profile) {
|
|
622
|
+
return editVault(root, (file) => {
|
|
623
|
+
const vault = readVaultForEdit(file);
|
|
624
|
+
const user = vault.users[username];
|
|
625
|
+
if (!user)
|
|
626
|
+
throw new Error(`no user ${username}`);
|
|
627
|
+
const clean = {};
|
|
628
|
+
if (profile.name?.trim())
|
|
629
|
+
clean.name = profile.name.trim();
|
|
630
|
+
if (profile.bio?.trim())
|
|
631
|
+
clean.bio = profile.bio.trim();
|
|
632
|
+
const links = (profile.links ?? []).map((l) => l.trim()).filter((l) => l !== '');
|
|
633
|
+
if (links.length)
|
|
634
|
+
clean.links = links;
|
|
635
|
+
if (Object.keys(clean).length)
|
|
636
|
+
user.profile = clean;
|
|
637
|
+
else
|
|
638
|
+
delete user.profile;
|
|
639
|
+
writeVault(file, vault);
|
|
640
|
+
return user;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
/** Whether the vault knows a user by this name; false when it failed to load. */
|
|
644
|
+
function userExists(root, name) {
|
|
645
|
+
const state = loadVault(root);
|
|
646
|
+
return state.status === 'ok' && Object.prototype.hasOwnProperty.call(state.vault.users, name);
|
|
647
|
+
}
|
|
381
648
|
/**
|
|
382
649
|
* The user a commit author email belongs to, or null when it belongs to no
|
|
383
650
|
* one this vault knows. Two ways in: the synthetic `<user>@noreply.<anything>`
|
|
@@ -429,6 +696,132 @@ function mergeContributors(vault, people) {
|
|
|
429
696
|
.map(({ bestCommits: _b, bestSynthetic: _s, ...c }) => c)
|
|
430
697
|
.sort((a, b) => b.commits - a.commits);
|
|
431
698
|
}
|
|
699
|
+
/** The user a GitHub id is linked to, for a sign-in arriving from GitHub. */
|
|
700
|
+
function findGithubAccount(vault, id) {
|
|
701
|
+
for (const [username, user] of Object.entries(vault.users)) {
|
|
702
|
+
if (user.github?.id === id)
|
|
703
|
+
return { username, user };
|
|
704
|
+
}
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Link a GitHub account to a user. One GitHub id links to at most one user,
|
|
709
|
+
* or a sign-in with it would have two answers; relinking the same id to the
|
|
710
|
+
* same user only refreshes the login.
|
|
711
|
+
*/
|
|
712
|
+
function linkGithub(root, username, account) {
|
|
713
|
+
return editVault(root, (file) => {
|
|
714
|
+
const vault = readVaultForEdit(file);
|
|
715
|
+
const user = vault.users[username];
|
|
716
|
+
if (!user)
|
|
717
|
+
throw new Error(`no user ${username}`);
|
|
718
|
+
const holder = findGithubAccount(vault, account.id);
|
|
719
|
+
if (holder && holder.username !== username) {
|
|
720
|
+
throw new Error(`The GitHub account ${account.login} is already linked to ${holder.username}.`);
|
|
721
|
+
}
|
|
722
|
+
const linked = user.github?.id === account.id ? user.github.linked : new Date().toISOString();
|
|
723
|
+
const stored = { id: account.id, login: account.login, ...(linked ? { linked } : {}) };
|
|
724
|
+
user.github = stored;
|
|
725
|
+
writeVault(file, vault);
|
|
726
|
+
return stored;
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/** Remove a user's GitHub link, ending any session signed in with it. */
|
|
730
|
+
function unlinkGithub(root, username) {
|
|
731
|
+
return editVault(root, (file) => {
|
|
732
|
+
const vault = readVaultForEdit(file);
|
|
733
|
+
const user = vault.users[username];
|
|
734
|
+
if (!user)
|
|
735
|
+
throw new Error(`no user ${username}`);
|
|
736
|
+
if (!user.github)
|
|
737
|
+
return false;
|
|
738
|
+
delete user.github;
|
|
739
|
+
writeVault(file, vault);
|
|
740
|
+
return true;
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
/** Add a GitHub account to the approved list, or refresh its login there. */
|
|
744
|
+
function approveGithub(root, account) {
|
|
745
|
+
return editVault(root, (file) => {
|
|
746
|
+
const vault = readVaultForEdit(file);
|
|
747
|
+
const list = vault.githubApproved ?? [];
|
|
748
|
+
const existing = list.find((a) => a.id === account.id);
|
|
749
|
+
if (existing) {
|
|
750
|
+
existing.login = account.login;
|
|
751
|
+
vault.githubApproved = list;
|
|
752
|
+
writeVault(file, vault);
|
|
753
|
+
return existing;
|
|
754
|
+
}
|
|
755
|
+
const added = { id: account.id, login: account.login, added: new Date().toISOString() };
|
|
756
|
+
vault.githubApproved = [...list, added];
|
|
757
|
+
writeVault(file, vault);
|
|
758
|
+
return added;
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
/** Remove one approval by GitHub id. A user already linked is not touched:
|
|
762
|
+
* approval gates only the first sign-in, which creates an account. */
|
|
763
|
+
function unapproveGithub(root, id) {
|
|
764
|
+
return editVault(root, (file) => {
|
|
765
|
+
const vault = readVaultForEdit(file);
|
|
766
|
+
const before = vault.githubApproved?.length ?? 0;
|
|
767
|
+
const after = (vault.githubApproved ?? []).filter((a) => a.id !== id);
|
|
768
|
+
if (after.length === before)
|
|
769
|
+
return false;
|
|
770
|
+
if (after.length)
|
|
771
|
+
vault.githubApproved = after;
|
|
772
|
+
else
|
|
773
|
+
delete vault.githubApproved;
|
|
774
|
+
writeVault(file, vault);
|
|
775
|
+
return true;
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* What a completed GitHub sign-in means to this vault. A linked account wins
|
|
780
|
+
* outright; failing that, an approved id has an account created for it, named
|
|
781
|
+
* after the GitHub login (with a numeric suffix when that name is taken or
|
|
782
|
+
* reserved); anything else is refused. One vault edit end to end, so the
|
|
783
|
+
* check and the provisioning cannot race a second sign-in into two accounts.
|
|
784
|
+
* The new account holds no tokens: it can use the web signed in with GitHub,
|
|
785
|
+
* and pushing over git waits until an administrator mints it a token.
|
|
786
|
+
*/
|
|
787
|
+
function resolveGithubSignIn(root, account) {
|
|
788
|
+
return editVault(root, (file) => {
|
|
789
|
+
if (!fs.existsSync(file))
|
|
790
|
+
return { kind: 'refused' };
|
|
791
|
+
const vault = readVaultForEdit(file);
|
|
792
|
+
const linked = findGithubAccount(vault, account.id);
|
|
793
|
+
if (linked) {
|
|
794
|
+
// A rename on GitHub's side refreshes the stored login, which is
|
|
795
|
+
// display only; the id did the matching.
|
|
796
|
+
if (linked.user.github && linked.user.github.login !== account.login) {
|
|
797
|
+
linked.user.github.login = account.login;
|
|
798
|
+
writeVault(file, vault);
|
|
799
|
+
}
|
|
800
|
+
return { kind: 'ok', username: linked.username, created: false };
|
|
801
|
+
}
|
|
802
|
+
const approval = (vault.githubApproved ?? []).find((a) => a.id === account.id);
|
|
803
|
+
if (!approval)
|
|
804
|
+
return { kind: 'refused' };
|
|
805
|
+
let username = null;
|
|
806
|
+
for (const candidate of [account.login, ...[2, 3, 4, 5, 6, 7, 8, 9].map((n) => `${account.login}-${n}`)]) {
|
|
807
|
+
if ((0, scan_1.isValidUserName)(candidate) && !vault.users[candidate]) {
|
|
808
|
+
username = candidate;
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (!username) {
|
|
813
|
+
return { kind: 'error', message: `No free username could be derived from ${account.login}.` };
|
|
814
|
+
}
|
|
815
|
+
vault.users[username] = {
|
|
816
|
+
tokens: [],
|
|
817
|
+
github: { id: account.id, login: account.login, linked: new Date().toISOString() },
|
|
818
|
+
};
|
|
819
|
+
if (approval.login !== account.login)
|
|
820
|
+
approval.login = account.login;
|
|
821
|
+
writeVault(file, vault);
|
|
822
|
+
return { kind: 'ok', username, created: true };
|
|
823
|
+
});
|
|
824
|
+
}
|
|
432
825
|
/** Remove a user, and with them every token they hold. */
|
|
433
826
|
function removeUser(root, username) {
|
|
434
827
|
return editVault(root, (file) => {
|