@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,39 @@
1
1
  import { createLogger } from '../logger/index.js';
2
2
  import { combineAbortSignals } from '../util/any-signal.js';
3
+ import { ClaustrumUnreachableError } from './availability.js';
3
4
  import { ClaustrumScopedCustody, decideScopedRetryAfter401, } from './custody.js';
4
5
  import { readClaustrumEnrollmentToken } from './enrollment.js';
5
6
  import { ClaustrumConsumerError } from './errors.js';
6
7
  import { acquireInsulaQuotaSource, } from './insula.js';
7
8
  import { pendingVaultQuota, retainVaultQuotaWriter } from './quota-writer.js';
8
- import { acceptVaultRoute, applyVaultQuota, declineVaultRoute, forgetPendingVaultDiscovery, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from './roster.js';
9
+ import { acceptVaultRoute, applyVaultQuota, declineVaultRoute, forgetPendingVaultDiscovery, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, vaultRoutingRows, } from './roster.js';
10
+ import { drainShutdown, shutdownWarning } from './shutdown.js';
9
11
  import { SAFETY_POLL_MS } from './watch.js';
12
+ /**
13
+ * How long one connect attempt may take by default. It bounds a connect that
14
+ * never settles; the client's own handshake limit is not guaranteed to cover
15
+ * every stall, and callers waiting on discovery need an answer either way.
16
+ */
17
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
18
+ /** An unreachable vault, raised when a connect passed its time limit. */
19
+ function unreachableConnect() {
20
+ return new ClaustrumUnreachableError();
21
+ }
22
+ let buildHubMember;
23
+ /**
24
+ * Build the consumer for one hub member. `options` holds only what the member
25
+ * uses: the hub's roster path for this member and the member's own fields.
26
+ * Hub-mode paths never read `tokenPath`, `family` or `connect`, which the hub
27
+ * owns. Package-internal: only `ClaustrumDiscoveryHub.member` calls it.
28
+ */
29
+ export function createHubMember(port, options) {
30
+ if (!buildHubMember)
31
+ throw new Error('ClaustrumConsumer is not initialized');
32
+ return buildHubMember(port, options);
33
+ }
34
+ function notActive() {
35
+ return new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
36
+ }
10
37
  /**
11
38
  * One plugin host's view of its vault accounts. It turns vault credentials
12
39
  * into rows for `/routing`, authorizes each physical send with the vault, and
@@ -19,6 +46,7 @@ export class ClaustrumConsumer {
19
46
  #options;
20
47
  #shutdown = new AbortController();
21
48
  #logger;
49
+ #warnShutdown;
22
50
  #custody;
23
51
  #connecting;
24
52
  #refreshing;
@@ -30,6 +58,8 @@ export class ClaustrumConsumer {
30
58
  #grantReach;
31
59
  #timer;
32
60
  #started = false;
61
+ #pending = new Set();
62
+ #disposing;
33
63
  #insula;
34
64
  #vaultWatch;
35
65
  #releaseInsulaWatch;
@@ -37,9 +67,23 @@ export class ClaustrumConsumer {
37
67
  /** When this instance last completed a discovery, by `now`. */
38
68
  #discoveredAt;
39
69
  #releaseQuotaWriter;
70
+ /** Set only on a member built by `ClaustrumDiscoveryHub.member`. */
71
+ #hub;
72
+ /** A hub member's one custody lease at a time. */
73
+ #lease;
74
+ /** The lease that issued each receipt, so a 401 goes back through it. */
75
+ #issuedBy = new WeakMap();
76
+ static {
77
+ buildHubMember = (port, options) => {
78
+ const consumer = new ClaustrumConsumer(options);
79
+ consumer.#hub = port;
80
+ return { consumer, hooks: consumer.#hubHooks() };
81
+ };
82
+ }
40
83
  constructor(options) {
41
84
  this.#options = options;
42
85
  this.#logger = options.logger ?? createLogger('claustrum');
86
+ this.#warnShutdown = shutdownWarning(this.#logger, this.#shutdown.signal);
43
87
  this.#releaseQuotaWriter = retainVaultQuotaWriter(options.rosterPath, (publication) => {
44
88
  this.#roster = applyVaultQuota(this.#roster, publication).roster;
45
89
  });
@@ -48,6 +92,11 @@ export class ClaustrumConsumer {
48
92
  this.#shutdown.signal.throwIfAborted();
49
93
  signal?.throwIfAborted();
50
94
  }
95
+ #track(pending) {
96
+ this.#pending.add(pending);
97
+ void pending.then(() => this.#pending.delete(pending), () => this.#pending.delete(pending));
98
+ return pending;
99
+ }
51
100
  async #active() {
52
101
  return (await this.#options.isCustodyActive?.()) ?? true;
53
102
  }
@@ -87,35 +136,69 @@ export class ClaustrumConsumer {
87
136
  if (this.#custody)
88
137
  return this.#custody;
89
138
  if (!this.#connecting) {
90
- this.#connecting = this.#options
91
- .connect()
92
- .then((client) => {
93
- if (this.#shutdown.signal.aborted) {
94
- client.close();
95
- this.#assertOpen();
96
- }
97
- this.#custody = new ClaustrumScopedCustody({
98
- client,
99
- family: this.#options.family,
100
- tokenPath: this.#options.tokenPath,
101
- parseIdentity: this.#options.parseIdentity,
102
- requireAssertion: this.#options.requireAssertion,
103
- now: this.#options.now,
104
- logger: this.#logger,
105
- setTimeoutImpl: this.#options.setTimeoutImpl,
106
- clearTimeoutImpl: this.#options.clearTimeoutImpl,
107
- });
108
- if (this.#options.insula && client.insula) {
109
- this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
110
- }
111
- return this.#custody;
112
- })
113
- .finally(() => {
114
- this.#connecting = undefined;
115
- });
139
+ // A connect that never settles must not hold #connecting open: every
140
+ // later refresh would wait on the same promise. The race below bounds
141
+ // each attempt, and only a client that arrives in time is installed.
142
+ const attempt = { timedOut: false };
143
+ this.#connecting = this.#boundedConnect(attempt);
144
+ this.#track(this.#connecting);
116
145
  }
117
146
  return this.#wait(this.#connecting);
118
147
  }
148
+ /**
149
+ * One `connect()` attempt under `connectTimeoutMs`. The late promise is still
150
+ * observed after the deadline, so a client that arrives then is closed
151
+ * rather than leaked.
152
+ */
153
+ #boundedConnect(attempt) {
154
+ const setTimer = this.#options.setTimeoutImpl ?? setTimeout;
155
+ const clearTimer = this.#options.clearTimeoutImpl ?? clearTimeout;
156
+ const pending = this.#options.connect().then((client) => {
157
+ if (this.#shutdown.signal.aborted || attempt.timedOut) {
158
+ client.close();
159
+ if (!attempt.timedOut)
160
+ this.#assertOpen();
161
+ throw unreachableConnect();
162
+ }
163
+ this.#custody = new ClaustrumScopedCustody({
164
+ client,
165
+ family: this.#options.family,
166
+ tokenPath: this.#options.tokenPath,
167
+ parseIdentity: this.#options.parseIdentity,
168
+ requireAssertion: this.#options.requireAssertion,
169
+ now: this.#options.now,
170
+ logger: this.#logger,
171
+ setTimeoutImpl: this.#options.setTimeoutImpl,
172
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
173
+ });
174
+ if (this.#options.insula && client.insula) {
175
+ this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
176
+ }
177
+ return this.#custody;
178
+ });
179
+ let timer;
180
+ const deadline = new Promise((_resolve, reject) => {
181
+ timer = setTimer(() => {
182
+ attempt.timedOut = true;
183
+ if (this.#shutdown.signal.aborted)
184
+ return;
185
+ this.#logger.warn('vault connect timed out', {
186
+ reason: 'unreachable',
187
+ connectTimeoutMs: this.#connectTimeoutMs(),
188
+ });
189
+ reject(unreachableConnect());
190
+ }, this.#connectTimeoutMs());
191
+ timer.unref?.();
192
+ });
193
+ return Promise.race([pending, deadline]).finally(() => {
194
+ if (timer)
195
+ clearTimer(timer);
196
+ this.#connecting = undefined;
197
+ });
198
+ }
199
+ #connectTimeoutMs() {
200
+ return this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
201
+ }
119
202
  /** The last committed roster this instance saw, without any I/O. */
120
203
  snapshot() {
121
204
  return this.#roster;
@@ -130,6 +213,8 @@ export class ClaustrumConsumer {
130
213
  * held the discovery lease and its newer roster was served instead).
131
214
  */
132
215
  grantReach() {
216
+ if (this.#hub)
217
+ return this.#hub.grantReach(this.#roster?.view);
133
218
  const latest = this.#grantReach;
134
219
  return latest && this.#roster?.view === latest.view
135
220
  ? latest.reach
@@ -137,6 +222,8 @@ export class ClaustrumConsumer {
137
222
  }
138
223
  /** How this consumer currently learns about changes, without I/O. */
139
224
  watchState() {
225
+ if (this.#hub)
226
+ return Object.freeze({ vault: this.#hub.watchMode() });
140
227
  return Object.freeze({
141
228
  vault: this.#vaultWatch?.mode ?? 'polling',
142
229
  ...(this.#options.insula && {
@@ -153,6 +240,13 @@ export class ClaustrumConsumer {
153
240
  }
154
241
  /** Whether any account is using an in-memory outage receipt, without I/O. */
155
242
  keptReceiptState() {
243
+ if (this.#hub) {
244
+ // Kept receipts live in the one shared custody, whichever lease reads it.
245
+ const lease = this.#lease;
246
+ return lease && this.#hub.leaseLive(lease)
247
+ ? lease.custody.keptReceiptState()
248
+ : this.#hub.keptReceiptState();
249
+ }
156
250
  return (this.#custody?.keptReceiptState() ??
157
251
  Object.freeze({ active: false, since: null }));
158
252
  }
@@ -162,6 +256,8 @@ export class ClaustrumConsumer {
162
256
  }
163
257
  refresh() {
164
258
  this.#assertOpen();
259
+ if (this.#hub)
260
+ return this.#track(this.#hubRefresh(this.#hub));
165
261
  if (this.#refreshing)
166
262
  return this.#refreshing;
167
263
  this.#refreshing = (async () => {
@@ -169,7 +265,10 @@ export class ClaustrumConsumer {
169
265
  this.#roster = undefined;
170
266
  this.#grantReach = undefined;
171
267
  this.#stopWatch();
172
- this.#custody?.close();
268
+ if (this.#custody) {
269
+ this.#custody.close();
270
+ this.#track(this.#custody.dispose(this.#warnShutdown));
271
+ }
173
272
  this.#custody = undefined;
174
273
  this.#insula?.release();
175
274
  this.#insula = undefined;
@@ -214,6 +313,7 @@ export class ClaustrumConsumer {
214
313
  })().finally(() => {
215
314
  this.#refreshing = undefined;
216
315
  });
316
+ this.#track(this.#refreshing);
217
317
  return this.#refreshing;
218
318
  }
219
319
  /**
@@ -231,6 +331,11 @@ export class ClaustrumConsumer {
231
331
  return;
232
332
  this.#assertOpen();
233
333
  this.#started = true;
334
+ // A hub member has no tick or watch of its own: the hub drives it.
335
+ if (this.#hub) {
336
+ this.#hub.start();
337
+ return;
338
+ }
234
339
  const tick = async () => {
235
340
  try {
236
341
  if (await this.#listDue())
@@ -249,11 +354,11 @@ export class ClaustrumConsumer {
249
354
  return;
250
355
  this.#timer = (this.#options.setTimeoutImpl ?? setTimeout)(() => {
251
356
  this.#timer = undefined;
252
- void tick();
357
+ void this.#track(tick());
253
358
  }, delay);
254
359
  this.#timer.unref?.();
255
360
  };
256
- void tick();
361
+ void this.#track(tick());
257
362
  }
258
363
  /** Whether a background tick must list the vault now. */
259
364
  async #listDue() {
@@ -282,11 +387,13 @@ export class ClaustrumConsumer {
282
387
  // re-applies it to kept receipts exactly as a poll does. A refresh
283
388
  // already in flight may have listed before the change was announced,
284
389
  // so wait for it and list again.
285
- onView: async () => {
390
+ onView: () => this.#track((async () => {
286
391
  while (this.#refreshing)
287
392
  await this.#refreshing.catch(() => { });
393
+ if (this.#shutdown.signal.aborted)
394
+ return;
288
395
  await this.refresh();
289
- },
396
+ })()),
290
397
  // Custody has dropped kept receipts, as a poll meeting the same
291
398
  // refusal from list_scoped would.
292
399
  onRefused: (error) => this.#options.onError?.(error),
@@ -412,6 +519,8 @@ export class ClaustrumConsumer {
412
519
  * poll, and a route whose credential or account changed is refused.
413
520
  */
414
521
  async authorize(routeId, signal) {
522
+ if (this.#hub)
523
+ return this.#hubAuthorize(this.#hub, routeId, signal);
415
524
  this.#assertOpen(signal);
416
525
  const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
417
526
  if (!roster || !(await this.#active())) {
@@ -459,8 +568,168 @@ export class ClaustrumConsumer {
459
568
  combined.dispose();
460
569
  }
461
570
  }
571
+ /** The hub's handle on this member's memory. */
572
+ #hubHooks() {
573
+ return {
574
+ install: (roster) => {
575
+ if (this.#shutdown.signal.aborted)
576
+ return;
577
+ const changed = roster.view !== this.#roster?.view;
578
+ this.#roster = pendingVaultQuota(this.#options.rosterPath, roster);
579
+ // One subscriber's failure must not stop the hub's fan-out.
580
+ if (changed)
581
+ try {
582
+ this.#options.onRoster?.(roster);
583
+ }
584
+ catch (error) {
585
+ this.#options.onError?.(error);
586
+ }
587
+ },
588
+ reset: () => {
589
+ this.#roster = undefined;
590
+ },
591
+ roster: () => this.#roster,
592
+ projection: () => ({
593
+ reservedRouteIds: new Set(this.#options.reservedRouteIds?.() ?? []),
594
+ routePrefix: this.#options.routePrefix,
595
+ mapAccount: this.#options.mapAccount,
596
+ now: (this.#options.now ?? Date.now)(),
597
+ }),
598
+ onError: (error) => {
599
+ if (!this.#shutdown.signal.aborted)
600
+ this.#options.onError?.(error);
601
+ },
602
+ };
603
+ }
604
+ /**
605
+ * A hub member's refresh is the hub's: the hub has already pushed this
606
+ * member's committed roster into memory when it resolves.
607
+ */
608
+ async #hubRefresh(hub) {
609
+ const outcome = await hub.refresh();
610
+ this.#assertOpen();
611
+ if (!outcome)
612
+ return undefined;
613
+ if (outcome.errors.has(hub.provider))
614
+ throw outcome.errors.get(hub.provider);
615
+ return this.#roster;
616
+ }
617
+ /** Release this member's lease, or only `lease` when one is named. */
618
+ #dropLease(lease) {
619
+ if (lease && this.#lease !== lease)
620
+ return;
621
+ this.#lease?.release();
622
+ this.#lease = undefined;
623
+ }
624
+ /** This member's lease, re-acquired once its connection has ended. */
625
+ async #currentLease(hub, signal) {
626
+ const held = this.#lease;
627
+ if (held && hub.leaseLive(held))
628
+ return held;
629
+ this.#dropLease();
630
+ const lease = await hub.acquire(signal);
631
+ if (this.#shutdown.signal.aborted) {
632
+ lease.release();
633
+ this.#assertOpen();
634
+ }
635
+ // A concurrent authorize may have acquired first; keep one lease.
636
+ const raced = this.#lease;
637
+ if (raced && hub.leaseLive(raced)) {
638
+ lease.release();
639
+ return raced;
640
+ }
641
+ this.#lease = lease;
642
+ return lease;
643
+ }
644
+ /**
645
+ * `authorize` for a hub member. Only the hub's own authority read decides
646
+ * that vault mode is off. If the hub's refresh still leaves this member
647
+ * without a roster, it fails with `route-unavailable` before acquiring a
648
+ * lease or asking for a credential, and no other member is affected.
649
+ */
650
+ async #hubAuthorize(hub, routeId, signal) {
651
+ this.#assertOpen(signal);
652
+ // A failed list rejects here; this member's own failed commit leaves it
653
+ // without a roster, handled below.
654
+ if (!this.#roster)
655
+ await this.#wait(hub.refresh(), signal);
656
+ const roster = this.#roster;
657
+ if (!(await hub.readAuthority())) {
658
+ this.#dropLease();
659
+ throw notActive();
660
+ }
661
+ this.#assertOpen(signal);
662
+ if (!roster)
663
+ throw new ClaustrumConsumerError('route-unavailable', 'Claustrum has no roster for this provider yet');
664
+ const current = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
665
+ this.#assertOpen(signal);
666
+ const observed = roster.rows.find((row) => row.routeId === routeId);
667
+ const configured = current?.rows.find((row) => row.routeId === routeId);
668
+ if (configured && !configured.enabled) {
669
+ if (observed)
670
+ hub.forget(observed.credentialId);
671
+ throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
672
+ }
673
+ if (!observed ||
674
+ !configured ||
675
+ configured.state !== 'active' ||
676
+ configured.credentialId !== observed.credentialId ||
677
+ configured.accountIdentity !== observed.accountIdentity ||
678
+ configured.credentialType !== observed.credentialType) {
679
+ if (observed)
680
+ hub.forget(observed.credentialId);
681
+ throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
682
+ }
683
+ const identity = {
684
+ credentialId: configured.credentialId,
685
+ credentialType: configured.credentialType,
686
+ ...(configured.accountIdentity !== undefined && {
687
+ accountIdentity: configured.accountIdentity,
688
+ }),
689
+ };
690
+ const combined = combineAbortSignals([
691
+ this.#shutdown.signal,
692
+ ...(signal ? [signal] : []),
693
+ ]);
694
+ try {
695
+ for (let attempt = 0;; attempt++) {
696
+ const lease = await this.#currentLease(hub, combined.signal);
697
+ try {
698
+ const served = await lease.custody.authorize(identity, combined.signal, routeId);
699
+ this.#issuedBy.set(served, lease);
700
+ return served;
701
+ }
702
+ catch (error) {
703
+ // Only a lease whose connection ended meanwhile earns one more
704
+ // try, in the current connection; anything else, or a second
705
+ // closed, is the answer.
706
+ if (attempt > 0 ||
707
+ combined.signal.aborted ||
708
+ hub.leaseLive(lease) ||
709
+ !(error instanceof ClaustrumConsumerError) ||
710
+ error.kind !== 'closed')
711
+ throw error;
712
+ this.#dropLease(lease);
713
+ if (!(await hub.readAuthority()))
714
+ throw notActive();
715
+ }
716
+ }
717
+ }
718
+ finally {
719
+ combined.dispose();
720
+ }
721
+ }
462
722
  async reportFailure(attempt, status, source) {
463
723
  this.#assertOpen();
724
+ if (this.#hub) {
725
+ // Only the lease that issued the receipt may report it; once that
726
+ // lease was released or its connection ended, its view throws closed.
727
+ const lease = this.#issuedBy.get(attempt);
728
+ if (!lease)
729
+ throw new ClaustrumConsumerError('no-receipt', 'Claustrum dispatch receipt has no active owner');
730
+ await lease.custody.reportFailure(attempt, status, source);
731
+ return;
732
+ }
464
733
  if (!this.#custody)
465
734
  throw new ClaustrumConsumerError('no-receipt', 'Claustrum dispatch receipt has no active owner');
466
735
  await this.#custody.reportFailure(attempt, status, source);
@@ -485,8 +754,12 @@ export class ClaustrumConsumer {
485
754
  let current;
486
755
  try {
487
756
  current = await this.authorize(routeId, signal);
757
+ // Cancellation can land after authorize settles, before this continuation.
758
+ signal?.throwIfAborted();
488
759
  }
489
- catch {
760
+ catch (error) {
761
+ if (signal?.aborted)
762
+ throw error;
490
763
  // Re-authorization failed: keep this 401 and report it against the
491
764
  // receipt (served record version) this request was sent with.
492
765
  }
@@ -515,13 +788,29 @@ export class ClaustrumConsumer {
515
788
  this.#assertOpen();
516
789
  await declineVaultRoute(this.#options.rosterPath, routeId);
517
790
  this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
518
- this.#custody?.retain(this.#roster?.rows ?? []);
791
+ // A hub member shares kept receipts with every other member, so they are
792
+ // retained against all members' rosters, never against this one alone.
793
+ if (this.#hub)
794
+ this.#hub.retainUnion();
795
+ else
796
+ this.#custody?.retain(this.#roster?.rows ?? []);
519
797
  }
520
798
  async accept(routeId) {
521
799
  this.#assertOpen();
522
800
  await acceptVaultRoute(this.#options.rosterPath, routeId);
523
801
  this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
524
802
  }
803
+ /**
804
+ * Set the order vault accounts are tried in (`reorderVaultRoster`), then
805
+ * re-read the roster, so the next routing decision in this process uses
806
+ * the new order without waiting for a discovery. Never contacts the vault.
807
+ */
808
+ async reorder(routeIds) {
809
+ this.#assertOpen();
810
+ const result = await reorderVaultRoster(this.#options.rosterPath, routeIds);
811
+ this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
812
+ return result;
813
+ }
525
814
  /**
526
815
  * Store a quota or profile observation for a vault route. The receipt the
527
816
  * reading was taken with is required: the observation lands only while the
@@ -566,7 +855,14 @@ export class ClaustrumConsumer {
566
855
  (this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
567
856
  this.#timer = undefined;
568
857
  this.#stopWatch();
569
- this.#custody?.close();
858
+ if (this.#custody) {
859
+ this.#custody.close();
860
+ this.#track(this.#custody.dispose(this.#warnShutdown));
861
+ }
862
+ // A hub member gives back its lease; the shared custody stays open.
863
+ this.#lease?.release();
864
+ this.#lease = undefined;
865
+ this.#hub?.detach();
570
866
  this.#insula?.release();
571
867
  this.#insula = undefined;
572
868
  this.#roster = undefined;
@@ -574,9 +870,13 @@ export class ClaustrumConsumer {
574
870
  void this.#releaseQuotaWriter();
575
871
  forgetPendingVaultDiscovery(this.#options.rosterPath);
576
872
  }
577
- /** Close immediately; the last consumer also waits for the writer's bounded drain. */
873
+ /** Close immediately; background work gets 2 s and the quota writer its own 1 s. */
578
874
  async dispose() {
579
875
  this.close();
580
- await this.#releaseQuotaWriter();
876
+ this.#disposing ??= Promise.all([
877
+ drainShutdown(this.#pending, this.#warnShutdown),
878
+ this.#releaseQuotaWriter(),
879
+ ]).then(() => { });
880
+ await this.#disposing;
581
881
  }
582
882
  }
@@ -22,10 +22,35 @@ export interface ClaustrumFamily {
22
22
  category: string;
23
23
  apiKeys?: boolean;
24
24
  }
25
+ /** Non-secret classification facts, including rows decoded by older clients. */
26
+ export interface VaultRowFacts {
27
+ readonly credentialId: string;
28
+ readonly credentialType: string;
29
+ readonly authMethod?: string;
30
+ readonly refreshAdapter?: string;
31
+ readonly providerIds?: readonly string[];
32
+ readonly categories: readonly string[];
33
+ }
34
+ /** A policy that partitions one category without requiring one OAuth adapter. */
35
+ export interface ClassifyingClaustrumFamily<Code extends string> {
36
+ category: string;
37
+ refusalCodes: readonly Code[];
38
+ classify(row: VaultRowFacts): {
39
+ kind: 'not-mine';
40
+ } | {
41
+ kind: 'mine';
42
+ member: string;
43
+ } | {
44
+ kind: 'refused';
45
+ code: Code;
46
+ };
47
+ }
48
+ export type ClaustrumFamilyInput = ClaustrumFamily | ClassifyingClaustrumFamily<string>;
25
49
  /** One vault credential this consumer may serve. Never carries bearer material. */
26
50
  export interface VaultCredential {
27
51
  readonly credentialId: string;
28
52
  readonly credentialType: VaultCredentialType;
53
+ readonly member?: string;
29
54
  /**
30
55
  * The provider account the credential logs into, when the vault's adapter
31
56
  * claims one. Absent means the adapter makes no claim, not a mismatch.
@@ -47,6 +72,7 @@ export type SkippedVaultReason = 'empty credential id' | 'duplicate credential i
47
72
  */
48
73
  export interface SkippedVaultRecord {
49
74
  readonly credentialId?: string;
75
+ readonly member?: string;
50
76
  readonly reason: SkippedVaultReason;
51
77
  }
52
78
  /**
@@ -82,6 +108,13 @@ export interface VaultInventory {
82
108
  readonly view: string;
83
109
  readonly credentials: readonly VaultCredential[];
84
110
  readonly skipped: readonly SkippedVaultRecord[];
111
+ /** Classifying families only: fixed refusal codes, never classifier errors. */
112
+ readonly refused?: readonly {
113
+ credentialId?: string;
114
+ code: string;
115
+ }[];
116
+ /** Classifying families only: rows outside this family's responsibility. */
117
+ readonly notMine?: number;
85
118
  /**
86
119
  * The consumer's grants, as the vault's list reply named them. Undefined means unknown
87
120
  * (the vault or client predates the fields), never zero grants.
@@ -221,7 +254,7 @@ export declare class ClaustrumScopedCustody {
221
254
  #private;
222
255
  constructor(options: {
223
256
  client: ClaustrumScopedClient;
224
- family: ClaustrumFamily;
257
+ family: ClaustrumFamilyInput;
225
258
  tokenPath?: string;
226
259
  readToken?: () => Promise<EnrollmentTokenFile>;
227
260
  parseIdentity?: IdentityParser;
@@ -296,4 +329,6 @@ export declare class ClaustrumScopedCustody {
296
329
  */
297
330
  watchRoster(options: ClaustrumRosterWatchOptions): ClaustrumRosterWatch;
298
331
  close(): void;
332
+ /** Stop immediately; allow token reads 2 s and share an owner's timeout warning. */
333
+ dispose(onTimeout?: (pendingOperations: number) => void): Promise<void>;
299
334
  }