@cortexkit/antigravity-auth-core 3.0.11 → 3.0.13

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 (66) hide show
  1. package/dist/account-selector.d.ts +9 -1
  2. package/dist/account-selector.d.ts.map +1 -1
  3. package/dist/account-selector.js +23 -8
  4. package/dist/account-selector.js.map +1 -1
  5. package/dist/agy-transport.d.ts.map +1 -1
  6. package/dist/agy-transport.js +4 -0
  7. package/dist/agy-transport.js.map +1 -1
  8. package/dist/common-auth-embedded/claustrum/consumer.d.ts +83 -2
  9. package/dist/common-auth-embedded/claustrum/consumer.js +337 -37
  10. package/dist/common-auth-embedded/claustrum/custody.d.ts +36 -1
  11. package/dist/common-auth-embedded/claustrum/custody.js +95 -11
  12. package/dist/common-auth-embedded/claustrum/enrollment.js +37 -12
  13. package/dist/common-auth-embedded/claustrum/errors.d.ts +1 -1
  14. package/dist/common-auth-embedded/claustrum/hub.d.ts +195 -0
  15. package/dist/common-auth-embedded/claustrum/hub.js +988 -0
  16. package/dist/common-auth-embedded/claustrum/index.d.ts +13 -3
  17. package/dist/common-auth-embedded/claustrum/index.js +37 -4
  18. package/dist/common-auth-embedded/claustrum/quota-writer.d.ts +8 -1
  19. package/dist/common-auth-embedded/claustrum/quota-writer.js +38 -66
  20. package/dist/common-auth-embedded/claustrum/roster.d.ts +80 -1
  21. package/dist/common-auth-embedded/claustrum/roster.js +259 -12
  22. package/dist/common-auth-embedded/claustrum/shutdown.js +42 -0
  23. package/dist/common-auth-embedded/commands/builtins.d.ts +37 -0
  24. package/dist/common-auth-embedded/commands/builtins.js +95 -13
  25. package/dist/common-auth-embedded/commands/index.d.ts +1 -1
  26. package/dist/common-auth-embedded/fs/file-read-cache.js +57 -0
  27. package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +1 -1
  28. package/dist/common-auth-embedded/fs/refresh-file-lock.js +20 -11
  29. package/dist/common-auth-embedded/quota/commit-interval.d.ts +9 -0
  30. package/dist/common-auth-embedded/quota/commit-interval.js +18 -0
  31. package/dist/common-auth-embedded/quota/pending.js +48 -0
  32. package/dist/common-auth-embedded/source-output.json +152 -88
  33. package/dist/common-auth-embedded/store/attribution.d.ts +5 -0
  34. package/dist/common-auth-embedded/store/attribution.js +39 -25
  35. package/dist/common-auth-embedded/store/mutate.d.ts +2 -0
  36. package/dist/common-auth-embedded/store/mutate.js +4 -0
  37. package/dist/common-auth-embedded/store/pool.d.ts +22 -0
  38. package/dist/common-auth-embedded/store/pool.js +18 -3
  39. package/dist/common-auth-embedded/store/pull.js +3 -1
  40. package/dist/common-auth-embedded/store/quota-writer.js +312 -0
  41. package/dist/common-auth-embedded/store/runtime.d.ts +2 -0
  42. package/dist/common-auth-embedded/store/settings.d.ts +1 -1
  43. package/dist/common-auth-embedded/store/settings.js +16 -6
  44. package/dist/model-registry.d.ts +2 -2
  45. package/dist/model-registry.d.ts.map +1 -1
  46. package/dist/model-registry.js +6 -4
  47. package/dist/model-registry.js.map +1 -1
  48. package/dist/pre-response-deadline.d.ts.map +1 -1
  49. package/dist/pre-response-deadline.js +6 -5
  50. package/dist/pre-response-deadline.js.map +1 -1
  51. package/dist/quota-manager.d.ts +1 -1
  52. package/dist/quota-manager.d.ts.map +1 -1
  53. package/dist/quota-manager.js +12 -4
  54. package/dist/quota-manager.js.map +1 -1
  55. package/dist/quota-presentation.d.ts.map +1 -1
  56. package/dist/quota-presentation.js +8 -0
  57. package/dist/quota-presentation.js.map +1 -1
  58. package/dist/quota-types.d.ts +1 -1
  59. package/dist/quota-types.d.ts.map +1 -1
  60. package/dist/quota-types.js +5 -0
  61. package/dist/quota-types.js.map +1 -1
  62. package/dist/vault-account-source.d.ts +6 -1
  63. package/dist/vault-account-source.d.ts.map +1 -1
  64. package/dist/vault-account-source.js +9 -0
  65. package/dist/vault-account-source.js.map +1 -1
  66. package/package.json +1 -1
@@ -1,12 +1,36 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { stat } from 'node:fs/promises';
2
3
  import { resolve } from 'node:path';
3
- import { acquireRefreshFileLock, LockContentionError, withLock, writeJsonAtomic, } from '../fs/index.js';
4
+ import { acquireRefreshFileLock, LockContentionError, LockOwnershipError, lockPathFor, PrivateFileError, withLock, writeJsonAtomic, } from '../fs/index.js';
4
5
  import { readPrivateText, windowsPrivateFiles } from '../fs/private-files.js';
5
6
  import { decodeQuotaMap, isQuotaObservation, mergeQuotaObservation, } from '../quota/index.js';
6
7
  import { ClaustrumConsumerError } from './errors.js';
7
8
  import { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
8
- import { enqueueVaultQuota, pendingVaultQuota, } from './quota-writer.js';
9
+ import { enqueueVaultQuota, hasPendingVaultQuota, pendingVaultQuota, } from './quota-writer.js';
9
10
  export { drainVaultQuota } from './quota-writer.js';
11
+ /** Declines and account order of `roster`, and nothing else of it. */
12
+ export function vaultRosterSeed(roster) {
13
+ return {
14
+ declined: roster.declined.map((entry) => ({ ...entry })),
15
+ order: roster.rows.flatMap((row) => memberIds(row).map((credentialId) => ({
16
+ credentialId,
17
+ ...(row.accountIdentity !== undefined && {
18
+ accountIdentity: row.accountIdentity,
19
+ }),
20
+ }))),
21
+ };
22
+ }
23
+ /** Where a projected row falls in the seed's order; unknown rows go last. */
24
+ function seedIndex(seed, row) {
25
+ const ids = memberIds(row);
26
+ const byAccount = row.accountIdentity === undefined
27
+ ? -1
28
+ : seed.order.findIndex((entry) => entry.accountIdentity === row.accountIdentity);
29
+ const index = byAccount >= 0
30
+ ? byAccount
31
+ : seed.order.findIndex((entry) => ids.includes(entry.credentialId));
32
+ return index >= 0 ? index : Infinity;
33
+ }
10
34
  export const DEFAULT_ROUTE_PREFIX = 'vault:';
11
35
  function hash(value) {
12
36
  return createHash('sha256').update(value).digest('hex');
@@ -47,8 +71,9 @@ export function projectVaultRoster(previous, inventory, options = {}) {
47
71
  const prefix = options.routePrefix ?? DEFAULT_ROUTE_PREFIX;
48
72
  const reserved = options.reservedRouteIds ?? new Set();
49
73
  const previousRows = previous?.rows ?? [];
74
+ const seed = previous ? undefined : options.seed;
50
75
  // Declines follow the account and outlive its records, so none is pruned.
51
- const declined = [...(previous?.declined ?? [])];
76
+ const declined = [...(previous?.declined ?? seed?.declined ?? [])];
52
77
  const complete = inventory.skipped.length === 0;
53
78
  const listed = new Set(inventory.credentials.map((credential) => credential.credentialId));
54
79
  const rejectedIds = new Set(inventory.skipped.flatMap((record) => record.credentialId === undefined ? [] : [record.credentialId]));
@@ -146,6 +171,11 @@ export function projectVaultRoster(previous, inventory, options = {}) {
146
171
  ...(!chosen.claimed && identity !== undefined && { unclaimed: true }),
147
172
  },
148
173
  });
174
+ // With no previous roster every row is new; a seed from another key
175
+ // sorts them by that key's order instead of by credential id.
176
+ const pushed = projected.at(-1);
177
+ if (seed && pushed)
178
+ pushed.previousIndex = seedIndex(seed, pushed.row);
149
179
  }
150
180
  // An account the reply does not account for keeps its last good projection:
151
181
  // a rejected record must not read as the account having been removed. A
@@ -293,6 +323,14 @@ function decodeRoster(value) {
293
323
  declined,
294
324
  };
295
325
  }
326
+ /**
327
+ * `roster` as `readVaultRoster` decodes it from the file that holds it, so a
328
+ * committed roster and a later read of its file compare equal. Package
329
+ * internal: not exported from `/claustrum`.
330
+ */
331
+ export function decodedVaultRoster(roster) {
332
+ return decodeRoster(JSON.parse(JSON.stringify(roster)));
333
+ }
296
334
  export async function readVaultRoster(path) {
297
335
  let text;
298
336
  try {
@@ -378,6 +416,78 @@ export function acceptVaultRoute(path, routeId) {
378
416
  };
379
417
  });
380
418
  }
419
+ function invalidOrder(message) {
420
+ return new ClaustrumConsumerError('invalid-order', message);
421
+ }
422
+ /**
423
+ * Why `routeIds` is not an order of these rows: it must name every distinct
424
+ * route id exactly once and nothing else. The message quotes only ids the
425
+ * caller supplied; route ids it left out are counted, not named.
426
+ */
427
+ function vaultOrderProblem(rows, routeIds) {
428
+ if (!Array.isArray(routeIds))
429
+ return 'route ids must be an array of route ids';
430
+ const rosterIds = new Set(rows.map((row) => row.routeId));
431
+ const given = new Set();
432
+ for (const id of routeIds) {
433
+ if (typeof id !== 'string')
434
+ return 'route ids must be an array of route ids';
435
+ if (given.has(id))
436
+ return `route id ${id} appears more than once`;
437
+ if (!rosterIds.has(id))
438
+ return `route id ${id} is not in the roster`;
439
+ given.add(id);
440
+ }
441
+ const missing = [...rosterIds].filter((id) => !given.has(id)).length;
442
+ if (missing > 0)
443
+ return `the order leaves out ${missing} roster route id${missing === 1 ? '' : 's'}`;
444
+ return undefined;
445
+ }
446
+ /**
447
+ * Set the order vault accounts are tried in. Routing reads that order from
448
+ * the roster's row order (`vaultRoutingRows`), and a later discovery keeps
449
+ * each account at its position, so the order lasts until the next reorder.
450
+ * The order is this host's routing preference: the vault has no order of its
451
+ * own, and this never contacts it.
452
+ *
453
+ * `routeIds` must name every roster route id exactly once; anything else
454
+ * (an unknown, missing or repeated id) refuses with `invalid-order` and
455
+ * writes nothing. It runs under the roster's write lock, like every other
456
+ * roster write, so a quota reading or decline committed meanwhile is kept.
457
+ * Each row is kept as the same object, so only the positions change: every
458
+ * row's fields and the rest of the file stay as they were. An order equal to
459
+ * the current one writes nothing. A host with no roster file yet has no
460
+ * routes, so only the empty order is accepted.
461
+ *
462
+ * A `ClaustrumConsumer` in the same process does not see the new order until
463
+ * it re-reads the file: call `consumer.reorder` instead to update both.
464
+ */
465
+ export function reorderVaultRoster(path, routeIds, options = {}) {
466
+ return mutateVaultRoster(path, (current) => {
467
+ const rows = current?.rows ?? [];
468
+ const problem = vaultOrderProblem(rows, routeIds);
469
+ if (problem)
470
+ throw invalidOrder(problem);
471
+ // Group by route id so a hand-edited file that repeats one keeps every
472
+ // copy: the later copies travel right after the first.
473
+ const byId = new Map();
474
+ for (const row of rows) {
475
+ const group = byId.get(row.routeId);
476
+ if (group)
477
+ group.push(row);
478
+ else
479
+ byId.set(row.routeId, [row]);
480
+ }
481
+ const ids = [...routeIds];
482
+ const next = ids.flatMap((id) => byId.get(id) ?? []);
483
+ if (!current || next.every((row, index) => row === rows[index]))
484
+ return { result: { ids, outcome: 'unchanged' } };
485
+ return {
486
+ next: { ...current, rows: next },
487
+ result: { ids, outcome: 'reordered' },
488
+ };
489
+ }, { ...(options.signal && { signal: options.signal }) });
490
+ }
381
491
  /**
382
492
  * What a receipt may do to a row's quota: `hold` (the row holds the receipt's
383
493
  * credential and account), `bind` (the row has never had a known account and
@@ -488,6 +598,102 @@ const pendingDiscoveries = new Map();
488
598
  export function forgetPendingVaultDiscovery(path) {
489
599
  pendingDiscoveries.delete(resolve(path));
490
600
  }
601
+ /**
602
+ * Whether `error` from a roster commit at `path` means another writer had the
603
+ * roster at that moment, not that the commit itself failed: the discovery
604
+ * lease was busy before any roster existed, the write lock stayed busy, or a
605
+ * lock this commit held was lost. On Windows the same lock steps can reject
606
+ * with a PrivateFileError instead (a lock file that could not be read or
607
+ * replaced while another process held it), so a PrivateFileError naming one
608
+ * of this roster's lock files counts the same on every OS.
609
+ */
610
+ export function isVaultRosterLockRefusal(path, error) {
611
+ const target = resolve(path);
612
+ if (error instanceof ClaustrumConsumerError)
613
+ return error.kind === 'roster-busy';
614
+ if (error instanceof LockContentionError ||
615
+ error instanceof LockOwnershipError)
616
+ return resolve(error.details.target) === target;
617
+ if (error instanceof PrivateFileError) {
618
+ const failed = resolve(error.path);
619
+ return [WRITE_LOCK.name, DISCOVERY_LEASE.name].some((name) => {
620
+ const lock = lockPathFor(target, name);
621
+ // The lock file itself, its eviction marker and the temporary files
622
+ // its owner record is staged in all start with the lock path.
623
+ return failed === lock || failed.startsWith(`${lock}.`);
624
+ });
625
+ }
626
+ return false;
627
+ }
628
+ const UNLOCKED_READ_ATTEMPTS = 4;
629
+ /**
630
+ * What identifies one version of the file at `path`: the roster is always
631
+ * replaced by a rename, so any write gives it a different inode, and an
632
+ * in-place rewrite a different size or modification time. Undefined when it
633
+ * cannot be inspected, which callers must read as 'may have changed'.
634
+ */
635
+ async function fileStamp(path) {
636
+ try {
637
+ const { dev, ino, size, mtimeMs } = await stat(path);
638
+ return { dev, ino, size, mtimeMs };
639
+ }
640
+ catch {
641
+ return undefined;
642
+ }
643
+ }
644
+ /** Whether two `fileStamp` readings are of the same file version. */
645
+ function sameFileStamp(left, right) {
646
+ return (left !== undefined &&
647
+ right !== undefined &&
648
+ left.dev === right.dev &&
649
+ left.ino === right.ino &&
650
+ left.size === right.size &&
651
+ left.mtimeMs === right.mtimeMs);
652
+ }
653
+ /**
654
+ * Whether a roster list that lands unlocked would write exactly the file at
655
+ * `path`. `overlay` names what this process holds besides the file: `target` is
656
+ * the file's own roster, which the locked commit compares against, and `local`,
657
+ * when different, is that roster plus unwritten quota readings, which must
658
+ * equal the commit's result too. Both must equal `next(target)`'s bytes, which
659
+ * is the write the commit would make; any difference, a missing or unreadable
660
+ * file, or a read that a concurrent reader keeps changing all mean 'commit'.
661
+ */
662
+ async function readUnlockedCommitTarget(path, overlay, next) {
663
+ for (let attempt = 0; attempt < UNLOCKED_READ_ATTEMPTS; attempt++) {
664
+ // Roster files are replaced by rename, so each attempt reads one whole
665
+ // file either way.
666
+ const before = await fileStamp(path);
667
+ const current = await readVaultRoster(path);
668
+ let target;
669
+ let local;
670
+ let changed;
671
+ try {
672
+ ;
673
+ ({ target, local } = overlay(current));
674
+ const written = JSON.stringify(next(target));
675
+ changed =
676
+ JSON.stringify(target) !== written ||
677
+ (local !== undefined && JSON.stringify(local) !== written);
678
+ }
679
+ catch {
680
+ return 'commit';
681
+ }
682
+ // No lock was taken, so another writer may have replaced the file while
683
+ // it was being projected. The answer must describe the file as it is now,
684
+ // otherwise a later list of the same roster would find it changed again.
685
+ if (!sameFileStamp(before, await fileStamp(path)))
686
+ continue;
687
+ if (changed)
688
+ return 'commit';
689
+ // Quota readings this process enqueued while the file was being projected
690
+ // are not part of `current`, and a list that returns unchanged while they
691
+ // wait would be the only chance to serve them as this list's roster. They
692
+ // keep their own commit either way, so this path takes the locked one.
693
+ return hasPendingVaultQuota(path) ? 'commit' : 'unchanged';
694
+ }
695
+ return 'commit';
696
+ }
491
697
  /**
492
698
  * Discover and commit the roster. The lease covers the list call and the
493
699
  * commit, not just the write: an opaque view cannot tell a delayed old reply
@@ -495,7 +701,8 @@ export function forgetPendingVaultDiscovery(path) {
495
701
  * failed list, or custody switching off mid-run, never writes anything; a
496
702
  * peer holding the lease means its last committed roster is served instead.
497
703
  * A write-lock timeout keeps the discovered inventory for the next poll,
498
- * unless a peer has superseded its base view in the meantime.
704
+ * unless a peer has superseded its base view in the meantime. A list that
705
+ * would write the file the roster already holds takes no write lock at all.
499
706
  */
500
707
  export async function refreshVaultRoster(options) {
501
708
  options.signal?.throwIfAborted();
@@ -517,10 +724,11 @@ export async function refreshVaultRoster(options) {
517
724
  return persisted;
518
725
  }
519
726
  const key = resolve(options.path);
727
+ const listedOver = options.listedOver;
520
728
  let discovered;
521
729
  try {
522
730
  const previous = await readVaultRoster(options.path);
523
- const pending = pendingDiscoveries.get(key);
731
+ const pending = listedOver ? undefined : pendingDiscoveries.get(key);
524
732
  // Opaque view cursors cannot be ordered. Reuse a discovery only while no
525
733
  // peer has committed a different view since it was read.
526
734
  discovered =
@@ -534,20 +742,58 @@ export async function refreshVaultRoster(options) {
534
742
  };
535
743
  pendingDiscoveries.delete(key);
536
744
  const { inventory } = discovered;
537
- options.onInventory?.(inventory);
745
+ // Called, not awaited: a caller noting the list here may wait for this
746
+ // roster's write lock, and this refresh decides below whether it needs it.
747
+ void options.onInventory?.(inventory);
538
748
  options.signal?.throwIfAborted();
749
+ const projection = typeof options.projection === 'function'
750
+ ? options.projection()
751
+ : options.projection;
752
+ const next = (current) => projectVaultRoster(current, inventory, projection);
753
+ const isActive = async () => {
754
+ options.signal?.throwIfAborted();
755
+ await lease.assertOwned();
756
+ return active();
757
+ };
758
+ // The write lock costs a lock file, an eviction marker and staging files in
759
+ // the roster's directory on every poll, so it is skipped when the list
760
+ // would write the file that is already there. The read deciding that takes
761
+ // no lock: a writer that commits between the read and this decision only
762
+ // makes our comparison stale, and a stale comparison never writes (the
763
+ // commit below judges the file again under the lock). Only equality with
764
+ // every byte the commit would write skips; every other answer commits.
765
+ const decision = await readUnlockedCommitTarget(options.path, (current) => {
766
+ // A hub member listed over a base view of its file: the file's own view
767
+ // is what the commit compares, so the overlay is the plain file. The
768
+ // locked commit below still applies the base-view rule.
769
+ if (listedOver)
770
+ return { target: current };
771
+ // This process's unwritten quota readings are part of the roster it
772
+ // serves, so a list that matches everything but them still commits.
773
+ const local = pendingVaultQuota(options.path, current);
774
+ return { target: current, ...(local !== current && { local }) };
775
+ }, next);
776
+ if (decision === 'unchanged') {
777
+ if (!(await isActive()))
778
+ return undefined;
779
+ // Returned as a later read of the file: that is what today's locked
780
+ // unchanged commit returns, and memory keeps only what was committed.
781
+ return await readVaultRoster(options.path);
782
+ }
539
783
  return await mutateVaultRoster(options.path, async (current) => {
540
784
  if (!(await active()))
541
785
  return { result: undefined };
542
786
  options.signal?.throwIfAborted();
543
787
  await lease.assertOwned();
544
- const projection = typeof options.projection === 'function'
545
- ? options.projection()
546
- : options.projection;
547
- const next = projectVaultRoster(current, inventory, projection);
548
- const changed = JSON.stringify(current) !== JSON.stringify(next);
549
- return { ...(changed && { next }), result: next };
788
+ if (listedOver &&
789
+ current?.view !== listedOver.view &&
790
+ current?.view !== inventory.view)
791
+ return { result: current };
792
+ const projected = next(current);
793
+ const changed = JSON.stringify(current) !== JSON.stringify(projected);
794
+ return { ...(changed && { next: projected }), result: projected };
550
795
  }, {
796
+ signal: options.signal,
551
797
  beforePublish: async () => {
552
798
  options.signal?.throwIfAborted();
553
799
  await lease.assertOwned();
@@ -557,6 +803,7 @@ export async function refreshVaultRoster(options) {
557
803
  catch (error) {
558
804
  if (error instanceof LockContentionError &&
559
805
  discovered &&
806
+ !listedOver &&
560
807
  !options.signal?.aborted &&
561
808
  (await active()))
562
809
  pendingDiscoveries.set(key, discovered);
@@ -0,0 +1,42 @@
1
+ /** Retired generations report separately until their owner closes and shares one warning. */
2
+ export function shutdownWarning(logger, shutdown) {
3
+ let warned = false;
4
+ return (pendingOperations) => {
5
+ if (warned)
6
+ return;
7
+ if (!shutdown || shutdown.aborted)
8
+ warned = true;
9
+ logger.warn('claustrum shutdown drain timed out', { pendingOperations });
10
+ };
11
+ }
12
+ /**
13
+ * Give cancelled background work two seconds to release its handles without letting
14
+ * an unresponsive connector or disk hold the host's shutdown indefinitely.
15
+ */
16
+ export async function drainShutdown(pending, onTimeout) {
17
+ if (!pending.size)
18
+ return;
19
+ let timer;
20
+ let expired = false;
21
+ const drained = (async () => {
22
+ while (!expired && pending.size)
23
+ await Promise.allSettled([...pending]);
24
+ })();
25
+ try {
26
+ await Promise.race([
27
+ drained,
28
+ new Promise((resolve) => {
29
+ timer = setTimeout(() => {
30
+ expired = true;
31
+ resolve();
32
+ }, 2_000);
33
+ }),
34
+ ]);
35
+ if (expired && pending.size)
36
+ onTimeout(pending.size);
37
+ }
38
+ finally {
39
+ expired = true;
40
+ clearTimeout(timer);
41
+ }
42
+ }
@@ -48,6 +48,43 @@ export interface RoutingSectionOptions {
48
48
  */
49
49
  orderedVariants?: MenuChoice[];
50
50
  formerMainId?: string;
51
+ /**
52
+ * The vault accounts' routing order, for a plugin that can run in vault
53
+ * mode. Called on each build; return the order while the plugin routes
54
+ * through vault accounts and undefined otherwise. While it returns one, the
55
+ * section shows and sets the vault accounts' order (by the name the menu
56
+ * shows for each account) instead of the pool roster's. Wire it to
57
+ * `ClaustrumConsumer.snapshot()` and `ClaustrumConsumer.reorder`, or to
58
+ * `readVaultRoster` and `reorderVaultRoster` for a plugin without a
59
+ * consumer.
60
+ */
61
+ vaultOrder?: () => VaultRoutingOrder | undefined | Promise<VaultRoutingOrder | undefined>;
62
+ }
63
+ /**
64
+ * The order vault accounts are tried in, as the Routing section reads and
65
+ * sets it in vault mode. Shaped after `/claustrum`'s roster, without
66
+ * importing it, so the menu does not need the vault client.
67
+ */
68
+ export interface VaultRoutingOrder {
69
+ /**
70
+ * Every vault roster row, in roster (routing) order: its route id and the
71
+ * name the plugin shows for that account (the roster row's `label`, or its
72
+ * email). Route ids are long and opaque, so the section shows and accepts
73
+ * labels; a route id is still accepted wherever a label is.
74
+ */
75
+ rows: readonly {
76
+ id: string;
77
+ label: string;
78
+ }[];
79
+ /**
80
+ * Sets the order. `ids` are route ids, every one exactly once. Wire it to
81
+ * `ClaustrumConsumer.reorder` or `reorderVaultRoster`, which refuse any
82
+ * other list without writing.
83
+ */
84
+ reorder(ids: readonly string[]): Promise<{
85
+ ids: readonly string[];
86
+ outcome: 'reordered' | 'unchanged';
87
+ }>;
51
88
  }
52
89
  export interface LimitsSectionOptions {
53
90
  /**
@@ -4,7 +4,7 @@
4
4
  // the same locks as any other writer of those files.
5
5
  import { formatQuota, isQuotaMap, projectQuota, quotaWindowName, } from '../quota/index.js';
6
6
  import { DEFAULT_FORMER_MAIN_ID, orderForPlacement, resolveRoutingMode, } from '../routing/index.js';
7
- import { projectFailure, } from './seam.js';
7
+ import { CommandError, projectFailure, } from './seam.js';
8
8
  /** `disabledReason` recorded when the user disables an account from the menu. */
9
9
  export const MENU_DISABLED_REASON = 'disabled from the command menu';
10
10
  function isRecord(value) {
@@ -65,13 +65,16 @@ function addedText(name, result) {
65
65
  function failureMessage(error) {
66
66
  return projectFailure(error).text;
67
67
  }
68
- async function snapshot(store) {
68
+ async function snapshot(options) {
69
+ const { store } = options;
69
70
  const load = await store.read();
70
71
  const read = await store.readSettings();
72
+ const vault = await options.routing?.vaultOrder?.();
71
73
  return {
72
74
  load,
73
75
  rows: distinctRows(load),
74
76
  settings: read.status === 'error' ? {} : read.settings,
77
+ ...(vault ? { vault } : {}),
75
78
  };
76
79
  }
77
80
  function accountView(row, options) {
@@ -323,6 +326,73 @@ function quotaSection(options, snap) {
323
326
  },
324
327
  };
325
328
  }
329
+ /**
330
+ * Sets the vault order. The roster's own refusal of the list (`invalid-order`:
331
+ * an account left out, which it counts without naming) is shown with its own
332
+ * message; any other failure is projected as usual. The kind is read from
333
+ * the error object so the menu does not import the vault client.
334
+ */
335
+ async function reorderVault(vault, order) {
336
+ try {
337
+ return await vault.reorder(order);
338
+ }
339
+ catch (error) {
340
+ if (error instanceof Error &&
341
+ error.name === 'ClaustrumConsumerError' &&
342
+ error.kind === 'invalid-order')
343
+ throw new CommandError('vault-invalid-order', error.message, {
344
+ cause: error,
345
+ });
346
+ throw error;
347
+ }
348
+ }
349
+ function invalidVaultOrder(message) {
350
+ return new CommandError('vault-invalid-order', message);
351
+ }
352
+ /**
353
+ * How the Routing section names vault accounts: by label, since route ids
354
+ * are long and opaque. A typed entry may be a label or a route id, and must
355
+ * name exactly one account. Refusals quote only the entry the user typed.
356
+ */
357
+ function vaultNames(vault) {
358
+ const rows = vault.rows.map((row) => ({
359
+ id: row.id,
360
+ label: row.label.trim() || row.id,
361
+ }));
362
+ const matches = (entry) => rows.filter((row) => row.id === entry || row.label === entry);
363
+ return {
364
+ ids: rows.map((row) => row.id),
365
+ nameOf: (id) => rows.find((row) => row.id === id)?.label ?? id,
366
+ /**
367
+ * What the order input starts with for an account: its label when
368
+ * typing that label back names this account alone, else its route id
369
+ * (two accounts sharing a label, or a label with a comma or line break
370
+ * in it, either of which would split it into two entries).
371
+ */
372
+ entryOf: (id) => {
373
+ const row = rows.find((entry) => entry.id === id);
374
+ return row && !/[,\n]/.test(row.label) && matches(row.label).length === 1
375
+ ? row.label
376
+ : id;
377
+ },
378
+ /** The route ids the typed entries name, in order; refuses before any write. */
379
+ resolve: (entries) => {
380
+ const ids = [];
381
+ for (const entry of entries) {
382
+ const found = matches(entry);
383
+ if (found.length === 0)
384
+ throw invalidVaultOrder(`"${entry}" is not a vault account.`);
385
+ if (found.length > 1)
386
+ throw invalidVaultOrder(`"${entry}" matches more than one vault account; enter those accounts by route id.`);
387
+ const id = found[0].id;
388
+ if (ids.includes(id))
389
+ throw invalidVaultOrder(`"${entry}" names an account already in the order.`);
390
+ ids.push(id);
391
+ }
392
+ return ids;
393
+ },
394
+ };
395
+ }
326
396
  function routingSection(options, snap) {
327
397
  const { store, extraLocks } = options;
328
398
  const locks = extraLocks ? { extraLocks } : {};
@@ -336,14 +406,18 @@ function routingSection(options, snap) {
336
406
  ? raw
337
407
  : 'ordered';
338
408
  const resolved = resolveRoutingMode(current);
339
- const ids = snap.rows.map((row) => row.id);
409
+ // In vault mode routing tries the vault accounts, so their order is the
410
+ // one shown and set here; the pool roster's order is not used.
411
+ const vault = snap.vault ? vaultNames(snap.vault) : undefined;
412
+ const ids = vault ? vault.ids : snap.rows.map((row) => row.id);
413
+ const nameOf = (id) => (vault ? vault.nameOf(id) : id);
340
414
  const tried = orderForPlacement(ids, resolved.placement, options.routing?.formerMainId ?? DEFAULT_FORMER_MAIN_ID);
341
415
  const label = choices.find((choice) => choice.value === current)?.label ?? current;
342
416
  const lines = [`Mode: ${label}`];
343
417
  if (ids.length > 0)
344
- lines.push(`Roster order: ${ids.join(', ')}`);
418
+ lines.push(`${vault ? 'Vault account order' : 'Roster order'}: ${ids.map(nameOf).join(', ')}`);
345
419
  if (resolved.mode === 'ordered' && tried.join() !== ids.join())
346
- lines.push(`Tried in order: ${tried.join(', ')}`);
420
+ lines.push(`Tried in order: ${tried.map(nameOf).join(', ')}`);
347
421
  const actions = [
348
422
  {
349
423
  id: 'mode',
@@ -371,19 +445,27 @@ function routingSection(options, snap) {
371
445
  {
372
446
  kind: 'text',
373
447
  id: 'order',
374
- label: 'Account ids, first to last',
375
- value: ids.join(', '),
448
+ label: vault
449
+ ? 'Vault accounts, first to last, separated by commas'
450
+ : 'Account ids, first to last',
451
+ value: vault ? ids.map(vault.entryOf).join(', ') : ids.join(', '),
376
452
  required: true,
377
453
  },
378
454
  ],
379
455
  run: async ({ values }) => {
380
- const order = String(values.order)
381
- .split(/[\s,]+/)
382
- .filter((id) => id.length > 0);
383
- const result = await store.reorder(order, locks);
456
+ const text = String(values.order);
457
+ // A vault label may contain spaces, so vault entries are separated by
458
+ // commas or line breaks, not spaces; pool ids never contain spaces.
459
+ const entries = text
460
+ .split(vault ? /[,\n]+/ : /[\s,]+/)
461
+ .map((entry) => entry.trim())
462
+ .filter((entry) => entry.length > 0);
463
+ const result = snap.vault && vault
464
+ ? await reorderVault(snap.vault, vault.resolve(entries))
465
+ : await store.reorder(entries, locks);
384
466
  return result.outcome === 'unchanged'
385
467
  ? 'The order is unchanged.'
386
- : `Order set to ${result.ids.join(', ')}.`;
468
+ : `Order set to ${result.ids.map(nameOf).join(', ')}.`;
387
469
  },
388
470
  });
389
471
  return {
@@ -524,7 +606,7 @@ function limitsSection(options, snap) {
524
606
  }
525
607
  /** The four built-in sections, in their fixed order, from one read of the store. */
526
608
  export async function builtinSections(options) {
527
- const snap = await snapshot(options.store);
609
+ const snap = await snapshot(options);
528
610
  return [
529
611
  accountsSection(options, snap),
530
612
  quotaSection(options, snap),
@@ -1,4 +1,4 @@
1
- export type { AccountsSectionOptions, LimitsSectionOptions, LoginOutcome, QuotaSectionOptions, RoutingSectionOptions, } from './builtins.js';
1
+ export type { AccountsSectionOptions, LimitsSectionOptions, LoginOutcome, QuotaSectionOptions, RoutingSectionOptions, VaultRoutingOrder, } from './builtins.js';
2
2
  export { MENU_DISABLED_REASON } from './builtins.js';
3
3
  export type { CommandMenu, CommandMenuOptions, StoreSectionSlot, } from './menu.js';
4
4
  export { createCommandMenu, parseApplyRequest } from './menu.js';
@@ -0,0 +1,57 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ async function identity(path) {
4
+ try {
5
+ const metadata = await lstat(path);
6
+ // Do not follow a symlink: its target can change without changing the link.
7
+ return metadata.isFile() ? metadata : undefined;
8
+ }
9
+ catch (error) {
10
+ return error.code === 'ENOENT'
11
+ ? 'absent'
12
+ : undefined;
13
+ }
14
+ }
15
+ function sameIdentity(left, right) {
16
+ if (left === 'absent' || right === 'absent')
17
+ return left === right;
18
+ return (left.dev === right.dev &&
19
+ left.ino === right.ino &&
20
+ left.size === right.size &&
21
+ left.mtimeMs === right.mtimeMs &&
22
+ left.ctimeMs === right.ctimeMs);
23
+ }
24
+ /** Process-local reads, capped at 64 path sets with one value per resolved set. */
25
+ export class FileReadCache {
26
+ #entries = new Map();
27
+ async read(paths, read, validateHit) {
28
+ const resolvedPaths = paths.map((path) => resolve(path));
29
+ const key = JSON.stringify(resolvedPaths);
30
+ const probed = await Promise.all(resolvedPaths.map(identity));
31
+ const identities = probed.every((entry) => entry !== undefined)
32
+ ? probed
33
+ : undefined;
34
+ const cached = this.#entries.get(key);
35
+ if (identities &&
36
+ cached &&
37
+ identities.every((entry, index) => {
38
+ const previous = cached.identities[index];
39
+ return previous !== undefined && sameIdentity(entry, previous);
40
+ })) {
41
+ validateHit?.(identities);
42
+ return cached.value;
43
+ }
44
+ // A failed probe or read must not leave a previous successful value reusable.
45
+ this.#entries.delete(key);
46
+ const value = await read(resolvedPaths);
47
+ if (identities) {
48
+ if (this.#entries.size >= 64) {
49
+ const oldest = this.#entries.keys().next().value;
50
+ if (oldest !== undefined)
51
+ this.#entries.delete(oldest);
52
+ }
53
+ this.#entries.set(key, { identities, value });
54
+ }
55
+ return value;
56
+ }
57
+ }