@cortexkit/antigravity-auth-core 3.0.6 → 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 (59) hide show
  1. package/dist/account-migration.d.ts +1 -1
  2. package/dist/account-migration.d.ts.map +1 -1
  3. package/dist/account-migration.js +228 -53
  4. package/dist/account-migration.js.map +1 -1
  5. package/dist/agy-transport.d.ts.map +1 -1
  6. package/dist/agy-transport.js +2 -0
  7. package/dist/agy-transport.js.map +1 -1
  8. package/dist/common-auth-embedded/claustrum/availability.d.ts +89 -0
  9. package/dist/common-auth-embedded/claustrum/availability.js +106 -0
  10. package/dist/common-auth-embedded/claustrum/consumer.d.ts +41 -1
  11. package/dist/common-auth-embedded/claustrum/consumer.js +152 -6
  12. package/dist/common-auth-embedded/claustrum/custody.d.ts +135 -14
  13. package/dist/common-auth-embedded/claustrum/custody.js +311 -16
  14. package/dist/common-auth-embedded/claustrum/errors.d.ts +1 -0
  15. package/dist/common-auth-embedded/claustrum/index.d.ts +5 -3
  16. package/dist/common-auth-embedded/claustrum/index.js +4 -1
  17. package/dist/common-auth-embedded/claustrum/insula.d.ts +48 -0
  18. package/dist/common-auth-embedded/claustrum/insula.js +286 -21
  19. package/dist/common-auth-embedded/claustrum/watch.d.ts +141 -0
  20. package/dist/common-auth-embedded/claustrum/watch.js +254 -0
  21. package/dist/common-auth-embedded/fs/private-files.js +10 -2
  22. package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +8 -2
  23. package/dist/common-auth-embedded/fs/refresh-file-lock.js +6 -1
  24. package/dist/common-auth-embedded/pi-slot/lock.js +7 -3
  25. package/dist/common-auth-embedded/quota/format.d.ts +17 -3
  26. package/dist/common-auth-embedded/quota/format.js +203 -18
  27. package/dist/common-auth-embedded/quota/index.d.ts +2 -2
  28. package/dist/common-auth-embedded/quota/index.js +1 -1
  29. package/dist/common-auth-embedded/quota/projection.d.ts +16 -0
  30. package/dist/common-auth-embedded/source-output.json +105 -73
  31. package/dist/common-auth-embedded/store/pool.d.ts +1 -1
  32. package/dist/common-auth-embedded/store/pool.js +0 -1
  33. package/dist/common-auth-embedded/store/refresh-lock.d.ts +0 -1
  34. package/dist/common-auth-embedded/store/refresh-lock.js +0 -1
  35. package/dist/credential-redaction.d.ts +8 -4
  36. package/dist/credential-redaction.d.ts.map +1 -1
  37. package/dist/credential-redaction.js +55 -28
  38. package/dist/credential-redaction.js.map +1 -1
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +3 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/pre-response-deadline.d.ts +41 -0
  44. package/dist/pre-response-deadline.d.ts.map +1 -0
  45. package/dist/pre-response-deadline.js +150 -0
  46. package/dist/pre-response-deadline.js.map +1 -0
  47. package/dist/quota-presentation.d.ts.map +1 -1
  48. package/dist/quota-presentation.js +9 -1
  49. package/dist/quota-presentation.js.map +1 -1
  50. package/dist/vault-account-source.d.ts +27 -2
  51. package/dist/vault-account-source.d.ts.map +1 -1
  52. package/dist/vault-account-source.js +12 -1
  53. package/dist/vault-account-source.js.map +1 -1
  54. package/dist/vault-folder-refusal.js +3 -3
  55. package/dist/vault-grant-reach.d.ts +23 -0
  56. package/dist/vault-grant-reach.d.ts.map +1 -0
  57. package/dist/vault-grant-reach.js +15 -0
  58. package/dist/vault-grant-reach.js.map +1 -0
  59. 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 } 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`),
@@ -71,9 +87,33 @@ export declare class ClaustrumConsumer<Q = QuotaObservation> {
71
87
  constructor(options: ClaustrumConsumerOptions<Q>);
72
88
  /** The last committed roster this instance saw, without any I/O. */
73
89
  snapshot(): VaultRosterFile | undefined;
90
+ /**
91
+ * What this consumer is granted, from its latest discovery, without any I/O.
92
+ * It explains an empty roster: `grants === 0` means no grant at all, while
93
+ * `grants > 0` means a grant that reaches no credential (`tuples` names its
94
+ * selector). Undefined means unknown, never zero: the vault or client is too
95
+ * old to send it, nothing was discovered yet, or the roster now in memory is
96
+ * a different view than the one this instance discovered (a peer process
97
+ * held the discovery lease and its newer roster was served instead).
98
+ */
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;
74
104
  /** Rows for `/routing`, from the last committed roster. */
75
105
  routingRows(): RoutingRow[];
76
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
+ */
77
117
  start(): void;
78
118
  /**
79
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
@@ -21,9 +22,18 @@ export class ClaustrumConsumer {
21
22
  #connecting;
22
23
  #refreshing;
23
24
  #roster;
25
+ /**
26
+ * The grants named by this instance's latest discovery, with the view that
27
+ * discovery committed. Memory only: the roster file never carries it.
28
+ */
29
+ #grantReach;
24
30
  #timer;
25
31
  #started = false;
26
32
  #insula;
33
+ #vaultWatch;
34
+ #releaseInsulaWatch;
35
+ /** When this instance last completed a discovery, by `now`. */
36
+ #discoveredAt;
27
37
  constructor(options) {
28
38
  this.#options = options;
29
39
  this.#logger = options.logger ?? createLogger('claustrum');
@@ -86,6 +96,8 @@ export class ClaustrumConsumer {
86
96
  requireAssertion: this.#options.requireAssertion,
87
97
  now: this.#options.now,
88
98
  logger: this.#logger,
99
+ setTimeoutImpl: this.#options.setTimeoutImpl,
100
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
89
101
  });
90
102
  if (this.#options.insula && client.insula) {
91
103
  this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
@@ -102,6 +114,42 @@ export class ClaustrumConsumer {
102
114
  snapshot() {
103
115
  return this.#roster;
104
116
  }
117
+ /**
118
+ * What this consumer is granted, from its latest discovery, without any I/O.
119
+ * It explains an empty roster: `grants === 0` means no grant at all, while
120
+ * `grants > 0` means a grant that reaches no credential (`tuples` names its
121
+ * selector). Undefined means unknown, never zero: the vault or client is too
122
+ * old to send it, nothing was discovered yet, or the roster now in memory is
123
+ * a different view than the one this instance discovered (a peer process
124
+ * held the discovery lease and its newer roster was served instead).
125
+ */
126
+ grantReach() {
127
+ const latest = this.#grantReach;
128
+ return latest && this.#roster?.view === latest.view
129
+ ? latest.reach
130
+ : undefined;
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
+ }
105
153
  /** Rows for `/routing`, from the last committed roster. */
106
154
  routingRows() {
107
155
  return vaultRoutingRows(this.#roster);
@@ -113,6 +161,8 @@ export class ClaustrumConsumer {
113
161
  this.#refreshing = (async () => {
114
162
  if (!(await this.#active())) {
115
163
  this.#roster = undefined;
164
+ this.#grantReach = undefined;
165
+ this.#stopWatch();
116
166
  this.#custody?.close();
117
167
  this.#custody = undefined;
118
168
  this.#insula?.release();
@@ -120,9 +170,17 @@ export class ClaustrumConsumer {
120
170
  return undefined;
121
171
  }
122
172
  const custody = await this.#getCustody();
173
+ // Capture the inventory so its grant fields can be kept beside the roster;
174
+ // the roster file itself never stores them.
175
+ let discovered;
123
176
  const roster = await refreshVaultRoster({
124
177
  path: this.#options.rosterPath,
125
- custody,
178
+ custody: {
179
+ discover: async (signal) => {
180
+ discovered = await custody.discover(signal);
181
+ return discovered;
182
+ },
183
+ },
126
184
  isActive: () => this.#active(),
127
185
  signal: this.#shutdown.signal,
128
186
  projection: () => ({
@@ -135,16 +193,35 @@ export class ClaustrumConsumer {
135
193
  this.#assertOpen();
136
194
  const changed = roster?.view !== this.#roster?.view;
137
195
  this.#roster = roster;
196
+ custody.retain(roster?.rows ?? []);
197
+ // Each discovery replaces the previous value, including with unknown. A
198
+ // refresh that served a peer's roster discovered nothing and leaves it;
199
+ // grantReach() hides it once the roster's view no longer matches.
200
+ if (discovered && roster?.view !== undefined)
201
+ this.#grantReach = discovered.grantReach
202
+ ? { view: roster.view, reach: discovered.grantReach }
203
+ : undefined;
138
204
  // Notify on view changes only: subscribers rewrite shared state on each
139
205
  // notification, and the poll runs every few seconds.
140
206
  if (roster && changed)
141
207
  this.#options.onRoster?.(roster);
208
+ this.#discoveredAt = (this.#options.now ?? Date.now)();
142
209
  return roster;
143
210
  })().finally(() => {
144
211
  this.#refreshing = undefined;
145
212
  });
146
213
  return this.#refreshing;
147
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
+ */
148
225
  start() {
149
226
  if (this.#started)
150
227
  return;
@@ -152,8 +229,10 @@ export class ClaustrumConsumer {
152
229
  this.#started = true;
153
230
  const tick = async () => {
154
231
  try {
155
- await this.refresh();
232
+ if (await this.#listDue())
233
+ await this.refresh();
156
234
  await this.refreshInsulaQuota();
235
+ this.#openWatch();
157
236
  }
158
237
  catch (error) {
159
238
  if (!this.#shutdown.signal.aborted)
@@ -172,6 +251,53 @@ export class ClaustrumConsumer {
172
251
  };
173
252
  void tick();
174
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
+ }
175
301
  /**
176
302
  * Fetch on a background tick, at most once per minute across projects with
177
303
  * the same daemon and enrollment token path. Admission must use the cache
@@ -246,20 +372,33 @@ export class ClaustrumConsumer {
246
372
  async authorize(routeId, signal) {
247
373
  this.#assertOpen(signal);
248
374
  const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
249
- 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;
250
381
  throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
382
+ }
251
383
  const current = await readVaultRoster(this.#options.rosterPath);
252
384
  this.#assertOpen(signal);
253
385
  const observed = roster.rows.find((row) => row.routeId === routeId);
254
386
  const configured = current?.rows.find((row) => row.routeId === routeId);
255
- if (configured && !configured.enabled)
387
+ if (configured && !configured.enabled) {
388
+ if (observed)
389
+ this.#custody?.forget(observed.credentialId);
256
390
  throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
391
+ }
257
392
  if (!observed ||
258
393
  !configured ||
259
394
  configured.state !== 'active' ||
260
395
  configured.credentialId !== observed.credentialId ||
261
- configured.accountIdentity !== observed.accountIdentity)
396
+ configured.accountIdentity !== observed.accountIdentity ||
397
+ configured.credentialType !== observed.credentialType) {
398
+ if (observed)
399
+ this.#custody?.forget(observed.credentialId);
262
400
  throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
401
+ }
263
402
  const custody = await this.#wait(this.#getCustody(), signal);
264
403
  const combined = combineAbortSignals([
265
404
  this.#shutdown.signal,
@@ -272,7 +411,7 @@ export class ClaustrumConsumer {
272
411
  ...(configured.accountIdentity !== undefined && {
273
412
  accountIdentity: configured.accountIdentity,
274
413
  }),
275
- }, combined.signal);
414
+ }, combined.signal, routeId);
276
415
  }
277
416
  finally {
278
417
  combined.dispose();
@@ -296,6 +435,10 @@ export class ClaustrumConsumer {
296
435
  const signal = options.signal;
297
436
  let served = await this.authorize(routeId, signal);
298
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
+ }
299
442
  if (response.status === 401 && !signal?.aborted) {
300
443
  let current;
301
444
  try {
@@ -330,6 +473,7 @@ export class ClaustrumConsumer {
330
473
  this.#assertOpen();
331
474
  await declineVaultRoute(this.#options.rosterPath, routeId);
332
475
  this.#roster = await readVaultRoster(this.#options.rosterPath);
476
+ this.#custody?.retain(this.#roster?.rows ?? []);
333
477
  }
334
478
  async accept(routeId) {
335
479
  this.#assertOpen();
@@ -385,9 +529,11 @@ export class ClaustrumConsumer {
385
529
  if (this.#timer)
386
530
  (this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
387
531
  this.#timer = undefined;
532
+ this.#stopWatch();
388
533
  this.#custody?.close();
389
534
  this.#insula?.release();
390
535
  this.#insula = undefined;
391
536
  this.#roster = undefined;
537
+ this.#grantReach = undefined;
392
538
  }
393
539
  }