@cortexkit/antigravity-auth-core 3.0.12 → 3.0.14

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 (55) 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/common-auth-embedded/claustrum/consumer.d.ts +77 -2
  6. package/dist/common-auth-embedded/claustrum/consumer.js +320 -35
  7. package/dist/common-auth-embedded/claustrum/custody.d.ts +36 -1
  8. package/dist/common-auth-embedded/claustrum/custody.js +95 -11
  9. package/dist/common-auth-embedded/claustrum/enrollment.d.ts +9 -0
  10. package/dist/common-auth-embedded/claustrum/enrollment.js +52 -20
  11. package/dist/common-auth-embedded/claustrum/hub.d.ts +195 -0
  12. package/dist/common-auth-embedded/claustrum/hub.js +1032 -0
  13. package/dist/common-auth-embedded/claustrum/index.d.ts +13 -3
  14. package/dist/common-auth-embedded/claustrum/index.js +37 -4
  15. package/dist/common-auth-embedded/claustrum/quota-writer.d.ts +8 -1
  16. package/dist/common-auth-embedded/claustrum/quota-writer.js +34 -12
  17. package/dist/common-auth-embedded/claustrum/roster.d.ts +49 -1
  18. package/dist/common-auth-embedded/claustrum/roster.js +187 -12
  19. package/dist/common-auth-embedded/claustrum/shutdown.js +42 -0
  20. package/dist/common-auth-embedded/fs/file-read-cache.js +57 -0
  21. package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +1 -1
  22. package/dist/common-auth-embedded/fs/refresh-file-lock.js +36 -14
  23. package/dist/common-auth-embedded/quota/commit-interval.d.ts +9 -0
  24. package/dist/common-auth-embedded/quota/commit-interval.js +18 -0
  25. package/dist/common-auth-embedded/source-output.json +118 -70
  26. package/dist/common-auth-embedded/store/mutate.d.ts +2 -0
  27. package/dist/common-auth-embedded/store/mutate.js +4 -0
  28. package/dist/common-auth-embedded/store/pool.d.ts +6 -1
  29. package/dist/common-auth-embedded/store/pool.js +1 -1
  30. package/dist/common-auth-embedded/store/quota-writer.js +27 -13
  31. package/dist/common-auth-embedded/store/settings.d.ts +1 -1
  32. package/dist/common-auth-embedded/store/settings.js +16 -6
  33. package/dist/model-registry.d.ts +2 -2
  34. package/dist/model-registry.d.ts.map +1 -1
  35. package/dist/model-registry.js +6 -4
  36. package/dist/model-registry.js.map +1 -1
  37. package/dist/quota-manager.d.ts +1 -1
  38. package/dist/quota-manager.d.ts.map +1 -1
  39. package/dist/quota-manager.js +12 -4
  40. package/dist/quota-manager.js.map +1 -1
  41. package/dist/quota-presentation.d.ts.map +1 -1
  42. package/dist/quota-presentation.js +8 -0
  43. package/dist/quota-presentation.js.map +1 -1
  44. package/dist/quota-types.d.ts +1 -1
  45. package/dist/quota-types.d.ts.map +1 -1
  46. package/dist/quota-types.js +5 -0
  47. package/dist/quota-types.js.map +1 -1
  48. package/dist/transform/gemini.d.ts.map +1 -1
  49. package/dist/transform/gemini.js +54 -6
  50. package/dist/transform/gemini.js.map +1 -1
  51. package/dist/vault-account-source.d.ts +6 -1
  52. package/dist/vault-account-source.d.ts.map +1 -1
  53. package/dist/vault-account-source.js +9 -0
  54. package/dist/vault-account-source.js.map +1 -1
  55. package/package.json +1 -1
@@ -0,0 +1,1032 @@
1
+ // One vault connection, one watch and one list for every provider a plugin
2
+ // serves on a host. The plugin still runs one ClaustrumConsumer per provider,
3
+ // each with its own roster file; the hub builds those consumers as its
4
+ // members, lists once, commits each member's share of the listing to that
5
+ // member's roster and pushes it into the member's memory.
6
+ //
7
+ // Several processes on one profile (an OpenCode server, Pi, a second
8
+ // OpenCode) each run their own hub and list for themselves. Nothing elects a
9
+ // leader between them: each hub commits every member through the same
10
+ // per-path discovery lease and roster write lock that plain consumers in
11
+ // different processes already share, plus the base-view rule, so a commit
12
+ // never replaces a view another process committed after this hub listed.
13
+ import { createHash } from 'node:crypto';
14
+ import { readdir, stat } from 'node:fs/promises';
15
+ import { dirname, join } from 'node:path';
16
+ import { createLogger } from '../logger/index.js';
17
+ import { combineAbortSignals } from '../util/any-signal.js';
18
+ import { ClaustrumUnreachableError } from './availability.js';
19
+ import { createHubMember, DEFAULT_CONNECT_TIMEOUT_MS, } from './consumer.js';
20
+ import { ClaustrumScopedCustody, } from './custody.js';
21
+ import { ensureClaustrumPrivateDirectory } from './enrollment.js';
22
+ import { ClaustrumConsumerError } from './errors.js';
23
+ import { DEFAULT_ROUTE_PREFIX, decodedVaultRoster, forgetPendingVaultDiscovery, isVaultRosterLockRefusal, readVaultRoster, refreshVaultRoster, vaultRosterSeed, } from './roster.js';
24
+ import { drainShutdown, shutdownWarning } from './shutdown.js';
25
+ import { SAFETY_POLL_MS } from './watch.js';
26
+ /** Lowercase ASCII, safe as a file name on every OS. */
27
+ const FILE_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
28
+ /** Device names Windows reserves in every directory, with any extension. */
29
+ const WINDOWS_DEVICE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/;
30
+ /** A roster key's directory name: the first 16 hex digits of a SHA-256. */
31
+ const FINGERPRINT = /^[0-9a-f]{16}$/;
32
+ /** A failing member is listed for on each of this many ticks before backing off. */
33
+ const PENDING_EVERY_TICK = 3;
34
+ /**
35
+ * `value` as JSON with every object's keys sorted, so equal inputs give equal
36
+ * text in every process. Undefined for anything that is not plain JSON
37
+ * (functions, undefined, non-finite numbers, class instances, cycles).
38
+ */
39
+ function canonicalJson(value, path = new Set()) {
40
+ if (value === null)
41
+ return 'null';
42
+ if (typeof value === 'string' || typeof value === 'boolean')
43
+ return JSON.stringify(value);
44
+ if (typeof value === 'number')
45
+ return Number.isFinite(value) ? JSON.stringify(value) : undefined;
46
+ if (typeof value !== 'object' || path.has(value))
47
+ return undefined;
48
+ const inner = new Set(path).add(value);
49
+ const parts = [];
50
+ if (Array.isArray(value)) {
51
+ for (let index = 0; index < value.length; index++) {
52
+ const part = canonicalJson(value[index], inner);
53
+ if (part === undefined)
54
+ return undefined;
55
+ parts.push(part);
56
+ }
57
+ return `[${parts.join(',')}]`;
58
+ }
59
+ const prototype = Object.getPrototypeOf(value);
60
+ if (prototype !== Object.prototype && prototype !== null)
61
+ return undefined;
62
+ for (const key of Object.keys(value).sort()) {
63
+ const part = canonicalJson(value[key], inner);
64
+ if (part === undefined)
65
+ return undefined;
66
+ parts.push(`${JSON.stringify(key)}:${part}`);
67
+ }
68
+ return `{${parts.join(',')}}`;
69
+ }
70
+ /**
71
+ * The roster key's fingerprint: a hash of everything that decides which rows
72
+ * a member's roster holds and how they are projected, other than the host
73
+ * generation (the directory above it) and the reserved route ids (local pool
74
+ * rows, which change at runtime; see `#collides`).
75
+ */
76
+ function rosterFingerprint(options, members) {
77
+ const input = canonicalJson({
78
+ format: 1,
79
+ classifier: options.classifierInput,
80
+ members: [...members].sort(),
81
+ routePrefix: options.routePrefix ?? DEFAULT_ROUTE_PREFIX,
82
+ // A function cannot be compared across processes; its source text
83
+ // changes with the plugin build that ships it.
84
+ mapAccount: options.mapAccount ? String(options.mapAccount) : null,
85
+ });
86
+ if (input === undefined)
87
+ throw new ClaustrumConsumerError('invalid-state', 'Claustrum hub classifierInput must be plain JSON');
88
+ return createHash('sha256').update(input).digest('hex').slice(0, 16);
89
+ }
90
+ function fileId(value, what) {
91
+ if (typeof value !== 'string' ||
92
+ !FILE_ID.test(value) ||
93
+ WINDOWS_DEVICE.test(value))
94
+ throw new ClaustrumConsumerError('invalid-state', `Claustrum hub ${what} must be a lowercase file-safe id`);
95
+ return value;
96
+ }
97
+ function closed(message = 'Claustrum custody lease is closed') {
98
+ return new ClaustrumConsumerError('closed', message);
99
+ }
100
+ function notActive() {
101
+ return new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
102
+ }
103
+ /** An unreachable vault, raised when a connect passed its time limit. */
104
+ function unreachableConnect() {
105
+ return new ClaustrumUnreachableError();
106
+ }
107
+ /**
108
+ * Follow `pending`, but reject as soon as `signal` aborts. `pending` is
109
+ * still observed afterwards, so its rejection is never unhandled.
110
+ */
111
+ function untilAborted(pending, signal) {
112
+ return new Promise((resolve, reject) => {
113
+ const abort = () => reject(signal.reason);
114
+ signal.addEventListener('abort', abort, { once: true });
115
+ pending.then((value) => {
116
+ signal.removeEventListener('abort', abort);
117
+ resolve(value);
118
+ }, (error) => {
119
+ signal.removeEventListener('abort', abort);
120
+ reject(error);
121
+ });
122
+ if (signal.aborted)
123
+ abort();
124
+ });
125
+ }
126
+ /**
127
+ * Each row's credential id and enabled state, serialized. A change between two
128
+ * rosters means kept receipts must be retained against the new rows.
129
+ */
130
+ function enabledRows(roster) {
131
+ return JSON.stringify((roster?.rows ?? []).map((row) => [row.credentialId, row.enabled]));
132
+ }
133
+ /**
134
+ * One discovery hub per host process: the only vault connection and the only
135
+ * `ClaustrumScopedCustody`, one vault watch and one list per refresh, fanned
136
+ * out to one roster per provider. Members (`member()`) are ordinary
137
+ * `ClaustrumConsumer`s that never connect, list or watch themselves; each
138
+ * holds a custody lease. The connection stays open while vault mode is on,
139
+ * because the hub's own discovery holds a lease of its own, and closes when
140
+ * the last lease of a connection is released, when vault mode goes off, or
141
+ * at `close()`.
142
+ */
143
+ export class ClaustrumDiscoveryHub {
144
+ #options;
145
+ #logger;
146
+ #warnShutdown;
147
+ #shutdown = new AbortController();
148
+ #members = new Map();
149
+ #leases = new WeakMap();
150
+ #generations = 0;
151
+ #current;
152
+ /** The pending connect, with the generation it was started for. */
153
+ #connecting;
154
+ /** The lease the hub's own discovery and watch hold. */
155
+ #own;
156
+ #watch;
157
+ #run;
158
+ /** The hub's last authority read; undefined before the first one. */
159
+ #active;
160
+ /** Counts inactive transitions, so local work started before one stops. */
161
+ #epoch = 0;
162
+ #committed;
163
+ #discoveredAt;
164
+ #timer;
165
+ #started = false;
166
+ #pending = new Set();
167
+ #disposing;
168
+ /** This host generation's roster directory, which holds every key. */
169
+ #generationDirectory;
170
+ /** This hub's roster key: the fingerprint directory under it. */
171
+ #key;
172
+ constructor(options) {
173
+ if (!options.family || !('classify' in options.family))
174
+ throw new ClaustrumConsumerError('wrong-consumer', 'Claustrum discovery hub requires a classifying family');
175
+ const hostGeneration = fileId(options.hostGeneration, 'host generation');
176
+ const providers = options.members.map((member) => fileId(member, 'member'));
177
+ if (new Set(providers).size !== providers.length)
178
+ throw new ClaustrumConsumerError('invalid-state', 'Claustrum hub members must be distinct');
179
+ this.#options = options;
180
+ this.#logger = options.logger ?? createLogger('claustrum');
181
+ this.#warnShutdown = shutdownWarning(this.#logger, this.#shutdown.signal);
182
+ // The roster key: the host generation, then a fingerprint of the
183
+ // classifier's whole input and the projection inputs. Every member keeps
184
+ // the path it is given here for its whole life, and every lock and
185
+ // temporary file of a roster sits beside it, inside the key's directory.
186
+ this.#generationDirectory = join(options.stateDir, 'rosters', hostGeneration);
187
+ this.#key = rosterFingerprint(options, providers);
188
+ const directory = join(this.#generationDirectory, this.#key);
189
+ for (const provider of providers)
190
+ this.#members.set(provider, {
191
+ provider,
192
+ path: join(directory, `${provider}.json`),
193
+ closed: false,
194
+ });
195
+ }
196
+ #track(pending) {
197
+ this.#pending.add(pending);
198
+ void pending.then(() => this.#pending.delete(pending), () => this.#pending.delete(pending));
199
+ return pending;
200
+ }
201
+ #assertOpen() {
202
+ if (this.#shutdown.signal.aborted)
203
+ throw closed('Claustrum discovery hub is closed');
204
+ }
205
+ #entry(provider) {
206
+ const entry = this.#members.get(provider);
207
+ if (!entry)
208
+ throw new ClaustrumConsumerError('wrong-consumer', 'Claustrum hub has no such member');
209
+ return entry;
210
+ }
211
+ /** The roster file member `provider` reads and the hub commits. */
212
+ rosterPath(provider) {
213
+ return this.#entry(provider).path;
214
+ }
215
+ /**
216
+ * Build the consumer for `provider`, once. It is an ordinary
217
+ * `ClaustrumConsumer` whose roster path is `rosterPath(provider)`; it
218
+ * connects, lists and watches only through this hub.
219
+ */
220
+ member(provider, options) {
221
+ this.#assertOpen();
222
+ const entry = this.#entry(provider);
223
+ if (entry.hooks)
224
+ throw new ClaustrumConsumerError('wrong-consumer', 'Claustrum hub member was already built');
225
+ // The type has none of these; refuse them when they arrive through a cast
226
+ // rather than let a member read a path or source the hub does not own.
227
+ for (const owned of [
228
+ 'rosterPath',
229
+ 'tokenPath',
230
+ 'family',
231
+ 'connect',
232
+ 'isCustodyActive',
233
+ 'insula',
234
+ 'pollIntervalMs',
235
+ 'routePrefix',
236
+ 'mapAccount',
237
+ ])
238
+ if (owned in options)
239
+ throw new ClaustrumConsumerError('wrong-consumer', `Claustrum hub members cannot take ${owned}`);
240
+ const port = {
241
+ provider: entry.provider,
242
+ start: () => this.start(),
243
+ refresh: () => this.#refresh(),
244
+ readAuthority: () => this.#readAuthority(),
245
+ acquire: (signal) => this.acquire(signal),
246
+ leaseLive: (lease) => this.#leaseLive(lease),
247
+ grantReach: (view) => view !== undefined && this.#committed?.view === view
248
+ ? this.#committed.reach
249
+ : undefined,
250
+ watchMode: () => this.watchState(),
251
+ keptReceiptState: () => this.keptReceiptState(),
252
+ retainUnion: () => this.#retainUnion(),
253
+ forget: (credentialId) => this.#current?.custody?.forget(credentialId),
254
+ detach: () => {
255
+ entry.closed = true;
256
+ },
257
+ };
258
+ const consumerOptions = {
259
+ rosterPath: entry.path,
260
+ ...(options.reservedRouteIds && {
261
+ reservedRouteIds: options.reservedRouteIds,
262
+ }),
263
+ ...(this.#options.routePrefix !== undefined && {
264
+ routePrefix: this.#options.routePrefix,
265
+ }),
266
+ ...(this.#options.mapAccount && { mapAccount: this.#options.mapAccount }),
267
+ ...(options.onRoster && { onRoster: options.onRoster }),
268
+ ...(options.onError && { onError: options.onError }),
269
+ logger: options.logger ?? this.#logger,
270
+ ...(this.#options.now && { now: this.#options.now }),
271
+ };
272
+ const { consumer, hooks } = createHubMember(port, consumerOptions);
273
+ entry.hooks = hooks;
274
+ return consumer;
275
+ }
276
+ // --- leases ---
277
+ #startGeneration() {
278
+ const generation = {
279
+ id: ++this.#generations,
280
+ count: 0,
281
+ ended: false,
282
+ controller: new AbortController(),
283
+ };
284
+ this.#current = generation;
285
+ return generation;
286
+ }
287
+ #live(generation) {
288
+ return (!generation.ended &&
289
+ this.#current === generation &&
290
+ !this.#shutdown.signal.aborted);
291
+ }
292
+ #leaseLive(lease) {
293
+ const state = this.#leases.get(lease);
294
+ return !!state && !state.released && !state.generation.ended;
295
+ }
296
+ /**
297
+ * End a generation: its signal aborts, every lease of it is revoked, its
298
+ * watch stops and its custody closes (with every kept receipt and the
299
+ * client). A connect still pending closes its own client when it resolves.
300
+ */
301
+ #end(generation, reason = closed()) {
302
+ if (generation.ended)
303
+ return;
304
+ generation.ended = true;
305
+ generation.count = 0;
306
+ if (this.#current === generation)
307
+ this.#current = undefined;
308
+ if (this.#own && this.#leases.get(this.#own)?.generation === generation)
309
+ this.#own = undefined;
310
+ if (this.#watch?.generation === generation) {
311
+ this.#watch.watch.close();
312
+ this.#watch = undefined;
313
+ }
314
+ generation.controller.abort(reason);
315
+ if (generation.custody) {
316
+ generation.custody.close();
317
+ this.#track(generation.custody.dispose(this.#warnShutdown));
318
+ }
319
+ }
320
+ /** Return one waiting acquirer's count to a generation that still runs. */
321
+ #giveBack(generation) {
322
+ if (generation.ended)
323
+ return;
324
+ generation.count--;
325
+ if (generation.count <= 0)
326
+ this.#end(generation);
327
+ }
328
+ #connect(generation) {
329
+ const pending = this.#connecting;
330
+ if (pending?.generation === generation && pending.promise)
331
+ return pending.promise;
332
+ const entry = { generation, timedOut: false };
333
+ // Every acquirer waits on this one connect, so it is bounded: a connect
334
+ // that never settles would otherwise hold them all for the hub's life.
335
+ const connectTimeoutMs = this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
336
+ let timer;
337
+ const connect = this.#options.connect().then((client) => {
338
+ // A connect resolves into its generation only while that generation
339
+ // is current and still wanted; otherwise its client never serves.
340
+ if (entry.timedOut) {
341
+ client.close();
342
+ throw unreachableConnect();
343
+ }
344
+ if (!this.#live(generation) || generation.count <= 0) {
345
+ client.close();
346
+ throw closed('Claustrum connection is no longer wanted');
347
+ }
348
+ generation.custody = new ClaustrumScopedCustody({
349
+ client,
350
+ family: this.#options.family,
351
+ tokenPath: this.#options.tokenPath,
352
+ parseIdentity: this.#options.parseIdentity,
353
+ requireAssertion: this.#options.requireAssertion,
354
+ now: this.#options.now,
355
+ logger: this.#logger,
356
+ setTimeoutImpl: this.#options.setTimeoutImpl,
357
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
358
+ });
359
+ return generation.custody;
360
+ });
361
+ const deadline = new Promise((_resolve, reject) => {
362
+ timer = (this.#options.setTimeoutImpl ?? setTimeout)(() => {
363
+ entry.timedOut = true;
364
+ if (!this.#live(generation))
365
+ return;
366
+ this.#logger.warn('vault connect timed out', {
367
+ reason: 'unreachable',
368
+ connectTimeoutMs,
369
+ });
370
+ reject(unreachableConnect());
371
+ }, connectTimeoutMs);
372
+ timer.unref?.();
373
+ });
374
+ entry.promise = this.#track(Promise.race([connect, deadline]).finally(() => {
375
+ if (timer)
376
+ (this.#options.clearTimeoutImpl ?? clearTimeout)(timer);
377
+ }));
378
+ // Clear only this connect: an older generation's connect settling late
379
+ // must leave a newer generation's pending connect in place.
380
+ const settled = entry.promise.finally(() => {
381
+ if (this.#connecting === entry)
382
+ this.#connecting = undefined;
383
+ });
384
+ settled.catch(() => { });
385
+ this.#connecting = entry;
386
+ return entry.promise;
387
+ }
388
+ /**
389
+ * Take a lease on the current connection, connecting first when there is
390
+ * none. The acquirer's count is taken before anything is awaited, so a
391
+ * release elsewhere meanwhile cannot end the connection under it. An abort
392
+ * or a failed connect gives that count back; an acquirer whose connection
393
+ * ended while it waited tries once more on the current one. Rejects with
394
+ * `not-active` while vault mode is off.
395
+ */
396
+ acquire(signal) {
397
+ try {
398
+ this.#assertOpen();
399
+ signal?.throwIfAborted();
400
+ if (this.#active === false)
401
+ throw notActive();
402
+ }
403
+ catch (error) {
404
+ return Promise.reject(error);
405
+ }
406
+ const generation = this.#current ?? this.#startGeneration();
407
+ generation.count++;
408
+ return this.#settle(generation, signal, true);
409
+ }
410
+ async #settle(generation, signal, retry) {
411
+ let custody = generation.custody;
412
+ if (!custody) {
413
+ const combined = combineAbortSignals([
414
+ generation.controller.signal,
415
+ ...(signal ? [signal] : []),
416
+ ]);
417
+ try {
418
+ custody = await untilAborted(this.#connect(generation), combined.signal);
419
+ }
420
+ catch (error) {
421
+ if (!generation.ended) {
422
+ // Aborted, or the connect failed: this acquirer holds nothing.
423
+ this.#giveBack(generation);
424
+ throw error;
425
+ }
426
+ return this.#again(signal, retry);
427
+ }
428
+ finally {
429
+ combined.dispose();
430
+ }
431
+ }
432
+ if (generation.ended)
433
+ return this.#again(signal, retry);
434
+ if (signal?.aborted) {
435
+ this.#giveBack(generation);
436
+ throw signal.reason;
437
+ }
438
+ return this.#lease(generation, custody);
439
+ }
440
+ /** An acquirer whose generation ended while it waited. */
441
+ #again(signal, retry) {
442
+ signal?.throwIfAborted();
443
+ this.#assertOpen();
444
+ if (this.#active === false)
445
+ throw notActive();
446
+ if (!retry)
447
+ throw closed();
448
+ const generation = this.#current ?? this.#startGeneration();
449
+ generation.count++;
450
+ return this.#settle(generation, signal, false);
451
+ }
452
+ #lease(generation, custody) {
453
+ const state = { generation, custody, released: false };
454
+ const check = () => {
455
+ if (state.released || generation.ended)
456
+ throw closed();
457
+ };
458
+ const view = Object.freeze({
459
+ authorize: async (identity, signal, routeId) => {
460
+ check();
461
+ // Work in flight stops when the generation ends.
462
+ const combined = combineAbortSignals([
463
+ generation.controller.signal,
464
+ ...(signal ? [signal] : []),
465
+ ]);
466
+ try {
467
+ return await custody.authorize(identity, combined.signal, routeId);
468
+ }
469
+ finally {
470
+ combined.dispose();
471
+ }
472
+ },
473
+ reportFailure: async (attempt, status, source) => {
474
+ check();
475
+ return custody.reportFailure(attempt, status, source);
476
+ },
477
+ forget: (credentialId) => {
478
+ check();
479
+ custody.forget(credentialId);
480
+ },
481
+ keptReceiptState: () => {
482
+ check();
483
+ return custody.keptReceiptState();
484
+ },
485
+ });
486
+ const lease = Object.freeze({
487
+ custody: view,
488
+ generation: generation.id,
489
+ release: () => {
490
+ if (state.released)
491
+ return;
492
+ state.released = true;
493
+ this.#giveBack(generation);
494
+ },
495
+ });
496
+ this.#leases.set(lease, state);
497
+ return lease;
498
+ }
499
+ /** The current connection and its lease count, for status and tests. */
500
+ leaseState() {
501
+ const current = this.#current;
502
+ return Object.freeze({
503
+ generation: current?.id,
504
+ count: current?.count ?? 0,
505
+ });
506
+ }
507
+ // --- authority ---
508
+ /**
509
+ * Read authority. The first false read after a true one (or before any)
510
+ * runs the inactive transition; nothing else does.
511
+ */
512
+ async #readAuthority() {
513
+ const active = (await this.#options.isCustodyActive?.()) ?? true;
514
+ if (this.#shutdown.signal.aborted)
515
+ return false;
516
+ const was = this.#active;
517
+ this.#active = active;
518
+ if (!active && was !== false)
519
+ this.#deactivate();
520
+ return active;
521
+ }
522
+ /** Vault mode went off. Kept receipts never survive into local mode. */
523
+ #deactivate() {
524
+ this.#epoch++;
525
+ const current = this.#current;
526
+ if (current)
527
+ this.#end(current, notActive());
528
+ this.#own = undefined;
529
+ this.#watch?.watch.close();
530
+ this.#watch = undefined;
531
+ this.#committed = undefined;
532
+ this.#discoveredAt = undefined;
533
+ for (const entry of this.#members.values()) {
534
+ entry.seen = undefined;
535
+ entry.pending = undefined;
536
+ entry.hooks?.reset();
537
+ forgetPendingVaultDiscovery(entry.path);
538
+ }
539
+ }
540
+ // --- discovery ---
541
+ /** Members built and not closed, in the order the hub was given them. */
542
+ #open() {
543
+ return [...this.#members.values()].filter((entry) => entry.hooks && !entry.closed);
544
+ }
545
+ /** The hub's own lease, taken in the current generation when needed. */
546
+ async #ownLease() {
547
+ const held = this.#own;
548
+ const heldState = held && this.#leases.get(held);
549
+ if (held && heldState && this.#leaseLive(held))
550
+ return heldState;
551
+ this.#own = undefined;
552
+ const lease = await this.acquire(this.#shutdown.signal);
553
+ const raced = this.#own && this.#leases.get(this.#own);
554
+ if (this.#own && raced && this.#leaseLive(this.#own)) {
555
+ lease.release();
556
+ return raced;
557
+ }
558
+ this.#own = lease;
559
+ const state = this.#leases.get(lease);
560
+ if (!state)
561
+ throw closed();
562
+ return state;
563
+ }
564
+ /**
565
+ * One coalesced refresh. A run whose generation ended is never joined: a
566
+ * refresh after vault mode went off and on again starts a new one.
567
+ */
568
+ #refresh() {
569
+ try {
570
+ this.#assertOpen();
571
+ }
572
+ catch (error) {
573
+ return Promise.reject(error);
574
+ }
575
+ const running = this.#run;
576
+ if (running && !running.generation?.ended)
577
+ return running.promise;
578
+ const run = {};
579
+ const promise = this.#track(this.#discover(run).finally(() => {
580
+ if (this.#run === run)
581
+ this.#run = undefined;
582
+ }));
583
+ run.promise = promise;
584
+ this.#run = run;
585
+ return promise;
586
+ }
587
+ /**
588
+ * Read each member's base view, list once, then commit each member's share
589
+ * of the listing to its roster (all members at once, each under its own
590
+ * roster's locks), push it into the member, and retain the union. Each
591
+ * publication step checks first that the generation it started in is
592
+ * still current. A member that this list did not commit is marked pending,
593
+ * so a later tick lists again.
594
+ */
595
+ async #discover(run) {
596
+ if (!(await this.#readAuthority()))
597
+ return undefined;
598
+ const own = await this.#ownLease();
599
+ const generation = own.generation;
600
+ run.generation = generation;
601
+ if (!this.#live(generation))
602
+ return undefined;
603
+ // Folders that could not be made owner-only fail every member's commit,
604
+ // as an unreadable base fails its member's: each is marked pending.
605
+ const folders = await this.#ensureRosterDirectories().then(() => undefined, (error) => error instanceof Error ? error : new Error(String(error)));
606
+ // What each member's file held before the list. Another process may
607
+ // commit while this list runs; the base-view rule then keeps its newer
608
+ // roster rather than replacing it with this older listing. Each file is
609
+ // read on its own, so the reads run together.
610
+ const bases = new Map(await Promise.all([...this.#members.values()].map(async (entry) => [
611
+ entry,
612
+ await readVaultRoster(entry.path).then((file) => (file ? { file } : {}), (error) => error instanceof Error ? error : new Error(String(error))),
613
+ ])));
614
+ if (!this.#live(generation))
615
+ return undefined;
616
+ let inventory;
617
+ try {
618
+ inventory = await own.custody.discover(generation.controller.signal);
619
+ }
620
+ catch (error) {
621
+ if (!this.#live(generation))
622
+ return undefined;
623
+ // Every pending member missed this list too.
624
+ for (const entry of this.#members.values())
625
+ if (entry.pending)
626
+ this.#markPending(entry);
627
+ throw error;
628
+ }
629
+ if (!this.#live(generation))
630
+ return undefined;
631
+ // Every member's commit starts at once and none waits for another: each
632
+ // touches only its own roster file and takes only that file's locks (its
633
+ // discovery lease, then its write lock, as `refreshVaultRoster` always
634
+ // does), so no commit ever holds two members' locks and no order between
635
+ // members is needed. The results are then published in member order.
636
+ const members = this.#open();
637
+ const commits = await Promise.all(members.map((entry) => entry.hooks && !entry.closed
638
+ ? this.#commit(entry, entry.hooks, inventory, folders ?? bases.get(entry), generation)
639
+ : undefined));
640
+ if (!this.#live(generation))
641
+ return undefined;
642
+ const errors = new Map();
643
+ let complete = true;
644
+ for (const [index, entry] of members.entries()) {
645
+ const hooks = entry.hooks;
646
+ const commit = commits[index];
647
+ if (!hooks || !commit)
648
+ continue;
649
+ if ('error' in commit) {
650
+ const { error } = commit;
651
+ complete = false;
652
+ this.#markPending(entry);
653
+ // Another writer holding or taking this roster's locks is not this
654
+ // member's failure: the member keeps what it has and is retried.
655
+ if (isVaultRosterLockRefusal(entry.path, error))
656
+ this.#logger.debug('claustrum hub member commit refused', {
657
+ member: entry.provider,
658
+ });
659
+ else
660
+ errors.set(entry.provider, error);
661
+ continue;
662
+ }
663
+ const { roster } = commit;
664
+ if (!this.#live(generation))
665
+ return undefined;
666
+ // Not this list's roster: vault mode went off, the file was removed
667
+ // since the list, or another process holds or committed it (a peer's
668
+ // newer view, or a peer that stopped mid-commit holding the lease).
669
+ if (roster?.view !== inventory.view) {
670
+ complete = false;
671
+ this.#markPending(entry);
672
+ }
673
+ else
674
+ entry.pending = undefined;
675
+ if (!roster)
676
+ continue;
677
+ if (this.#collides(hooks, roster)) {
678
+ // Another process projected a route id that is a local row here.
679
+ complete = false;
680
+ this.#markPending(entry);
681
+ continue;
682
+ }
683
+ // The push: memory holds the committed roster before refresh resolves.
684
+ entry.seen = JSON.stringify(decodedVaultRoster(roster));
685
+ if (!entry.closed)
686
+ hooks.install(roster);
687
+ }
688
+ if (!this.#live(generation))
689
+ return undefined;
690
+ this.#retainUnion();
691
+ // The committed view, and the refusals and grant reach tied to it, move
692
+ // only when every member committed this listing.
693
+ if (complete)
694
+ this.#committed = Object.freeze({
695
+ view: inventory.view,
696
+ ...(inventory.grantReach && { reach: inventory.grantReach }),
697
+ refused: Object.freeze([...(inventory.refused ?? [])]),
698
+ notMine: inventory.notMine ?? 0,
699
+ });
700
+ this.#discoveredAt = (this.#options.now ?? Date.now)();
701
+ return { errors };
702
+ }
703
+ /**
704
+ * Commit one member's share of `inventory` to its roster file over the
705
+ * base view read before the list. Every failure is returned, not thrown,
706
+ * so one member's failure leaves the other members' commits running.
707
+ */
708
+ async #commit(entry, hooks, inventory, base, generation) {
709
+ const share = {
710
+ ...inventory,
711
+ credentials: inventory.credentials.filter((credential) => credential.member === entry.provider),
712
+ skipped: inventory.skipped.filter((record) => record.member === entry.provider),
713
+ };
714
+ try {
715
+ if (!base)
716
+ throw closed('Claustrum hub member has no base view');
717
+ if (base instanceof Error)
718
+ throw base;
719
+ // A member's first roster under this key carries the declines and
720
+ // order of its newest roster under an older key.
721
+ const seed = base.file ? undefined : await this.#seed(entry);
722
+ const roster = await refreshVaultRoster({
723
+ path: entry.path,
724
+ custody: { discover: async () => share },
725
+ // Generation g's commit checks that g is still current, not only
726
+ // that vault mode is on, so off-then-on cannot let it into g+1.
727
+ isActive: () => this.#live(generation) && this.#active === true,
728
+ signal: generation.controller.signal,
729
+ projection: () => ({ ...hooks.projection(), ...(seed && { seed }) }),
730
+ listedOver: { view: base.file?.view },
731
+ });
732
+ return { roster };
733
+ }
734
+ catch (error) {
735
+ return { error };
736
+ }
737
+ }
738
+ /**
739
+ * Create the roster folders under the state folder (`rosters`, this host
740
+ * generation and this key) owner-only, as every other Claustrum private
741
+ * folder is: 0700 on POSIX, tightening a folder an earlier release left
742
+ * open, and private-files on Windows. Each is handled on its own so an
743
+ * existing open parent is tightened too. Without this, the first roster
744
+ * lock would create them with the process umask, usually 0755.
745
+ */
746
+ async #ensureRosterDirectories() {
747
+ for (const directory of [
748
+ dirname(this.#generationDirectory),
749
+ this.#generationDirectory,
750
+ join(this.#generationDirectory, this.#key),
751
+ ])
752
+ await ensureClaustrumPrivateDirectory(directory, {}, 'roster directory');
753
+ }
754
+ /**
755
+ * The preferences a member's first roster under this key starts from: the
756
+ * declines and order of the newest roster of the same host generation and
757
+ * member under any other key, the layout without a fingerprint included.
758
+ * Those files are only read. Undefined when there is none.
759
+ */
760
+ async #seed(entry) {
761
+ const name = `${entry.provider}.json`;
762
+ const candidates = [join(this.#generationDirectory, name)];
763
+ try {
764
+ for (const key of await readdir(this.#generationDirectory))
765
+ if (key !== this.#key && FINGERPRINT.test(key))
766
+ candidates.push(join(this.#generationDirectory, key, name));
767
+ }
768
+ catch {
769
+ // No directory for this host generation yet: nothing to seed from.
770
+ }
771
+ let newest;
772
+ for (const path of candidates) {
773
+ try {
774
+ const modified = (await stat(path)).mtimeMs;
775
+ const roster = await readVaultRoster(path);
776
+ if (roster && (!newest || modified > newest.modified))
777
+ newest = { modified, roster };
778
+ }
779
+ catch {
780
+ // A missing or unreadable older roster seeds nothing.
781
+ }
782
+ }
783
+ return newest && vaultRosterSeed(newest.roster);
784
+ }
785
+ /**
786
+ * Whether `roster` routes through an id this member's plugin uses for a
787
+ * local row. Only a roster another process projected can: this process's
788
+ * own projection gives such a route a new id. Rather than serve it, the
789
+ * hub lists, and its own projection renames that route.
790
+ */
791
+ #collides(hooks, roster) {
792
+ const reserved = hooks.projection().reservedRouteIds;
793
+ return (!!reserved?.size && roster.rows.some((row) => reserved.has(row.routeId)));
794
+ }
795
+ /**
796
+ * This member missed a list: due again on each of the next three ticks,
797
+ * then after 2, 4, 8 ... ticks, up to the five-minute safety poll, so one
798
+ * broken member file never keeps the hub listing every tick.
799
+ */
800
+ #markPending(entry) {
801
+ const failures = (entry.pending?.failures ?? 0) + 1;
802
+ const tick = Math.max(1, this.#options.pollIntervalMs ?? 5_000);
803
+ const longest = Math.max(1, Math.ceil(SAFETY_POLL_MS / tick));
804
+ const interval = failures <= PENDING_EVERY_TICK
805
+ ? 1
806
+ : Math.min(2 ** (failures - PENDING_EVERY_TICK), longest);
807
+ entry.pending = { failures, wait: interval - 1 };
808
+ }
809
+ /** Retain kept receipts against every open member's roster in memory. */
810
+ #retainUnion() {
811
+ const custody = this.#current?.custody;
812
+ if (!custody)
813
+ return;
814
+ custody.retain(this.#open().flatMap((entry) => entry.hooks?.roster()?.rows ?? []));
815
+ }
816
+ /**
817
+ * Take member files another writer changed. The same view as in memory
818
+ * means a decline, accept, reorder or quota reading, which moves no view:
819
+ * it is installed, as a plain consumer's `authorize` and `decline` read
820
+ * such a file back (`consumer.ts`, `authorize` and `decline`). A different
821
+ * view, or a member with no roster in memory, means another process
822
+ * committed a listing: the member is marked pending and the next tick
823
+ * lists, then commits over that file or takes it, as a plain consumer's
824
+ * refresh does (`refreshVaultRoster`, which serves a peer's roster while
825
+ * the peer holds the discovery lease). Local reads only.
826
+ */
827
+ async #install() {
828
+ const epoch = this.#epoch;
829
+ for (const entry of this.#open()) {
830
+ let file;
831
+ try {
832
+ file = await readVaultRoster(entry.path);
833
+ }
834
+ catch (error) {
835
+ entry.hooks?.onError(error);
836
+ continue;
837
+ }
838
+ if (epoch !== this.#epoch || this.#active !== true)
839
+ return;
840
+ const hooks = entry.hooks;
841
+ if (!file || !hooks || entry.closed)
842
+ continue;
843
+ const content = JSON.stringify(file);
844
+ if (content === entry.seen)
845
+ continue;
846
+ const memory = hooks.roster();
847
+ if (!memory || memory.view !== file.view || this.#collides(hooks, file)) {
848
+ entry.pending ??= { failures: 0, wait: 0 };
849
+ continue;
850
+ }
851
+ const before = enabledRows(memory);
852
+ entry.seen = content;
853
+ hooks.install(file);
854
+ if (enabledRows(hooks.roster()) !== before)
855
+ this.#retainUnion();
856
+ }
857
+ }
858
+ /**
859
+ * Whether a tick must list now: the consumer's rule, once for every
860
+ * member, and also when a pending member's wait is over. Called once per
861
+ * tick, which is what counts a pending member's wait down.
862
+ */
863
+ #listDue() {
864
+ let pending = false;
865
+ for (const entry of this.#members.values()) {
866
+ if (!entry.pending)
867
+ continue;
868
+ if (entry.pending.wait <= 0)
869
+ pending = true;
870
+ else
871
+ entry.pending.wait--;
872
+ }
873
+ if (this.#watch?.watch.mode !== 'watching' || pending)
874
+ return true;
875
+ const now = (this.#options.now ?? Date.now)();
876
+ return (this.#discoveredAt === undefined ||
877
+ now - this.#discoveredAt >= SAFETY_POLL_MS);
878
+ }
879
+ /** Open the one vault watch on the hub's own lease, once per generation. */
880
+ #openWatch() {
881
+ const own = this.#own;
882
+ const state = own && this.#leases.get(own);
883
+ if (!own || !state || !this.#leaseLive(own) || this.#active !== true)
884
+ return;
885
+ const generation = state.generation;
886
+ if (!this.#live(generation) || this.#watch?.generation === generation)
887
+ return;
888
+ const watch = state.custody.watchRoster({
889
+ view: () => this.#committed?.view,
890
+ onView: () => this.#track((async () => {
891
+ // A refresh already in flight may have listed before the change was
892
+ // announced, so wait for it and list again.
893
+ while (this.#run)
894
+ await this.#run.promise.catch(() => { });
895
+ if (!this.#live(generation))
896
+ return;
897
+ await this.#list();
898
+ })()),
899
+ // The vault refused the watch: custody has dropped every kept receipt,
900
+ // which every member served from, as a refused list would.
901
+ onRefused: (error) => this.#everyone(error),
902
+ // The watch alone failed; listing goes on and every member is served.
903
+ onError: (error) => this.#error(error),
904
+ });
905
+ this.#watch = { generation, watch };
906
+ }
907
+ #error(error) {
908
+ if (!this.#shutdown.signal.aborted)
909
+ this.#options.onError?.(error);
910
+ }
911
+ /** A failure of every member's discovery: the hub's and each member's. */
912
+ #everyone(error) {
913
+ if (this.#shutdown.signal.aborted)
914
+ return;
915
+ this.#options.onError?.(error);
916
+ for (const entry of this.#open())
917
+ entry.hooks?.onError(error);
918
+ }
919
+ /** Each member's own commit error goes to that member's `onError` only. */
920
+ #publish(outcome) {
921
+ for (const [provider, error] of outcome?.errors ?? [])
922
+ this.#members.get(provider)?.hooks?.onError(error);
923
+ }
924
+ /** One list the hub itself decided on, with its errors published. */
925
+ async #list() {
926
+ let outcome;
927
+ try {
928
+ outcome = await this.#refresh();
929
+ }
930
+ catch (error) {
931
+ this.#everyone(error);
932
+ return;
933
+ }
934
+ this.#publish(outcome);
935
+ }
936
+ async #tick() {
937
+ try {
938
+ if (await this.#readAuthority()) {
939
+ await this.#install();
940
+ if (this.#listDue())
941
+ await this.#list();
942
+ this.#openWatch();
943
+ }
944
+ }
945
+ catch (error) {
946
+ this.#error(error);
947
+ }
948
+ if (this.#shutdown.signal.aborted)
949
+ return;
950
+ const delay = this.#options.pollIntervalMs ?? 5_000;
951
+ if (delay <= 0)
952
+ return;
953
+ this.#timer = (this.#options.setTimeoutImpl ?? setTimeout)(() => {
954
+ this.#timer = undefined;
955
+ void this.#track(this.#tick());
956
+ }, delay);
957
+ this.#timer.unref?.();
958
+ }
959
+ /**
960
+ * Start the hub's tick, which runs until `close()`. While vault mode is
961
+ * off a tick only reads authority and makes no vault request.
962
+ */
963
+ start() {
964
+ if (this.#started)
965
+ return;
966
+ this.#assertOpen();
967
+ this.#started = true;
968
+ void this.#track(this.#tick());
969
+ }
970
+ /**
971
+ * List once and commit every member, as a tick does. Resolves once every
972
+ * member's memory holds its new roster. Rejects when the list fails; a
973
+ * member's own commit error goes to that member's `onError`.
974
+ */
975
+ async refresh() {
976
+ this.#publish(await this.#refresh());
977
+ }
978
+ // --- accessors, without I/O ---
979
+ /**
980
+ * Rows the last discovery every member committed refused, with their fixed
981
+ * codes, and how many rows it left to other plugins. Undefined while
982
+ * unknown: before a complete discovery, and while vault mode is off.
983
+ */
984
+ refusals() {
985
+ const committed = this.#committed;
986
+ return (committed &&
987
+ Object.freeze({
988
+ view: committed.view,
989
+ refused: committed.refused,
990
+ notMine: committed.notMine,
991
+ }));
992
+ }
993
+ /** The grant reach of the same discovery as `refusals()`. */
994
+ grantReach() {
995
+ return this.#committed?.reach;
996
+ }
997
+ /** How the hub learns about changes; `polling` until a watch is open. */
998
+ watchState() {
999
+ const open = this.#watch;
1000
+ return open && this.#live(open.generation) ? open.watch.mode : 'polling';
1001
+ }
1002
+ /** The shared custody's kept receipts, host-wide. */
1003
+ keptReceiptState() {
1004
+ return (this.#current?.custody?.keptReceiptState() ??
1005
+ Object.freeze({ active: false, since: null }));
1006
+ }
1007
+ /**
1008
+ * Stop the tick and the watch and close the connection. Members are not
1009
+ * closed; they fail `closed` from then on. Use `dispose()` to also allow
1010
+ * in-flight discovery up to two seconds to release its file handles.
1011
+ */
1012
+ close() {
1013
+ if (this.#shutdown.signal.aborted)
1014
+ return;
1015
+ this.#shutdown.abort(closed('Claustrum discovery hub is closed'));
1016
+ if (this.#timer)
1017
+ (this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
1018
+ this.#timer = undefined;
1019
+ const current = this.#current;
1020
+ if (current)
1021
+ this.#end(current);
1022
+ this.#own = undefined;
1023
+ this.#watch?.watch.close();
1024
+ this.#watch = undefined;
1025
+ }
1026
+ /** Close immediately, then join background work for at most two seconds. */
1027
+ async dispose() {
1028
+ this.close();
1029
+ this.#disposing ??= drainShutdown(this.#pending, this.#warnShutdown);
1030
+ await this.#disposing;
1031
+ }
1032
+ }