@magland/mochi 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +7 -6
  2. package/dist/agefile.js +47 -0
  3. package/dist/agescript.js +343 -0
  4. package/dist/api/admin.js +66 -28
  5. package/dist/api/auth.js +3 -2
  6. package/dist/api/backup.js +227 -192
  7. package/dist/api/contents.js +46 -16
  8. package/dist/api/repos.js +60 -4
  9. package/dist/api/write.js +138 -8
  10. package/dist/api.js +34 -1
  11. package/dist/browse.js +171 -33
  12. package/dist/build-info.json +2 -2
  13. package/dist/ci/api.js +138 -4
  14. package/dist/ci/artifacts.js +82 -2
  15. package/dist/ci/engine.js +30 -17
  16. package/dist/ci/expr.js +14 -0
  17. package/dist/ci/runners.js +60 -1
  18. package/dist/ci/runs.js +21 -3
  19. package/dist/ci/trigger.js +6 -4
  20. package/dist/ci/views.js +24 -3
  21. package/dist/ci/web.js +48 -0
  22. package/dist/cli/admin-cmd.js +40 -3
  23. package/dist/cli/backup-cmd.js +138 -120
  24. package/dist/cli/gitrun.js +50 -0
  25. package/dist/cli/pr-cmd.js +155 -0
  26. package/dist/cli/repo-cmd.js +134 -6
  27. package/dist/cli/sync-cmd.js +128 -0
  28. package/dist/cli/target.js +7 -3
  29. package/dist/config.js +10 -0
  30. package/dist/credentials.js +9 -8
  31. package/dist/deploy-cli.js +214 -109
  32. package/dist/deploy-runner-cli.js +125 -49
  33. package/dist/domains.js +259 -0
  34. package/dist/egress.js +23 -6
  35. package/dist/forms.js +703 -32
  36. package/dist/git.js +124 -13
  37. package/dist/githttp.js +80 -23
  38. package/dist/githubauth.js +185 -0
  39. package/dist/icons.js +14 -0
  40. package/dist/import-cli.js +40 -25
  41. package/dist/index.js +110 -4
  42. package/dist/issueweb.js +10 -6
  43. package/dist/lfs.js +9 -2
  44. package/dist/limit.js +2 -2
  45. package/dist/logincodes.js +31 -0
  46. package/dist/maintenance.js +297 -0
  47. package/dist/markdown.js +192 -3
  48. package/dist/mdedit.js +29 -0
  49. package/dist/migrate.js +48 -0
  50. package/dist/naming.js +25 -0
  51. package/dist/onetime.js +112 -0
  52. package/dist/ops.js +293 -16
  53. package/dist/pagescript.js +355 -24
  54. package/dist/perms.js +1 -0
  55. package/dist/profile.js +2 -0
  56. package/dist/pulls.js +21 -1
  57. package/dist/pullweb.js +25 -6
  58. package/dist/releases.js +10 -1
  59. package/dist/runner/actions.js +12 -1
  60. package/dist/runner/client.js +40 -2
  61. package/dist/runner/docker.js +40 -28
  62. package/dist/runner/download.js +77 -0
  63. package/dist/runner/externals.js +12 -1
  64. package/dist/runner/job.js +26 -11
  65. package/dist/runner/steps.js +42 -7
  66. package/dist/runner-cli.js +89 -0
  67. package/dist/scan.js +58 -2
  68. package/dist/server.js +107 -26
  69. package/dist/session.js +82 -40
  70. package/dist/site.js +110 -27
  71. package/dist/sitepublish.js +177 -0
  72. package/dist/sitesettings.js +420 -0
  73. package/dist/siteshost.js +116 -28
  74. package/dist/source.js +20 -0
  75. package/dist/style.js +162 -3
  76. package/dist/topics.js +165 -0
  77. package/dist/vault.js +399 -6
  78. package/dist/vendor-age.js +15 -0
  79. package/dist/views.js +242 -57
  80. package/dist/web.js +11 -1
  81. package/dist/webauthn.js +347 -0
  82. package/dist/webops.js +1263 -33
  83. package/package.json +4 -4
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
- return path.join(root, exports.VAULT_FILE);
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
- return { users, ...(legacy ? { legacy: true } : {}) };
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 = 'mochi_' + crypto.randomBytes(32).toString('hex');
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({ version: exports.VAULT_VERSION, users }, null, 2) + '\n', { mode: 0o600 });
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)(path.join(root, `${exports.VAULT_FILE}.lock`), () => fn(vaultFilePath(root)));
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) => {