@cortexkit/antigravity-auth-core 3.0.12 → 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 (51) 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.js +37 -12
  10. package/dist/common-auth-embedded/claustrum/hub.d.ts +195 -0
  11. package/dist/common-auth-embedded/claustrum/hub.js +988 -0
  12. package/dist/common-auth-embedded/claustrum/index.d.ts +13 -3
  13. package/dist/common-auth-embedded/claustrum/index.js +37 -4
  14. package/dist/common-auth-embedded/claustrum/quota-writer.d.ts +8 -1
  15. package/dist/common-auth-embedded/claustrum/quota-writer.js +34 -12
  16. package/dist/common-auth-embedded/claustrum/roster.d.ts +49 -1
  17. package/dist/common-auth-embedded/claustrum/roster.js +187 -12
  18. package/dist/common-auth-embedded/claustrum/shutdown.js +42 -0
  19. package/dist/common-auth-embedded/fs/file-read-cache.js +57 -0
  20. package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +1 -1
  21. package/dist/common-auth-embedded/fs/refresh-file-lock.js +20 -11
  22. package/dist/common-auth-embedded/quota/commit-interval.d.ts +9 -0
  23. package/dist/common-auth-embedded/quota/commit-interval.js +18 -0
  24. package/dist/common-auth-embedded/source-output.json +115 -67
  25. package/dist/common-auth-embedded/store/mutate.d.ts +2 -0
  26. package/dist/common-auth-embedded/store/mutate.js +4 -0
  27. package/dist/common-auth-embedded/store/pool.d.ts +6 -1
  28. package/dist/common-auth-embedded/store/pool.js +1 -1
  29. package/dist/common-auth-embedded/store/quota-writer.js +27 -13
  30. package/dist/common-auth-embedded/store/settings.d.ts +1 -1
  31. package/dist/common-auth-embedded/store/settings.js +16 -6
  32. package/dist/model-registry.d.ts +2 -2
  33. package/dist/model-registry.d.ts.map +1 -1
  34. package/dist/model-registry.js +6 -4
  35. package/dist/model-registry.js.map +1 -1
  36. package/dist/quota-manager.d.ts +1 -1
  37. package/dist/quota-manager.d.ts.map +1 -1
  38. package/dist/quota-manager.js +12 -4
  39. package/dist/quota-manager.js.map +1 -1
  40. package/dist/quota-presentation.d.ts.map +1 -1
  41. package/dist/quota-presentation.js +8 -0
  42. package/dist/quota-presentation.js.map +1 -1
  43. package/dist/quota-types.d.ts +1 -1
  44. package/dist/quota-types.d.ts.map +1 -1
  45. package/dist/quota-types.js +5 -0
  46. package/dist/quota-types.js.map +1 -1
  47. package/dist/vault-account-source.d.ts +6 -1
  48. package/dist/vault-account-source.d.ts.map +1 -1
  49. package/dist/vault-account-source.js +9 -0
  50. package/dist/vault-account-source.js.map +1 -1
  51. 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
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);
@@ -519,7 +788,12 @@ export class ClaustrumConsumer {
519
788
  this.#assertOpen();
520
789
  await declineVaultRoute(this.#options.rosterPath, routeId);
521
790
  this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
522
- 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 ?? []);
523
797
  }
524
798
  async accept(routeId) {
525
799
  this.#assertOpen();
@@ -581,7 +855,14 @@ export class ClaustrumConsumer {
581
855
  (this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
582
856
  this.#timer = undefined;
583
857
  this.#stopWatch();
584
- 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();
585
866
  this.#insula?.release();
586
867
  this.#insula = undefined;
587
868
  this.#roster = undefined;
@@ -589,9 +870,13 @@ export class ClaustrumConsumer {
589
870
  void this.#releaseQuotaWriter();
590
871
  forgetPendingVaultDiscovery(this.#options.rosterPath);
591
872
  }
592
- /** 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. */
593
874
  async dispose() {
594
875
  this.close();
595
- await this.#releaseQuotaWriter();
876
+ this.#disposing ??= Promise.all([
877
+ drainShutdown(this.#pending, this.#warnShutdown),
878
+ this.#releaseQuotaWriter(),
879
+ ]).then(() => { });
880
+ await this.#disposing;
596
881
  }
597
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
  }