@cortexkit/antigravity-auth-core 3.0.7 → 3.0.8

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 (28) hide show
  1. package/dist/common-auth-embedded/claustrum/availability.d.ts +89 -0
  2. package/dist/common-auth-embedded/claustrum/availability.js +106 -0
  3. package/dist/common-auth-embedded/claustrum/consumer.d.ts +31 -1
  4. package/dist/common-auth-embedded/claustrum/consumer.js +114 -5
  5. package/dist/common-auth-embedded/claustrum/custody.d.ts +106 -14
  6. package/dist/common-auth-embedded/claustrum/custody.js +294 -16
  7. package/dist/common-auth-embedded/claustrum/errors.d.ts +1 -0
  8. package/dist/common-auth-embedded/claustrum/index.d.ts +5 -3
  9. package/dist/common-auth-embedded/claustrum/index.js +4 -1
  10. package/dist/common-auth-embedded/claustrum/insula.d.ts +48 -0
  11. package/dist/common-auth-embedded/claustrum/insula.js +286 -21
  12. package/dist/common-auth-embedded/claustrum/watch.d.ts +141 -0
  13. package/dist/common-auth-embedded/claustrum/watch.js +254 -0
  14. package/dist/common-auth-embedded/fs/private-files.js +10 -2
  15. package/dist/common-auth-embedded/quota/format.d.ts +3 -0
  16. package/dist/common-auth-embedded/quota/format.js +69 -1
  17. package/dist/common-auth-embedded/quota/index.d.ts +1 -1
  18. package/dist/common-auth-embedded/quota/projection.d.ts +16 -0
  19. package/dist/common-auth-embedded/source-output.json +81 -49
  20. package/dist/quota-presentation.d.ts.map +1 -1
  21. package/dist/quota-presentation.js +9 -1
  22. package/dist/quota-presentation.js.map +1 -1
  23. package/dist/vault-account-source.d.ts +6 -1
  24. package/dist/vault-account-source.d.ts.map +1 -1
  25. package/dist/vault-account-source.js +7 -1
  26. package/dist/vault-account-source.js.map +1 -1
  27. package/dist/vault-folder-refusal.js +3 -3
  28. package/package.json +3 -3
@@ -0,0 +1,89 @@
1
+ import { ClaustrumConsumerError } from './errors.js';
2
+ export type VaultFailureClass = 'unreachable' | 'answered';
3
+ /**
4
+ * Codes from claustrum-client's decoder and its subc transport. A retry action
5
+ * alone is not evidence of an outage: even transient vault refusals must win
6
+ * over a previously served credential. New codes fail closed until reviewed.
7
+ */
8
+ export declare const VAULT_FAILURE_CLASSES: Readonly<{
9
+ ECONNREFUSED: "unreachable";
10
+ ENOENT: "unreachable";
11
+ ECONNRESET: "unreachable";
12
+ EPIPE: "unreachable";
13
+ ETIMEDOUT: "unreachable";
14
+ transport_error: "unreachable";
15
+ unavailable: "unreachable";
16
+ timeout: "unreachable";
17
+ route_closed: "unreachable";
18
+ daemon_draining: "unreachable";
19
+ connection_closed: "unreachable";
20
+ connection_dropped: "unreachable";
21
+ request_deadline: "unreachable";
22
+ deadline_exceeded_no_drop_observed: "unreachable";
23
+ module_reloading: "unreachable";
24
+ module_warming: "unreachable";
25
+ target_unavailable: "unreachable";
26
+ module_timeout: "unreachable";
27
+ invalid_response: "answered";
28
+ invalid_record_version: "answered";
29
+ invalid_expiry: "answered";
30
+ invalid_status: "answered";
31
+ invalid_params: "answered";
32
+ ttl_unsatisfiable: "answered";
33
+ not_found: "answered";
34
+ enrollment_revoked: "answered";
35
+ enrollment_invalid: "answered";
36
+ grant_denied: "answered";
37
+ credential_unavailable: "answered";
38
+ needs_reauth: "answered";
39
+ declined: "answered";
40
+ disabled: "answered";
41
+ identity_changed: "answered";
42
+ closed: "answered";
43
+ client_closed: "answered";
44
+ pending_exists: "answered";
45
+ pending_queue_full: "answered";
46
+ already_consumed: "answered";
47
+ superseded: "answered";
48
+ stale_generation: "answered";
49
+ missing_identity: "answered";
50
+ invalid_control_body: "answered";
51
+ unknown_module: "answered";
52
+ module_removed: "answered";
53
+ capability_denied: "answered";
54
+ unknown_channel: "unreachable";
55
+ stale_route_epoch: "unreachable";
56
+ capability_forbidden: "answered";
57
+ capability_unprovided: "answered";
58
+ capability_ambiguous: "answered";
59
+ invalid_capability_identifier: "answered";
60
+ binary_body_required: "answered";
61
+ binary_call_requires_call_binary: "answered";
62
+ corr_exhausted: "answered";
63
+ late_route_cleanup_failed: "answered";
64
+ reverse_request_capability_not_declared: "answered";
65
+ stale_route_handle: "answered";
66
+ reverse_request_unhandled: "answered";
67
+ handler_error: "answered";
68
+ health_error: "answered";
69
+ duplicate_module_id: "answered";
70
+ route_rejected: "answered";
71
+ lock_busy: "answered";
72
+ owner_invalid: "answered";
73
+ renewal_failed: "answered";
74
+ unsupported_platform: "answered";
75
+ EACCES: "answered";
76
+ EPERM: "answered";
77
+ ERR_PRIVATE_FILES_SECURITY: "answered";
78
+ }>;
79
+ /**
80
+ * An injected adapter may throw this ONLY when its connection/transport is
81
+ * unreachable, including a failed connect before getScoped. Do not wrap an
82
+ * adapter shutdown, cancellation, validation error or vault refusal in it.
83
+ * Its fixed message cannot echo enrollment or credential material, and its
84
+ * kind stays `unavailable` for callers handling ClaustrumConsumerError.
85
+ */
86
+ export declare class ClaustrumUnreachableError extends ClaustrumConsumerError {
87
+ constructor();
88
+ }
89
+ export declare function classifyVaultFailure(error: unknown): VaultFailureClass;
@@ -0,0 +1,106 @@
1
+ import { ClaustrumCredentialError } from '@cortexkit/claustrum-client';
2
+ import { SocketClosedError, SocketTimeoutError, SubcCallError, } from '@cortexkit/subc-client';
3
+ import { ClaustrumConsumerError } from './errors.js';
4
+ /**
5
+ * Codes from claustrum-client's decoder and its subc transport. A retry action
6
+ * alone is not evidence of an outage: even transient vault refusals must win
7
+ * over a previously served credential. New codes fail closed until reviewed.
8
+ */
9
+ export const VAULT_FAILURE_CLASSES = Object.freeze({
10
+ ECONNREFUSED: 'unreachable',
11
+ ENOENT: 'unreachable',
12
+ ECONNRESET: 'unreachable',
13
+ EPIPE: 'unreachable',
14
+ ETIMEDOUT: 'unreachable',
15
+ transport_error: 'unreachable',
16
+ unavailable: 'unreachable',
17
+ timeout: 'unreachable',
18
+ route_closed: 'unreachable',
19
+ daemon_draining: 'unreachable',
20
+ connection_closed: 'unreachable',
21
+ connection_dropped: 'unreachable',
22
+ request_deadline: 'unreachable',
23
+ deadline_exceeded_no_drop_observed: 'unreachable',
24
+ module_reloading: 'unreachable',
25
+ module_warming: 'unreachable',
26
+ target_unavailable: 'unreachable',
27
+ module_timeout: 'unreachable',
28
+ invalid_response: 'answered',
29
+ invalid_record_version: 'answered',
30
+ invalid_expiry: 'answered',
31
+ invalid_status: 'answered',
32
+ invalid_params: 'answered',
33
+ ttl_unsatisfiable: 'answered',
34
+ not_found: 'answered',
35
+ enrollment_revoked: 'answered',
36
+ enrollment_invalid: 'answered',
37
+ grant_denied: 'answered',
38
+ credential_unavailable: 'answered',
39
+ needs_reauth: 'answered',
40
+ declined: 'answered',
41
+ disabled: 'answered',
42
+ identity_changed: 'answered',
43
+ closed: 'answered',
44
+ client_closed: 'answered',
45
+ pending_exists: 'answered',
46
+ pending_queue_full: 'answered',
47
+ already_consumed: 'answered',
48
+ superseded: 'answered',
49
+ stale_generation: 'answered',
50
+ missing_identity: 'answered',
51
+ invalid_control_body: 'answered',
52
+ unknown_module: 'answered',
53
+ module_removed: 'answered',
54
+ capability_denied: 'answered',
55
+ unknown_channel: 'unreachable',
56
+ stale_route_epoch: 'unreachable',
57
+ capability_forbidden: 'answered',
58
+ capability_unprovided: 'answered',
59
+ capability_ambiguous: 'answered',
60
+ invalid_capability_identifier: 'answered',
61
+ binary_body_required: 'answered',
62
+ binary_call_requires_call_binary: 'answered',
63
+ corr_exhausted: 'answered',
64
+ late_route_cleanup_failed: 'answered',
65
+ reverse_request_capability_not_declared: 'answered',
66
+ stale_route_handle: 'answered',
67
+ reverse_request_unhandled: 'answered',
68
+ handler_error: 'answered',
69
+ health_error: 'answered',
70
+ duplicate_module_id: 'answered',
71
+ route_rejected: 'answered',
72
+ lock_busy: 'answered',
73
+ owner_invalid: 'answered',
74
+ renewal_failed: 'answered',
75
+ unsupported_platform: 'answered',
76
+ EACCES: 'answered',
77
+ EPERM: 'answered',
78
+ ERR_PRIVATE_FILES_SECURITY: 'answered',
79
+ });
80
+ /**
81
+ * An injected adapter may throw this ONLY when its connection/transport is
82
+ * unreachable, including a failed connect before getScoped. Do not wrap an
83
+ * adapter shutdown, cancellation, validation error or vault refusal in it.
84
+ * Its fixed message cannot echo enrollment or credential material, and its
85
+ * kind stays `unavailable` for callers handling ClaustrumConsumerError.
86
+ */
87
+ export class ClaustrumUnreachableError extends ClaustrumConsumerError {
88
+ constructor() {
89
+ super('unavailable', 'Claustrum scoped operation unavailable');
90
+ this.name = 'ClaustrumUnreachableError';
91
+ }
92
+ }
93
+ export function classifyVaultFailure(error) {
94
+ if (error instanceof ClaustrumUnreachableError ||
95
+ error instanceof SocketClosedError ||
96
+ error instanceof SocketTimeoutError)
97
+ return 'unreachable';
98
+ const code = error instanceof ClaustrumCredentialError || error instanceof SubcCallError
99
+ ? error.code
100
+ : error instanceof Error && 'code' in error
101
+ ? error.code
102
+ : undefined;
103
+ if (typeof code !== 'string' || !Object.hasOwn(VAULT_FAILURE_CLASSES, code))
104
+ return 'answered';
105
+ return VAULT_FAILURE_CLASSES[code];
106
+ }
@@ -1,10 +1,11 @@
1
1
  import type { ClaustrumReporterSource } from '@cortexkit/claustrum-client';
2
2
  import type { QuotaObservation } from '../quota/index.js';
3
3
  import type { RoutingRow } from '../routing/index.js';
4
- import { type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, type IdentityParser, type VaultGrantReach } from './custody.js';
4
+ import { type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, type IdentityParser, type KeptReceiptState, type VaultGrantReach } from './custody.js';
5
5
  import { type ClaustrumLogger } from './errors.js';
6
6
  import { type InsulaQuotaOptions } from './insula.js';
7
7
  import { type AccountMapper, type QuotaReceipt, type VaultRosterFile } from './roster.js';
8
+ import { type WatchMode } from './watch.js';
8
9
  export interface ClaustrumConsumerOptions<Q = QuotaObservation> {
9
10
  /**
10
11
  * The roster file: one row per vault account (route id, credential id,
@@ -41,6 +42,12 @@ export interface ClaustrumConsumerOptions<Q = QuotaObservation> {
41
42
  */
42
43
  onRoster?: (roster: VaultRosterFile) => void;
43
44
  onError?: (error: unknown) => void;
45
+ /**
46
+ * How often `start()` ticks. A tick lists the vault when no vault watch is
47
+ * open (before it is accepted, or when the vault or the client cannot
48
+ * watch), and otherwise only every five minutes, as a safety net. A tick
49
+ * polls insula (at most once a minute) only while its watch is not open.
50
+ */
44
51
  pollIntervalMs?: number;
45
52
  setTimeoutImpl?: typeof setTimeout;
46
53
  clearTimeoutImpl?: typeof clearTimeout;
@@ -49,6 +56,15 @@ export interface ClaustrumConsumerOptions<Q = QuotaObservation> {
49
56
  /** Optional background quota source. In-band readings still use recordQuota. */
50
57
  insula?: InsulaQuotaOptions<Q>;
51
58
  }
59
+ /**
60
+ * How a started consumer learns about changes, for logs and status displays.
61
+ * `insula` is present when the consumer was given an insula source. Never
62
+ * carries secrets.
63
+ */
64
+ export interface ClaustrumWatchState {
65
+ readonly vault: WatchMode;
66
+ readonly insula?: WatchMode;
67
+ }
52
68
  export interface SendOptions {
53
69
  /**
54
70
  * What kind of request this is (for example `model`, `quota`, `profile`),
@@ -81,9 +97,23 @@ export declare class ClaustrumConsumer<Q = QuotaObservation> {
81
97
  * held the discovery lease and its newer roster was served instead).
82
98
  */
83
99
  grantReach(): VaultGrantReach | undefined;
100
+ /** How this consumer currently learns about changes, without I/O. */
101
+ watchState(): ClaustrumWatchState;
102
+ /** Whether any account is using an in-memory outage receipt, without I/O. */
103
+ keptReceiptState(): KeptReceiptState;
84
104
  /** Rows for `/routing`, from the last committed roster. */
85
105
  routingRows(): RoutingRow[];
86
106
  refresh(): Promise<VaultRosterFile | undefined>;
107
+ /**
108
+ * Poll in the background and open the vault and insula watches. Until the
109
+ * vault watch is accepted, and whenever it is unavailable, every tick lists
110
+ * the vault exactly as before watches existed. While it is open a tick
111
+ * lists only when five minutes have passed since the last discovery, since
112
+ * the watch keeps no history; it still checks `isCustodyActive`, so a switch
113
+ * to local mode closes the connection as quickly as before. Insula is
114
+ * polled on the tick only while its own watch is not open. A host not in
115
+ * vault mode makes no request.
116
+ */
87
117
  start(): void;
88
118
  /**
89
119
  * Fetch on a background tick, at most once per minute across projects with
@@ -5,6 +5,7 @@ import { readClaustrumEnrollmentToken } from './enrollment.js';
5
5
  import { ClaustrumConsumerError } from './errors.js';
6
6
  import { acquireInsulaQuotaSource } from './insula.js';
7
7
  import { acceptVaultRoute, declineVaultRoute, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from './roster.js';
8
+ import { SAFETY_POLL_MS } from './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
@@ -29,6 +30,10 @@ export class ClaustrumConsumer {
29
30
  #timer;
30
31
  #started = false;
31
32
  #insula;
33
+ #vaultWatch;
34
+ #releaseInsulaWatch;
35
+ /** When this instance last completed a discovery, by `now`. */
36
+ #discoveredAt;
32
37
  constructor(options) {
33
38
  this.#options = options;
34
39
  this.#logger = options.logger ?? createLogger('claustrum');
@@ -91,6 +96,8 @@ export class ClaustrumConsumer {
91
96
  requireAssertion: this.#options.requireAssertion,
92
97
  now: this.#options.now,
93
98
  logger: this.#logger,
99
+ setTimeoutImpl: this.#options.setTimeoutImpl,
100
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
94
101
  });
95
102
  if (this.#options.insula && client.insula) {
96
103
  this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
@@ -122,6 +129,27 @@ export class ClaustrumConsumer {
122
129
  ? latest.reach
123
130
  : undefined;
124
131
  }
132
+ /** How this consumer currently learns about changes, without I/O. */
133
+ watchState() {
134
+ return Object.freeze({
135
+ vault: this.#vaultWatch?.mode ?? 'polling',
136
+ ...(this.#options.insula && {
137
+ insula: this.#insula?.source.watchMode() ?? 'polling',
138
+ }),
139
+ });
140
+ }
141
+ /** Close the watches with the connection they belong to. */
142
+ #stopWatch() {
143
+ this.#vaultWatch?.close();
144
+ this.#vaultWatch = undefined;
145
+ this.#releaseInsulaWatch?.();
146
+ this.#releaseInsulaWatch = undefined;
147
+ }
148
+ /** Whether any account is using an in-memory outage receipt, without I/O. */
149
+ keptReceiptState() {
150
+ return (this.#custody?.keptReceiptState() ??
151
+ Object.freeze({ active: false, since: null }));
152
+ }
125
153
  /** Rows for `/routing`, from the last committed roster. */
126
154
  routingRows() {
127
155
  return vaultRoutingRows(this.#roster);
@@ -134,6 +162,7 @@ export class ClaustrumConsumer {
134
162
  if (!(await this.#active())) {
135
163
  this.#roster = undefined;
136
164
  this.#grantReach = undefined;
165
+ this.#stopWatch();
137
166
  this.#custody?.close();
138
167
  this.#custody = undefined;
139
168
  this.#insula?.release();
@@ -164,6 +193,7 @@ export class ClaustrumConsumer {
164
193
  this.#assertOpen();
165
194
  const changed = roster?.view !== this.#roster?.view;
166
195
  this.#roster = roster;
196
+ custody.retain(roster?.rows ?? []);
167
197
  // Each discovery replaces the previous value, including with unknown. A
168
198
  // refresh that served a peer's roster discovered nothing and leaves it;
169
199
  // grantReach() hides it once the roster's view no longer matches.
@@ -175,12 +205,23 @@ export class ClaustrumConsumer {
175
205
  // notification, and the poll runs every few seconds.
176
206
  if (roster && changed)
177
207
  this.#options.onRoster?.(roster);
208
+ this.#discoveredAt = (this.#options.now ?? Date.now)();
178
209
  return roster;
179
210
  })().finally(() => {
180
211
  this.#refreshing = undefined;
181
212
  });
182
213
  return this.#refreshing;
183
214
  }
215
+ /**
216
+ * Poll in the background and open the vault and insula watches. Until the
217
+ * vault watch is accepted, and whenever it is unavailable, every tick lists
218
+ * the vault exactly as before watches existed. While it is open a tick
219
+ * lists only when five minutes have passed since the last discovery, since
220
+ * the watch keeps no history; it still checks `isCustodyActive`, so a switch
221
+ * to local mode closes the connection as quickly as before. Insula is
222
+ * polled on the tick only while its own watch is not open. A host not in
223
+ * vault mode makes no request.
224
+ */
184
225
  start() {
185
226
  if (this.#started)
186
227
  return;
@@ -188,8 +229,10 @@ export class ClaustrumConsumer {
188
229
  this.#started = true;
189
230
  const tick = async () => {
190
231
  try {
191
- await this.refresh();
232
+ if (await this.#listDue())
233
+ await this.refresh();
192
234
  await this.refreshInsulaQuota();
235
+ this.#openWatch();
193
236
  }
194
237
  catch (error) {
195
238
  if (!this.#shutdown.signal.aborted)
@@ -208,6 +251,53 @@ export class ClaustrumConsumer {
208
251
  };
209
252
  void tick();
210
253
  }
254
+ /** Whether a background tick must list the vault now. */
255
+ async #listDue() {
256
+ if (this.#vaultWatch?.mode !== 'watching')
257
+ return true;
258
+ const now = (this.#options.now ?? Date.now)();
259
+ if (this.#discoveredAt === undefined ||
260
+ now - this.#discoveredAt >= SAFETY_POLL_MS)
261
+ return true;
262
+ // refresh() is what closes the connection when the host left vault mode.
263
+ return !(await this.#active());
264
+ }
265
+ /**
266
+ * Open the vault and insula watches once a vault connection exists. Only
267
+ * reached in vault mode: a host in local mode never connects.
268
+ */
269
+ #openWatch() {
270
+ const custody = this.#custody;
271
+ if (this.#shutdown.signal.aborted || !custody)
272
+ return;
273
+ // The same entry point a host without a consumer uses, so both apply
274
+ // notices, refusals and outages alike.
275
+ this.#vaultWatch ??= custody.watchRoster({
276
+ view: () => this.#roster?.view,
277
+ // The existing discovery, so a watched change commits the roster and
278
+ // re-applies it to kept receipts exactly as a poll does. A refresh
279
+ // already in flight may have listed before the change was announced,
280
+ // so wait for it and list again.
281
+ onView: async () => {
282
+ while (this.#refreshing)
283
+ await this.#refreshing.catch(() => { });
284
+ await this.refresh();
285
+ },
286
+ // Custody has dropped kept receipts, as a poll meeting the same
287
+ // refusal from list_scoped would.
288
+ onRefused: (error) => this.#options.onError?.(error),
289
+ onError: (error) => {
290
+ if (!this.#shutdown.signal.aborted)
291
+ this.#options.onError?.(error);
292
+ },
293
+ });
294
+ if (this.#options.insula && this.#insula && !this.#releaseInsulaWatch)
295
+ this.#releaseInsulaWatch = this.#insula.source.watch({
296
+ provider: this.#options.insula.provider,
297
+ setTimeoutImpl: this.#options.setTimeoutImpl,
298
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
299
+ });
300
+ }
211
301
  /**
212
302
  * Fetch on a background tick, at most once per minute across projects with
213
303
  * the same daemon and enrollment token path. Admission must use the cache
@@ -282,20 +372,33 @@ export class ClaustrumConsumer {
282
372
  async authorize(routeId, signal) {
283
373
  this.#assertOpen(signal);
284
374
  const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
285
- if (!roster || !(await this.#active()))
375
+ if (!roster || !(await this.#active())) {
376
+ this.#custody?.close();
377
+ this.#custody = undefined;
378
+ this.#stopWatch();
379
+ this.#insula?.release();
380
+ this.#insula = undefined;
286
381
  throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
382
+ }
287
383
  const current = await readVaultRoster(this.#options.rosterPath);
288
384
  this.#assertOpen(signal);
289
385
  const observed = roster.rows.find((row) => row.routeId === routeId);
290
386
  const configured = current?.rows.find((row) => row.routeId === routeId);
291
- if (configured && !configured.enabled)
387
+ if (configured && !configured.enabled) {
388
+ if (observed)
389
+ this.#custody?.forget(observed.credentialId);
292
390
  throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
391
+ }
293
392
  if (!observed ||
294
393
  !configured ||
295
394
  configured.state !== 'active' ||
296
395
  configured.credentialId !== observed.credentialId ||
297
- configured.accountIdentity !== observed.accountIdentity)
396
+ configured.accountIdentity !== observed.accountIdentity ||
397
+ configured.credentialType !== observed.credentialType) {
398
+ if (observed)
399
+ this.#custody?.forget(observed.credentialId);
298
400
  throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
401
+ }
299
402
  const custody = await this.#wait(this.#getCustody(), signal);
300
403
  const combined = combineAbortSignals([
301
404
  this.#shutdown.signal,
@@ -308,7 +411,7 @@ export class ClaustrumConsumer {
308
411
  ...(configured.accountIdentity !== undefined && {
309
412
  accountIdentity: configured.accountIdentity,
310
413
  }),
311
- }, combined.signal);
414
+ }, combined.signal, routeId);
312
415
  }
313
416
  finally {
314
417
  combined.dispose();
@@ -332,6 +435,10 @@ export class ClaustrumConsumer {
332
435
  const signal = options.signal;
333
436
  let served = await this.authorize(routeId, signal);
334
437
  let response = await dispatch(served, signal);
438
+ if (response.status === 401 && served.fromKeptReceipt) {
439
+ await this.reportFailure(served, 401, options.reporterSource ?? 'direct');
440
+ return response;
441
+ }
335
442
  if (response.status === 401 && !signal?.aborted) {
336
443
  let current;
337
444
  try {
@@ -366,6 +473,7 @@ export class ClaustrumConsumer {
366
473
  this.#assertOpen();
367
474
  await declineVaultRoute(this.#options.rosterPath, routeId);
368
475
  this.#roster = await readVaultRoster(this.#options.rosterPath);
476
+ this.#custody?.retain(this.#roster?.rows ?? []);
369
477
  }
370
478
  async accept(routeId) {
371
479
  this.#assertOpen();
@@ -421,6 +529,7 @@ export class ClaustrumConsumer {
421
529
  if (this.#timer)
422
530
  (this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
423
531
  this.#timer = undefined;
532
+ this.#stopWatch();
424
533
  this.#custody?.close();
425
534
  this.#insula?.release();
426
535
  this.#insula = undefined;
@@ -1,9 +1,14 @@
1
1
  import { type ClaustrumClient, type ClaustrumReporterSource, type EnrollmentTokenFile } from '@cortexkit/claustrum-client';
2
2
  import { type ClaustrumLogger } from './errors.js';
3
3
  import type { InsulaUsageTransport } from './insula.js';
4
+ import { type ScopedWatchSource, type WatchMode } from './watch.js';
5
+ /**
6
+ * `watchScoped` is present from claustrum-client 0.12.0. Hosts detect it at
7
+ * run time and keep polling without it.
8
+ */
4
9
  export type ClaustrumScopedClient = Pick<ClaustrumClient, 'listScoped' | 'getScoped' | 'reportAuthFailureScoped' | 'close'> & {
5
10
  insula?: InsulaUsageTransport;
6
- };
11
+ } & ScopedWatchSource;
7
12
  /** The two credential types a plugin's family can hold; routing differs by type. */
8
13
  export type VaultCredentialType = 'oauth' | 'api_key';
9
14
  /**
@@ -132,6 +137,57 @@ export interface ClaustrumScopedAttempt {
132
137
  readonly accessToken: string;
133
138
  readonly recordVersion: number;
134
139
  readonly expiresAtMs: number | null;
140
+ /**
141
+ * True when this receipt was not freshly served but is the last token kept
142
+ * from an earlier read, used because the vault could not be reached. A 401
143
+ * with such a receipt must not be retried and is not reported to the vault,
144
+ * which is unreachable.
145
+ */
146
+ readonly fromKeptReceipt?: true;
147
+ }
148
+ export interface KeptReceiptState {
149
+ readonly active: boolean;
150
+ readonly since: number | null;
151
+ }
152
+ export interface ClaustrumRosterWatchOptions {
153
+ /**
154
+ * Run the host's own discovery (`custody.discover()`, then its roster
155
+ * commit). Called with the newest notice's view when that view differs from
156
+ * the last discovered one, when a notice shows a sequence gap or a new
157
+ * epoch, and on the first notice of a watch re-opened after a close (the
158
+ * time between two watches was not observed). One call runs at a time;
159
+ * notices that arrive meanwhile earn at most one more call, which runs only
160
+ * if the view still differs. A rejection goes to `onError`.
161
+ */
162
+ onView: (view: string) => unknown;
163
+ /**
164
+ * The view of the roster the host last committed. Defaults to the view of
165
+ * the last successful `custody.discover()` (outage probes excluded). Pass it
166
+ * when the host can commit a roster it did not list itself (a peer process
167
+ * held the discovery lease) or can fail after listing.
168
+ */
169
+ view?: () => string | undefined;
170
+ /**
171
+ * Called whenever the mode changes. The watch starts in `polling`. Keep the
172
+ * host's own list timer at its usual pace unless the mode is `watching`.
173
+ */
174
+ onMode?: (mode: WatchMode) => void;
175
+ /**
176
+ * The vault refused the watch, and its confirming `list_scoped` read it with
177
+ * this error (a revoked enrollment answers `not_found`). Custody has already
178
+ * applied it as it applies a refused discovery (`listFailed`: every kept
179
+ * receipt is dropped); handle it as the host handles that refusal.
180
+ */
181
+ onRefused?: (error: unknown) => void;
182
+ /** A failed `onView`, or a watch that could not be opened. */
183
+ onError?: (error: unknown) => void;
184
+ }
185
+ /** A vault watch held by a custody; see `ClaustrumScopedCustody.watchRoster`. */
186
+ export interface ClaustrumRosterWatch {
187
+ /** How the host currently learns about changes. */
188
+ readonly mode: WatchMode;
189
+ /** Stop the watch and its timers. Safe to call more than once. */
190
+ close(): void;
135
191
  }
136
192
  /** Reads the provider identity a served token executes under, when the plugin can tell. */
137
193
  export type IdentityParser = (accessToken: string) => string | undefined;
@@ -151,13 +207,15 @@ export declare function decideScopedRetryAfter401(site: string, served: Claustru
151
207
  * so it cannot expire while a long request is still using it.
152
208
  */
153
209
  export declare const SERVING_MARGIN_MS = 300000;
210
+ /** A kept receipt needs a minute of headroom, not the vault's refresh demand. */
211
+ export declare const KEPT_RECEIPT_MARGIN_MS = 60000;
154
212
  /**
155
213
  * Reads this consumer's vault credentials (list, fetch, 401 report), with the
156
- * enrollment token as authorization. Used by every host of a plugin. There is
157
- * deliberately no credential cache and no single-flight of credential reads:
158
- * each physical send is authorized by the vault, so a revoked enrollment or a
159
- * changed record takes effect on the next send. The enrollment token is
160
- * re-read per operation so an operator's reissue on disk is picked up.
214
+ * enrollment token as authorization. Used by every host of a plugin. Every
215
+ * physical send asks the vault first while reachable. Only an unreachable vault permits
216
+ * reuse of the last validated receipt, in memory and bounded by its expiry.
217
+ * The enrollment token is re-read per operation so revocation and disconnect
218
+ * still take effect without a process restart.
161
219
  */
162
220
  export declare class ClaustrumScopedCustody {
163
221
  #private;
@@ -177,7 +235,13 @@ export declare class ClaustrumScopedCustody {
177
235
  requireAssertion?: boolean;
178
236
  now?: () => number;
179
237
  logger?: ClaustrumLogger;
238
+ setTimeoutImpl?: typeof setTimeout;
239
+ clearTimeoutImpl?: typeof clearTimeout;
180
240
  });
241
+ /** A watch losing its transport is the same outage as an unreachable read. */
242
+ transportLost(error?: unknown): void;
243
+ /** Any vault answer ends transport fallback, even when it refuses access. */
244
+ reachable(): void;
181
245
  /**
182
246
  * List this consumer's credentials. A record that cannot be used is skipped
183
247
  * and warned about rather than failing the whole list, so one bad record
@@ -186,15 +250,23 @@ export declare class ClaustrumScopedCustody {
186
250
  */
187
251
  discover(signal?: AbortSignal): Promise<VaultInventory>;
188
252
  /**
189
- * Fetch the credential for one physical send and wrap it in a fresh receipt.
190
- * The vault's served identity (or, without one, the plugin's parse of the
191
- * token) must equal the roster's when both are present. Without
192
- * `requireAssertion`, absence on either side proves nothing and does not
193
- * refuse; the receipt records what the vault asserted separately from what
194
- * the roster expected. With it, a reply that does not itself name the
195
- * credential id and the expected account is refused.
253
+ * Apply a failed account listing: any answer from the vault (a refusal such
254
+ * as a revoked enrollment's `not_found`) drops every kept receipt, while an
255
+ * unreachable vault keeps them. A vault watch that ends with the same
256
+ * refusal calls this too, so both paths treat it alike.
196
257
  */
197
- authorize(identity: ClaustrumScopedIdentity, signal?: AbortSignal): Promise<ClaustrumScopedAttempt>;
258
+ listFailed(error: unknown): void;
259
+ /** Discard bearer material when a route is removed, declined or rebound. */
260
+ forget(credentialId: string): void;
261
+ /** Apply an already-held roster; this never fetches credentials or writes it. */
262
+ retain(identities: readonly (ClaustrumScopedIdentity & {
263
+ enabled: boolean;
264
+ state: string;
265
+ })[]): void;
266
+ /** A frozen, non-secret summary for a plugin's status display. */
267
+ keptReceiptState(): KeptReceiptState;
268
+ /** Ask the vault while reachable; retain one validated receipt in memory only. */
269
+ authorize(identity: ClaustrumScopedIdentity, signal?: AbortSignal, routeId?: string): Promise<ClaustrumScopedAttempt>;
198
270
  /**
199
271
  * Report that a send this consumer actually made was rejected. Only a 401
200
272
  * is reported (the vault refuses 429, 402 and 5xx reports), addressed by
@@ -203,5 +275,25 @@ export declare class ClaustrumScopedCustody {
203
275
  * custody did not issue is refused rather than reported.
204
276
  */
205
277
  reportFailure(attempt: ClaustrumScopedAttempt, status: number, reporterSource: ClaustrumReporterSource): Promise<void>;
278
+ /**
279
+ * Watch this consumer's visible vault accounts instead of listing them on a
280
+ * short timer. Opens one `watchScoped` on this custody's client and calls
281
+ * `onView` when the host must run its discovery (see
282
+ * `ClaustrumRosterWatchOptions`). A claustrum-client without `watchScoped`
283
+ * (before 0.12.0) or a vault that answers `unknown_method` reports
284
+ * `fallback` and opens nothing. A watch that closes before any notice was
285
+ * never accepted: the client reads once to learn why, and the watch reports
286
+ * `fallback` and tries one new watch after `WATCH_RETRY_MS`, never in a
287
+ * loop. A watch that closes after notices is re-opened after
288
+ * `RESUBSCRIBE_BACKOFF_MS`, doubling per close in a row. A lost transport
289
+ * starts this custody's outage, exactly as an unreachable call does, and
290
+ * the first notice of a watch ends it. Timers are unreferenced; `close()`
291
+ * on the returned watch or on this custody stops it.
292
+ *
293
+ * The watch keeps no history, so the host still lists on a slow timer while
294
+ * the mode is `watching`: every `SAFETY_POLL_MS` (five minutes). In any
295
+ * other mode the host keeps its usual list timer.
296
+ */
297
+ watchRoster(options: ClaustrumRosterWatchOptions): ClaustrumRosterWatch;
206
298
  close(): void;
207
299
  }