@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.
- package/dist/account-selector.d.ts +9 -1
- package/dist/account-selector.d.ts.map +1 -1
- package/dist/account-selector.js +23 -8
- package/dist/account-selector.js.map +1 -1
- package/dist/agy-transport.d.ts.map +1 -1
- package/dist/agy-transport.js +4 -0
- package/dist/agy-transport.js.map +1 -1
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +83 -2
- package/dist/common-auth-embedded/claustrum/consumer.js +337 -37
- package/dist/common-auth-embedded/claustrum/custody.d.ts +36 -1
- package/dist/common-auth-embedded/claustrum/custody.js +95 -11
- package/dist/common-auth-embedded/claustrum/enrollment.js +37 -12
- package/dist/common-auth-embedded/claustrum/errors.d.ts +1 -1
- package/dist/common-auth-embedded/claustrum/hub.d.ts +195 -0
- package/dist/common-auth-embedded/claustrum/hub.js +988 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +13 -3
- package/dist/common-auth-embedded/claustrum/index.js +37 -4
- package/dist/common-auth-embedded/claustrum/quota-writer.d.ts +8 -1
- package/dist/common-auth-embedded/claustrum/quota-writer.js +38 -66
- package/dist/common-auth-embedded/claustrum/roster.d.ts +80 -1
- package/dist/common-auth-embedded/claustrum/roster.js +259 -12
- package/dist/common-auth-embedded/claustrum/shutdown.js +42 -0
- package/dist/common-auth-embedded/commands/builtins.d.ts +37 -0
- package/dist/common-auth-embedded/commands/builtins.js +95 -13
- package/dist/common-auth-embedded/commands/index.d.ts +1 -1
- package/dist/common-auth-embedded/fs/file-read-cache.js +57 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +1 -1
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +20 -11
- package/dist/common-auth-embedded/quota/commit-interval.d.ts +9 -0
- package/dist/common-auth-embedded/quota/commit-interval.js +18 -0
- package/dist/common-auth-embedded/quota/pending.js +48 -0
- package/dist/common-auth-embedded/source-output.json +152 -88
- package/dist/common-auth-embedded/store/attribution.d.ts +5 -0
- package/dist/common-auth-embedded/store/attribution.js +39 -25
- package/dist/common-auth-embedded/store/mutate.d.ts +2 -0
- package/dist/common-auth-embedded/store/mutate.js +4 -0
- package/dist/common-auth-embedded/store/pool.d.ts +22 -0
- package/dist/common-auth-embedded/store/pool.js +18 -3
- package/dist/common-auth-embedded/store/pull.js +3 -1
- package/dist/common-auth-embedded/store/quota-writer.js +312 -0
- package/dist/common-auth-embedded/store/runtime.d.ts +2 -0
- package/dist/common-auth-embedded/store/settings.d.ts +1 -1
- package/dist/common-auth-embedded/store/settings.js +16 -6
- package/dist/model-registry.d.ts +2 -2
- package/dist/model-registry.d.ts.map +1 -1
- package/dist/model-registry.js +6 -4
- package/dist/model-registry.js.map +1 -1
- package/dist/pre-response-deadline.d.ts.map +1 -1
- package/dist/pre-response-deadline.js +6 -5
- package/dist/pre-response-deadline.js.map +1 -1
- package/dist/quota-manager.d.ts +1 -1
- package/dist/quota-manager.d.ts.map +1 -1
- package/dist/quota-manager.js +12 -4
- package/dist/quota-manager.js.map +1 -1
- package/dist/quota-presentation.d.ts.map +1 -1
- package/dist/quota-presentation.js +8 -0
- package/dist/quota-presentation.js.map +1 -1
- package/dist/quota-types.d.ts +1 -1
- package/dist/quota-types.d.ts.map +1 -1
- package/dist/quota-types.js +5 -0
- package/dist/quota-types.js.map +1 -1
- package/dist/vault-account-source.d.ts +6 -1
- package/dist/vault-account-source.d.ts.map +1 -1
- package/dist/vault-account-source.js +9 -0
- package/dist/vault-account-source.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
375
|
-
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
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
|
|
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
|
+
}
|