@cortexkit/opencode-openai-auth 1.1.0 → 1.3.0

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/bin.js +8 -8
  2. package/dist/{codex-request-BXDXToyt.d.ts → codex-request-B9D0e97Q.d.ts} +177 -28
  3. package/dist/{index-7c6sxtd6.js → index-1mbgsxsr.js} +2 -2
  4. package/dist/{index-4c8yw54k.js → index-2pgkrebq.js} +2 -2
  5. package/dist/index-4v2fs42x.js +65 -0
  6. package/dist/index-8x1aj7q3.js +2 -0
  7. package/dist/{index-f2fmb596.js → index-bc89akak.js} +1 -1
  8. package/dist/index-ex6nx3mq.js +2 -0
  9. package/dist/{index-zpm95mt5.js → index-ftkhhr6d.js} +1 -1
  10. package/dist/{index-msx2m78k.js → index-gn2b097f.js} +2 -2
  11. package/dist/{index-j8v2xwq6.js → index-m3ctrabv.js} +1 -1
  12. package/dist/index-psjyexj3.js +69 -0
  13. package/dist/{index-k6cydkgt.js → index-tddr876f.js} +2 -2
  14. package/dist/index-tn8x5tz8.js +90 -0
  15. package/dist/{index-csky2xpg.js → index-xwvze96g.js} +1 -1
  16. package/dist/index.d.ts +5 -3
  17. package/dist/index.js +1 -1
  18. package/dist/{model-YRg7Uowo.d.ts → model-BdITTmeb.d.ts} +1 -1
  19. package/dist/{port-file-CxEpgSHY.d.ts → port-file-DbHIVC9a.d.ts} +1 -1
  20. package/dist/{protocol-CDV1Pc6h.d.ts → protocol-MysYPAho.d.ts} +1 -1
  21. package/dist/rpc/port-file.d.ts +1 -1
  22. package/dist/rpc/port-file.js +1 -1
  23. package/dist/rpc/protocol.d.ts +1 -1
  24. package/dist/rpc/protocol.js +1 -1
  25. package/dist/rpc/rpc-client.d.ts +3 -3
  26. package/dist/rpc/rpc-client.js +1 -1
  27. package/dist/rpc/rpc-dir.js +1 -1
  28. package/dist/{sidebar-state-DjTWUbmf.d.ts → sidebar-state-CVSok7zv.d.ts} +114 -5
  29. package/dist/sidebar-state.d.ts +2 -2
  30. package/dist/sidebar-state.js +1 -1
  31. package/dist/token-util-cf0nyh7n.js +1 -0
  32. package/dist/{token-rz4t3ay6.js → token-z6qq7a1d.js} +1 -1
  33. package/dist/tui-preferences.js +1 -1
  34. package/dist/tui.d.ts +2 -2
  35. package/dist/v2/server.d.ts +109 -2
  36. package/dist/v2/server.js +3 -3
  37. package/package.json +4 -4
  38. package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +203 -18
  39. package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +242 -16
  40. package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +168 -6
  41. package/src/tui-compiled/raw/shared/375d9cca2def037d-private-files.js +10 -2
  42. package/src/tui-compiled/raw/shared/94f53cfd572ba4bb-availability.js +106 -0
  43. package/src/tui-compiled/raw/shared/d0f0b6be200311af-insula.js +286 -21
  44. package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +1 -1
  45. package/src/tui-compiled/raw/shared/dc9290c2a7552ba6-watch.js +253 -0
  46. package/src/tui-compiled/raw/shared/e8d3c8da776ef9cd-index.js +4 -1
  47. package/src/tui-compiled/raw/sidebar-state.js +63 -1
  48. package/src/tui-compiled/raw/tui.tsx +82 -30
  49. package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +203 -18
  50. package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +242 -16
  51. package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +168 -6
  52. package/src/tui-compiled/runtime/shared/375d9cca2def037d-private-files.js +10 -2
  53. package/src/tui-compiled/runtime/shared/94f53cfd572ba4bb-availability.js +106 -0
  54. package/src/tui-compiled/runtime/shared/d0f0b6be200311af-insula.js +286 -21
  55. package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +1 -1
  56. package/src/tui-compiled/runtime/shared/dc9290c2a7552ba6-watch.js +253 -0
  57. package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +4 -1
  58. package/src/tui-compiled/runtime/sidebar-state.js +63 -1
  59. package/src/tui-compiled/runtime/tui.js +245 -208
  60. package/dist/index-0yh85bwb.js +0 -69
  61. package/dist/index-7eb3dw37.js +0 -90
  62. package/dist/index-8re8rprk.js +0 -2
  63. package/dist/index-edbdscy7.js +0 -65
  64. package/dist/index-m5qmvr6c.js +0 -2
  65. package/dist/map-Buq-GW7l.d.ts +0 -56
  66. package/dist/token-util-tpy2c7h6.js +0 -1
@@ -1,11 +1,14 @@
1
1
  import { ClaustrumCredentialError, } from "@cortexkit/claustrum-client";
2
2
  import { createLogger } from "./cc9e0702a752cea7-index.js";
3
+ import { ClaustrumUnreachableError, classifyVaultFailure, } from "./94f53cfd572ba4bb-availability.js";
3
4
  import { readClaustrumEnrollmentToken } from "./b9588ad4725c1d10-enrollment.js";
4
5
  import { ClaustrumConsumerError } from "./ca0fccaba1d87af4-errors.js";
5
6
  import { CUSTODY_PLACEHOLDER_PREFIX } from "./d9d2892a4c371301-host-slot.js";
6
7
  /** Only a new version of the same account may replace an in-flight 401. */
7
8
  export function isScopedCredentialRotation(served, current) {
8
9
  return (current !== undefined &&
10
+ !current.fromKeptReceipt &&
11
+ !served.fromKeptReceipt &&
9
12
  current.credentialId === served.credentialId &&
10
13
  current.accountIdentity === served.accountIdentity &&
11
14
  current.recordVersion !== served.recordVersion);
@@ -45,6 +48,8 @@ export function decideScopedRetryAfter401(site, served, current, logger = defaul
45
48
  * so it cannot expire while a long request is still using it.
46
49
  */
47
50
  export const SERVING_MARGIN_MS = 300_000;
51
+ /** A kept receipt needs a minute of headroom, not the vault's refresh demand. */
52
+ export const KEPT_RECEIPT_MARGIN_MS = 60_000;
48
53
  function invalidMaterial() {
49
54
  return new ClaustrumConsumerError('invalid-material', 'Claustrum returned invalid credential material');
50
55
  }
@@ -83,13 +88,28 @@ function credentialTypeOf(row, family) {
83
88
  return row.refreshAdapter === undefined ? 'api_key' : undefined;
84
89
  return undefined;
85
90
  }
91
+ /**
92
+ * The grants named in the vault's list reply, or undefined when it names none.
93
+ * Both fields must be present: a reply carrying only one of them is treated as
94
+ * unknown rather than completed with a guess. claustrum-client already refuses
95
+ * a malformed value, so nothing here validates it again.
96
+ */
97
+ function grantReachOf(listed) {
98
+ const { grants, grantTuples } = listed;
99
+ if (grants === undefined || grantTuples === undefined)
100
+ return undefined;
101
+ return Object.freeze({
102
+ grants,
103
+ tuples: Object.freeze(grantTuples.map(({ selectorKind, selector, operation }) => Object.freeze({ selectorKind, selector, operation }))),
104
+ });
105
+ }
86
106
  /**
87
107
  * Reads this consumer's vault credentials (list, fetch, 401 report), with the
88
- * enrollment token as authorization. Used by every host of a plugin. There is
89
- * deliberately no credential cache and no single-flight of credential reads:
90
- * each physical send is authorized by the vault, so a revoked enrollment or a
91
- * changed record takes effect on the next send. The enrollment token is
92
- * re-read per operation so an operator's reissue on disk is picked up.
108
+ * enrollment token as authorization. Used by every host of a plugin. Every
109
+ * physical send asks the vault first while reachable. Only an unreachable vault permits
110
+ * reuse of the last validated receipt, in memory and bounded by its expiry.
111
+ * The enrollment token is re-read per operation so revocation and disconnect
112
+ * still take effect without a process restart.
93
113
  */
94
114
  export class ClaustrumScopedCustody {
95
115
  #client;
@@ -101,6 +121,16 @@ export class ClaustrumScopedCustody {
101
121
  #logger;
102
122
  #provenance = new WeakMap();
103
123
  #reports = new WeakMap();
124
+ #kept = new Map();
125
+ #keptAttempts = new WeakMap();
126
+ #setTimeout;
127
+ #clearTimeout;
128
+ #outage = false;
129
+ #outageError = new ClaustrumUnreachableError();
130
+ #probeTimer;
131
+ #probing = false;
132
+ #lastProbeAt = -Infinity;
133
+ #probeDelay = 2_000;
104
134
  #closed = false;
105
135
  constructor(options) {
106
136
  const tokenPath = options.tokenPath;
@@ -119,6 +149,63 @@ export class ClaustrumScopedCustody {
119
149
  this.#requireAssertion = options.requireAssertion ?? false;
120
150
  this.#now = options.now ?? Date.now;
121
151
  this.#logger = options.logger ?? defaultLogger;
152
+ this.#setTimeout = options.setTimeoutImpl ?? setTimeout;
153
+ this.#clearTimeout = options.clearTimeoutImpl ?? clearTimeout;
154
+ }
155
+ /** A watch losing its transport is the same outage as an unreachable read. */
156
+ transportLost(error) {
157
+ if (this.#closed || this.#outage)
158
+ return;
159
+ this.#outage = true;
160
+ this.#outageError =
161
+ error instanceof ClaustrumCredentialError
162
+ ? error
163
+ : new ClaustrumUnreachableError();
164
+ this.#logger.warn('serving kept vault receipt', { reason: 'unreachable' });
165
+ this.#scheduleProbe();
166
+ }
167
+ /** Any vault answer ends transport fallback, even when it refuses access. */
168
+ reachable() {
169
+ if (this.#closed || !this.#outage)
170
+ return;
171
+ this.#outage = false;
172
+ if (this.#probeTimer)
173
+ this.#clearTimeout(this.#probeTimer);
174
+ this.#probeTimer = undefined;
175
+ this.#probeDelay = 2_000;
176
+ for (const kept of this.#kept.values())
177
+ kept.since = null;
178
+ (this.#logger.info ?? defaultLogger.info)('vault receipt serving resumed', { reason: 'reachable' });
179
+ }
180
+ #scheduleProbe() {
181
+ if (this.#closed || !this.#outage || this.#probeTimer || this.#probing)
182
+ return;
183
+ this.#probeTimer = this.#setTimeout(() => {
184
+ this.#probeTimer = undefined;
185
+ this.#probe(true);
186
+ }, this.#probeDelay);
187
+ this.#probeTimer.unref?.();
188
+ }
189
+ #probe(idle = false) {
190
+ if (this.#closed || !this.#outage || this.#probing)
191
+ return;
192
+ if (this.#now() - this.#lastProbeAt < 1_000) {
193
+ this.#scheduleProbe();
194
+ return;
195
+ }
196
+ this.#lastProbeAt = this.#now();
197
+ this.#probing = true;
198
+ // Both triggers share list_scoped discovery, including its receipt-drop rules.
199
+ // Sends never await discovery or let idle backoff delay the next send probe.
200
+ void this.discover()
201
+ .catch(() => {
202
+ if (idle && this.#outage)
203
+ this.#probeDelay = Math.min(this.#probeDelay * 2, 30_000);
204
+ })
205
+ .finally(() => {
206
+ this.#probing = false;
207
+ this.#scheduleProbe();
208
+ });
122
209
  }
123
210
  #check(signal) {
124
211
  if (this.#closed)
@@ -157,17 +244,24 @@ export class ClaustrumScopedCustody {
157
244
  }
158
245
  catch (error) {
159
246
  this.#check(signal);
247
+ if (classifyVaultFailure(error) === 'unreachable')
248
+ this.transportLost(error);
249
+ else
250
+ this.reachable();
160
251
  // Keep the vault's own refusals (ClaustrumCredentialError, carrying
161
252
  // code, class and action); replace any other error, whose text may echo
162
253
  // request params that include the enrollment token.
163
254
  if (error instanceof ClaustrumCredentialError)
164
255
  throw error;
256
+ if (classifyVaultFailure(error) === 'unreachable')
257
+ throw new ClaustrumUnreachableError();
165
258
  throw new ClaustrumConsumerError('unavailable', 'Claustrum scoped operation unavailable');
166
259
  }
167
260
  finally {
168
261
  removeAbortListener?.();
169
262
  }
170
263
  this.#check(signal);
264
+ this.reachable();
171
265
  return result;
172
266
  }
173
267
  /**
@@ -177,8 +271,22 @@ export class ClaustrumScopedCustody {
177
271
  * skipped records: they simply are not this consumer's.
178
272
  */
179
273
  async discover(signal) {
180
- const token = await this.#token(signal);
181
- const inventory = await this.#call(() => this.#client.listScoped(token), signal);
274
+ let inventory;
275
+ let token;
276
+ try {
277
+ token = await this.#token(signal);
278
+ }
279
+ catch (error) {
280
+ this.#kept.clear();
281
+ throw error;
282
+ }
283
+ try {
284
+ inventory = await this.#call(() => this.#client.listScoped(token), signal);
285
+ }
286
+ catch (error) {
287
+ this.listFailed(error);
288
+ throw error;
289
+ }
182
290
  const skipped = [];
183
291
  const candidates = [];
184
292
  const counts = new Map();
@@ -217,29 +325,128 @@ export class ClaustrumScopedCustody {
217
325
  }
218
326
  for (const record of skipped)
219
327
  this.#logger.warn('skipped malformed vault record', record);
328
+ const grantReach = grantReachOf(inventory);
220
329
  return {
221
330
  view: inventory.view,
222
331
  credentials: Object.freeze(credentials),
223
332
  skipped: Object.freeze(skipped),
333
+ ...(grantReach && { grantReach }),
224
334
  };
225
335
  }
226
336
  /**
227
- * Fetch the credential for one physical send and wrap it in a fresh receipt.
228
- * The vault's served identity (or, without one, the plugin's parse of the
229
- * token) must equal the roster's when both are present. Without
230
- * `requireAssertion`, absence on either side proves nothing and does not
231
- * refuse; the receipt records what the vault asserted separately from what
232
- * the roster expected. With it, a reply that does not itself name the
233
- * credential id and the expected account is refused.
337
+ * Apply a failed account listing: any answer from the vault (a refusal such
338
+ * as a revoked enrollment's `not_found`) drops every kept receipt, while an
339
+ * unreachable vault keeps them. A vault watch that ends with the same
340
+ * refusal calls this too, so both paths treat it alike.
234
341
  */
235
- async authorize(identity, signal) {
342
+ listFailed(error) {
343
+ if (classifyVaultFailure(error) === 'unreachable')
344
+ this.transportLost(error);
345
+ else {
346
+ this.reachable();
347
+ this.#kept.clear();
348
+ }
349
+ }
350
+ /** Discard bearer material when a route is removed, declined or rebound. */
351
+ forget(credentialId) {
352
+ this.#kept.delete(credentialId);
353
+ }
354
+ /** Apply an already-held roster; this never fetches credentials or writes it. */
355
+ retain(identities) {
356
+ const current = new Map(identities.map((identity) => [identity.credentialId, identity]));
357
+ for (const [id, kept] of this.#kept) {
358
+ const identity = current.get(id);
359
+ if (!identity?.enabled ||
360
+ identity.state !== 'active' ||
361
+ identity.credentialType !== kept.attempt.credentialType ||
362
+ identity.accountIdentity !== kept.attempt.expectedAccountIdentity)
363
+ this.forget(id);
364
+ }
365
+ }
366
+ /** A frozen, non-secret summary for a plugin's status display. */
367
+ keptReceiptState() {
368
+ let since = null;
369
+ for (const [id, kept] of this.#kept) {
370
+ if (!this.#usable(kept.attempt)) {
371
+ this.forget(id);
372
+ continue;
373
+ }
374
+ if (kept.since !== null)
375
+ since = Math.min(since ?? kept.since, kept.since);
376
+ }
377
+ return Object.freeze({ active: since !== null, since });
378
+ }
379
+ #usable(attempt) {
380
+ return attempt.expiresAtMs === null
381
+ ? attempt.credentialType === 'api_key'
382
+ : attempt.expiresAtMs - this.#now() > KEPT_RECEIPT_MARGIN_MS;
383
+ }
384
+ /** Ask the vault while reachable; retain one validated receipt in memory only. */
385
+ async authorize(identity, signal, routeId = 'vault') {
386
+ this.#check(signal);
387
+ const { credentialId, credentialType, accountIdentity } = identity;
388
+ let kept = this.#kept.get(credentialId);
389
+ if (kept &&
390
+ (kept.attempt.credentialType !== credentialType ||
391
+ kept.attempt.expectedAccountIdentity !== accountIdentity)) {
392
+ this.forget(credentialId);
393
+ kept = undefined;
394
+ }
395
+ // A missing or invalid enrollment is not a transport outage.
396
+ let token;
397
+ try {
398
+ token = await this.#token(signal);
399
+ }
400
+ catch (error) {
401
+ this.#kept.clear();
402
+ throw error;
403
+ }
404
+ if (this.#outage && kept) {
405
+ this.#probe();
406
+ return this.#serveKept(credentialId, kept, token, routeId);
407
+ }
408
+ try {
409
+ const attempt = await this.#authorizeFresh({ credentialId, credentialType, accountIdentity }, token, signal);
410
+ this.#kept.set(credentialId, { attempt, routeId, since: null });
411
+ return attempt;
412
+ }
413
+ catch (error) {
414
+ this.#check(signal);
415
+ if (classifyVaultFailure(error) !== 'unreachable') {
416
+ this.forget(credentialId);
417
+ throw error;
418
+ }
419
+ return this.#serveKept(credentialId, kept, token, routeId, error);
420
+ }
421
+ }
422
+ #serveKept(credentialId, kept, token, routeId, error = this.#outageError) {
423
+ // A concurrent refusal or 401 may already have discarded this entry.
424
+ if (!kept ||
425
+ this.#kept.get(credentialId) !== kept ||
426
+ !this.#usable(kept.attempt)) {
427
+ this.forget(credentialId);
428
+ throw error;
429
+ }
430
+ if (kept.since === null)
431
+ kept.since = this.#now();
432
+ const attempt = Object.freeze(Object.defineProperty({ ...kept.attempt, fromKeptReceipt: true }, 'accessToken', { value: kept.attempt.accessToken, enumerable: false }));
433
+ this.#provenance.set(attempt, token);
434
+ this.#keptAttempts.set(attempt, routeId);
435
+ return attempt;
436
+ }
437
+ /**
438
+ * Fetch and validate a fresh receipt before retaining it. The vault assertion
439
+ * (or the plugin's token parse) must agree with the roster when both exist.
440
+ * With requireAssertion, an absent assertion also refuses rather than
441
+ * treating the roster's expectation as proof of the served account.
442
+ */
443
+ async #authorizeFresh(identity, token, signal) {
236
444
  if (!identity.credentialId)
237
445
  throw new ClaustrumConsumerError('route-unavailable', 'Claustrum dispatch requires a credential id');
238
446
  // Capture the caller's fields before yielding so a later mutation cannot move the fence.
239
447
  const { credentialId, credentialType, accountIdentity } = identity;
240
448
  if (this.#requireAssertion && accountIdentity === undefined)
241
449
  throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum dispatch requires a known account identity');
242
- const token = await this.#token(signal);
243
450
  const served = await this.#call(() => this.#client.getScoped({
244
451
  credentialId,
245
452
  enrollmentToken: token,
@@ -321,6 +528,21 @@ export class ClaustrumScopedCustody {
321
528
  const token = this.#provenance.get(attempt);
322
529
  if (!token)
323
530
  throw new ClaustrumConsumerError('no-receipt', 'Claustrum failure report requires an original dispatch receipt');
531
+ const keptRoute = this.#keptAttempts.get(attempt);
532
+ if (keptRoute !== undefined) {
533
+ this.forget(attempt.credentialId);
534
+ this.#logger.debug('scoped 401 not reported', {
535
+ routeId: keptRoute,
536
+ reason: 'kept-receipt',
537
+ });
538
+ return;
539
+ }
540
+ // The provider rejected this token, so it must not come back as a kept
541
+ // receipt during a later outage. Drop it before reporting, so a report that
542
+ // fails because the vault is unreachable still drops it.
543
+ const kept = this.#kept.get(attempt.credentialId);
544
+ if (kept && kept.attempt.recordVersion === attempt.recordVersion)
545
+ this.forget(attempt.credentialId);
324
546
  const pending = this.#reports.get(attempt);
325
547
  if (pending)
326
548
  return pending;
@@ -349,6 +571,10 @@ export class ClaustrumScopedCustody {
349
571
  if (this.#closed)
350
572
  return;
351
573
  this.#closed = true;
574
+ if (this.#probeTimer)
575
+ this.#clearTimeout(this.#probeTimer);
576
+ this.#probeTimer = undefined;
577
+ this.#kept.clear();
352
578
  this.#client.close();
353
579
  }
354
580
  }
@@ -5,6 +5,7 @@ import { readClaustrumEnrollmentToken } from "./b9588ad4725c1d10-enrollment.js";
5
5
  import { ClaustrumConsumerError } from "./ca0fccaba1d87af4-errors.js";
6
6
  import { acquireInsulaQuotaSource } from "./d0f0b6be200311af-insula.js";
7
7
  import { acceptVaultRoute, declineVaultRoute, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from "./30f5bc480f31f51c-roster.js";
8
+ import { SAFETY_POLL_MS, ScopedRosterWatch } from "./dc9290c2a7552ba6-watch.js";
8
9
  /**
9
10
  * One plugin host's view of its vault accounts. It turns vault credentials
10
11
  * into rows for `/routing`, authorizes each physical send with the vault, and
@@ -18,12 +19,23 @@ export class ClaustrumConsumer {
18
19
  #shutdown = new AbortController();
19
20
  #logger;
20
21
  #custody;
22
+ /** The custody's client, kept so `start()` can open its vault watch. */
23
+ #client;
21
24
  #connecting;
22
25
  #refreshing;
23
26
  #roster;
27
+ /**
28
+ * The grants named by this instance's latest discovery, with the view that
29
+ * discovery committed. Memory only: the roster file never carries it.
30
+ */
31
+ #grantReach;
24
32
  #timer;
25
33
  #started = false;
26
34
  #insula;
35
+ #vaultWatch;
36
+ #releaseInsulaWatch;
37
+ /** When this instance last completed a discovery, by `now`. */
38
+ #discoveredAt;
27
39
  constructor(options) {
28
40
  this.#options = options;
29
41
  this.#logger = options.logger ?? createLogger('claustrum');
@@ -78,6 +90,7 @@ export class ClaustrumConsumer {
78
90
  client.close();
79
91
  this.#assertOpen();
80
92
  }
93
+ this.#client = client;
81
94
  this.#custody = new ClaustrumScopedCustody({
82
95
  client,
83
96
  family: this.#options.family,
@@ -86,6 +99,8 @@ export class ClaustrumConsumer {
86
99
  requireAssertion: this.#options.requireAssertion,
87
100
  now: this.#options.now,
88
101
  logger: this.#logger,
102
+ setTimeoutImpl: this.#options.setTimeoutImpl,
103
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
89
104
  });
90
105
  if (this.#options.insula && client.insula) {
91
106
  this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
@@ -102,6 +117,43 @@ export class ClaustrumConsumer {
102
117
  snapshot() {
103
118
  return this.#roster;
104
119
  }
120
+ /**
121
+ * What this consumer is granted, from its latest discovery, without any I/O.
122
+ * It explains an empty roster: `grants === 0` means no grant at all, while
123
+ * `grants > 0` means a grant that reaches no credential (`tuples` names its
124
+ * selector). Undefined means unknown, never zero: the vault or client is too
125
+ * old to send it, nothing was discovered yet, or the roster now in memory is
126
+ * a different view than the one this instance discovered (a peer process
127
+ * held the discovery lease and its newer roster was served instead).
128
+ */
129
+ grantReach() {
130
+ const latest = this.#grantReach;
131
+ return latest && this.#roster?.view === latest.view
132
+ ? latest.reach
133
+ : undefined;
134
+ }
135
+ /** How this consumer currently learns about changes, without I/O. */
136
+ watchState() {
137
+ return Object.freeze({
138
+ vault: this.#vaultWatch?.mode ?? 'polling',
139
+ ...(this.#options.insula && {
140
+ insula: this.#insula?.source.watchMode() ?? 'polling',
141
+ }),
142
+ });
143
+ }
144
+ /** Close the watches with the connection they belong to. */
145
+ #stopWatch() {
146
+ this.#vaultWatch?.stop();
147
+ this.#vaultWatch = undefined;
148
+ this.#client = undefined;
149
+ this.#releaseInsulaWatch?.();
150
+ this.#releaseInsulaWatch = undefined;
151
+ }
152
+ /** Whether any account is using an in-memory outage receipt, without I/O. */
153
+ keptReceiptState() {
154
+ return (this.#custody?.keptReceiptState() ??
155
+ Object.freeze({ active: false, since: null }));
156
+ }
105
157
  /** Rows for `/routing`, from the last committed roster. */
106
158
  routingRows() {
107
159
  return vaultRoutingRows(this.#roster);
@@ -113,6 +165,8 @@ export class ClaustrumConsumer {
113
165
  this.#refreshing = (async () => {
114
166
  if (!(await this.#active())) {
115
167
  this.#roster = undefined;
168
+ this.#grantReach = undefined;
169
+ this.#stopWatch();
116
170
  this.#custody?.close();
117
171
  this.#custody = undefined;
118
172
  this.#insula?.release();
@@ -120,9 +174,17 @@ export class ClaustrumConsumer {
120
174
  return undefined;
121
175
  }
122
176
  const custody = await this.#getCustody();
177
+ // Capture the inventory so its grant fields can be kept beside the roster;
178
+ // the roster file itself never stores them.
179
+ let discovered;
123
180
  const roster = await refreshVaultRoster({
124
181
  path: this.#options.rosterPath,
125
- custody,
182
+ custody: {
183
+ discover: async (signal) => {
184
+ discovered = await custody.discover(signal);
185
+ return discovered;
186
+ },
187
+ },
126
188
  isActive: () => this.#active(),
127
189
  signal: this.#shutdown.signal,
128
190
  projection: () => ({
@@ -135,16 +197,35 @@ export class ClaustrumConsumer {
135
197
  this.#assertOpen();
136
198
  const changed = roster?.view !== this.#roster?.view;
137
199
  this.#roster = roster;
200
+ custody.retain(roster?.rows ?? []);
201
+ // Each discovery replaces the previous value, including with unknown. A
202
+ // refresh that served a peer's roster discovered nothing and leaves it;
203
+ // grantReach() hides it once the roster's view no longer matches.
204
+ if (discovered && roster?.view !== undefined)
205
+ this.#grantReach = discovered.grantReach
206
+ ? { view: roster.view, reach: discovered.grantReach }
207
+ : undefined;
138
208
  // Notify on view changes only: subscribers rewrite shared state on each
139
209
  // notification, and the poll runs every few seconds.
140
210
  if (roster && changed)
141
211
  this.#options.onRoster?.(roster);
212
+ this.#discoveredAt = (this.#options.now ?? Date.now)();
142
213
  return roster;
143
214
  })().finally(() => {
144
215
  this.#refreshing = undefined;
145
216
  });
146
217
  return this.#refreshing;
147
218
  }
219
+ /**
220
+ * Poll in the background and open the vault and insula watches. Until the
221
+ * vault watch is accepted, and whenever it is unavailable, every tick lists
222
+ * the vault exactly as before watches existed. While it is open a tick
223
+ * lists only when five minutes have passed since the last discovery, since
224
+ * the watch keeps no history; it still checks `isCustodyActive`, so a switch
225
+ * to local mode closes the connection as quickly as before. Insula is
226
+ * polled on the tick only while its own watch is not open. A host not in
227
+ * vault mode makes no request.
228
+ */
148
229
  start() {
149
230
  if (this.#started)
150
231
  return;
@@ -152,8 +233,10 @@ export class ClaustrumConsumer {
152
233
  this.#started = true;
153
234
  const tick = async () => {
154
235
  try {
155
- await this.refresh();
236
+ if (await this.#listDue())
237
+ await this.refresh();
156
238
  await this.refreshInsulaQuota();
239
+ this.#openWatch();
157
240
  }
158
241
  catch (error) {
159
242
  if (!this.#shutdown.signal.aborted)
@@ -172,6 +255,65 @@ export class ClaustrumConsumer {
172
255
  };
173
256
  void tick();
174
257
  }
258
+ /** Whether a background tick must list the vault now. */
259
+ async #listDue() {
260
+ if (this.#vaultWatch?.mode !== 'watching')
261
+ return true;
262
+ const now = (this.#options.now ?? Date.now)();
263
+ if (this.#discoveredAt === undefined ||
264
+ now - this.#discoveredAt >= SAFETY_POLL_MS)
265
+ return true;
266
+ // refresh() is what closes the connection when the host left vault mode.
267
+ return !(await this.#active());
268
+ }
269
+ /**
270
+ * Open the vault and insula watches once a vault connection exists. Only
271
+ * reached in vault mode: a host in local mode never connects.
272
+ */
273
+ #openWatch() {
274
+ const client = this.#client;
275
+ if (this.#shutdown.signal.aborted || !client || !this.#custody)
276
+ return;
277
+ if (!this.#vaultWatch) {
278
+ const watch = new ScopedRosterWatch({
279
+ client,
280
+ enrollmentToken: async () => (await readClaustrumEnrollmentToken(this.#options.tokenPath)).token,
281
+ view: () => this.#roster?.view,
282
+ // The existing discovery, so a watched change commits the roster and
283
+ // re-applies it to kept receipts exactly as a poll does. A refresh
284
+ // already in flight may have listed before the change was announced,
285
+ // so wait for it and list again.
286
+ discover: async () => {
287
+ while (this.#refreshing)
288
+ await this.#refreshing.catch(() => { });
289
+ await this.refresh();
290
+ },
291
+ onRefused: (error) => {
292
+ // As a poll that met the same refusal from list_scoped would.
293
+ this.#custody?.listFailed(error);
294
+ this.#options.onError?.(error);
295
+ },
296
+ onTransportLost: () => this.#custody?.transportLost(),
297
+ onReachable: () => this.#custody?.reachable(),
298
+ onError: (error) => {
299
+ if (!this.#shutdown.signal.aborted)
300
+ this.#options.onError?.(error);
301
+ },
302
+ logger: this.#logger,
303
+ now: this.#options.now,
304
+ setTimeoutImpl: this.#options.setTimeoutImpl,
305
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
306
+ });
307
+ this.#vaultWatch = watch;
308
+ watch.start();
309
+ }
310
+ if (this.#options.insula && this.#insula && !this.#releaseInsulaWatch)
311
+ this.#releaseInsulaWatch = this.#insula.source.watch({
312
+ provider: this.#options.insula.provider,
313
+ setTimeoutImpl: this.#options.setTimeoutImpl,
314
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
315
+ });
316
+ }
175
317
  /**
176
318
  * Fetch on a background tick, at most once per minute across projects with
177
319
  * the same daemon and enrollment token path. Admission must use the cache
@@ -246,20 +388,33 @@ export class ClaustrumConsumer {
246
388
  async authorize(routeId, signal) {
247
389
  this.#assertOpen(signal);
248
390
  const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
249
- if (!roster || !(await this.#active()))
391
+ if (!roster || !(await this.#active())) {
392
+ this.#custody?.close();
393
+ this.#custody = undefined;
394
+ this.#stopWatch();
395
+ this.#insula?.release();
396
+ this.#insula = undefined;
250
397
  throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
398
+ }
251
399
  const current = await readVaultRoster(this.#options.rosterPath);
252
400
  this.#assertOpen(signal);
253
401
  const observed = roster.rows.find((row) => row.routeId === routeId);
254
402
  const configured = current?.rows.find((row) => row.routeId === routeId);
255
- if (configured && !configured.enabled)
403
+ if (configured && !configured.enabled) {
404
+ if (observed)
405
+ this.#custody?.forget(observed.credentialId);
256
406
  throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
407
+ }
257
408
  if (!observed ||
258
409
  !configured ||
259
410
  configured.state !== 'active' ||
260
411
  configured.credentialId !== observed.credentialId ||
261
- configured.accountIdentity !== observed.accountIdentity)
412
+ configured.accountIdentity !== observed.accountIdentity ||
413
+ configured.credentialType !== observed.credentialType) {
414
+ if (observed)
415
+ this.#custody?.forget(observed.credentialId);
262
416
  throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
417
+ }
263
418
  const custody = await this.#wait(this.#getCustody(), signal);
264
419
  const combined = combineAbortSignals([
265
420
  this.#shutdown.signal,
@@ -272,7 +427,7 @@ export class ClaustrumConsumer {
272
427
  ...(configured.accountIdentity !== undefined && {
273
428
  accountIdentity: configured.accountIdentity,
274
429
  }),
275
- }, combined.signal);
430
+ }, combined.signal, routeId);
276
431
  }
277
432
  finally {
278
433
  combined.dispose();
@@ -296,6 +451,10 @@ export class ClaustrumConsumer {
296
451
  const signal = options.signal;
297
452
  let served = await this.authorize(routeId, signal);
298
453
  let response = await dispatch(served, signal);
454
+ if (response.status === 401 && served.fromKeptReceipt) {
455
+ await this.reportFailure(served, 401, options.reporterSource ?? 'direct');
456
+ return response;
457
+ }
299
458
  if (response.status === 401 && !signal?.aborted) {
300
459
  let current;
301
460
  try {
@@ -330,6 +489,7 @@ export class ClaustrumConsumer {
330
489
  this.#assertOpen();
331
490
  await declineVaultRoute(this.#options.rosterPath, routeId);
332
491
  this.#roster = await readVaultRoster(this.#options.rosterPath);
492
+ this.#custody?.retain(this.#roster?.rows ?? []);
333
493
  }
334
494
  async accept(routeId) {
335
495
  this.#assertOpen();
@@ -385,9 +545,11 @@ export class ClaustrumConsumer {
385
545
  if (this.#timer)
386
546
  (this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
387
547
  this.#timer = undefined;
548
+ this.#stopWatch();
388
549
  this.#custody?.close();
389
550
  this.#insula?.release();
390
551
  this.#insula = undefined;
391
552
  this.#roster = undefined;
553
+ this.#grantReach = undefined;
392
554
  }
393
555
  }