@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
@@ -1,14 +1,16 @@
1
1
  import { type ClaustrumClientOptions } from '@cortexkit/claustrum-client';
2
2
  import type { ClaustrumScopedClient } from './custody.js';
3
3
  export { ClaustrumCredentialError, type ClaustrumReporterSource, type EnrollmentTokenFile, } from '@cortexkit/claustrum-client';
4
- export { ClaustrumConsumer, type ClaustrumConsumerOptions, type SendOptions, } from './consumer.js';
5
- export { type AccountIdentitySource, type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultReason, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultInventory, } from './custody.js';
4
+ export { ClaustrumUnreachableError, classifyVaultFailure, VAULT_FAILURE_CLASSES, type VaultFailureClass, } from './availability.js';
5
+ export { ClaustrumConsumer, type ClaustrumConsumerOptions, type ClaustrumWatchState, type SendOptions, } from './consumer.js';
6
+ export { type AccountIdentitySource, type ClaustrumFamily, type ClaustrumRosterWatch, type ClaustrumRosterWatchOptions, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, KEPT_RECEIPT_MARGIN_MS, type KeptReceiptState, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultReason, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultGrantReach, type VaultGrantTuple, type VaultInventory, } from './custody.js';
6
7
  export { type ClaustrumEnrollmentClient, type ClaustrumEnrollmentConnection, type ClaustrumEnrollmentDisconnectResult, ClaustrumEnrollmentManager, type ClaustrumEnrollmentPaths, type ClaustrumEnrollmentResetResult, type ClaustrumEnrollmentStatus, classifyEnrollmentError, connectClaustrumEnrollmentClient, disconnectClaustrumEnrollment, type EnrollmentDisposition, enrollmentAuthority, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
7
8
  export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type ClaustrumLogger, } from './errors.js';
8
9
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
9
- export type { InsulaQuotaContext, InsulaQuotaOptions, InsulaUsageEntry, InsulaUsageTransport, InsulaUsageWindow, } from './insula.js';
10
+ export { acquireInsulaQuotaSource, type InsulaQuotaContext, type InsulaQuotaOptions, type InsulaQuotaSource, type InsulaUsageEntry, type InsulaUsageTransport, type InsulaUsageWatch, type InsulaUsageWindow, type InsulaWatchOptions, } from './insula.js';
10
11
  export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, } from './interlock.js';
11
12
  export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, type MutateVaultRosterOptions, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterLockContext, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
13
+ export { type ChangeNotice, ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, type ScopedRosterWatchOptions, type ScopedWatchEnd, type ScopedWatchHandle, type ScopedWatchNoticeShape, type ScopedWatchSource, WATCH_RETRY_MS, type WatchMode, } from './watch.js';
12
14
  /**
13
15
  * Connect the client that lists and fetches this consumer's vault credentials
14
16
  * on the request path. `connectionFile` is required: this library
@@ -6,13 +6,16 @@
6
6
  import { ClaustrumClient, } from '@cortexkit/claustrum-client';
7
7
  import { insulaUsageTransport } from './insula.js';
8
8
  export { ClaustrumCredentialError, } from '@cortexkit/claustrum-client';
9
+ export { ClaustrumUnreachableError, classifyVaultFailure, VAULT_FAILURE_CLASSES, } from './availability.js';
9
10
  export { ClaustrumConsumer, } from './consumer.js';
10
- export { ClaustrumScopedCustody, decideScopedRetryAfter401, isScopedCredentialRotation, SERVING_MARGIN_MS, } from './custody.js';
11
+ export { ClaustrumScopedCustody, decideScopedRetryAfter401, isScopedCredentialRotation, KEPT_RECEIPT_MARGIN_MS, SERVING_MARGIN_MS, } from './custody.js';
11
12
  export { ClaustrumEnrollmentManager, classifyEnrollmentError, connectClaustrumEnrollmentClient, disconnectClaustrumEnrollment, enrollmentAuthority, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
12
13
  export { ClaustrumConsumerError, } from './errors.js';
13
14
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
15
+ export { acquireInsulaQuotaSource, } from './insula.js';
14
16
  export { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
15
17
  export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, vaultRoutingRows, } from './roster.js';
18
+ export { ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, WATCH_RETRY_MS, } from './watch.js';
16
19
  /**
17
20
  * Connect the client that lists and fetches this consumer's vault credentials
18
21
  * on the request path. `connectionFile` is required: this library
@@ -1,4 +1,5 @@
1
1
  import type { ClaustrumClientOptions } from '@cortexkit/claustrum-client';
2
+ import { type WatchMode } from './watch.js';
2
3
  /** Insula's window fields are open: plugins choose which limits they understand. */
3
4
  export interface InsulaUsageWindow {
4
5
  usedPercent: number;
@@ -43,21 +44,68 @@ export interface InsulaQuotaOptions<Q> {
43
44
  */
44
45
  normalize: (entry: InsulaUsageEntry, context: InsulaQuotaContext) => Q | undefined;
45
46
  }
47
+ /**
48
+ * A held-open `usage.watch` stream. `closed` resolves when insula ends the
49
+ * stream (STREAM_END at shutdown, or a reply that ends it) or after `stop()`,
50
+ * and rejects with insula's refusal (an error with a `code`, such as
51
+ * `unknown_method` from an insula that predates the method, or
52
+ * `usage_watch_limit` when 64 watches are already open) or with the
53
+ * transport failure.
54
+ */
55
+ export interface InsulaUsageWatch {
56
+ stop(): void;
57
+ readonly closed: Promise<void>;
58
+ }
46
59
  /** Optional transport for custom scoped clients; getUsage must not retry refusals. */
47
60
  export interface InsulaUsageTransport {
48
61
  connectionFile: string;
49
62
  getUsage(): Promise<unknown>;
63
+ /**
64
+ * Open a `usage.watch` stream, passing each notice's raw JSON body to
65
+ * `onNotice`. Optional: a transport without it is polled as before.
66
+ */
67
+ watchUsage?(onNotice: (body: Uint8Array) => void): InsulaUsageWatch;
50
68
  }
51
69
  /** Constructing this transport opens no connection; getUsage opens one per call. */
52
70
  export declare function insulaUsageTransport(options: ClaustrumClientOptions & {
53
71
  connectionFile: string;
54
72
  }): InsulaUsageTransport;
73
+ export interface InsulaWatchOptions {
74
+ /**
75
+ * The provider this watcher reads. A notice that names only other
76
+ * providers then needs no read; unset means every notice does.
77
+ */
78
+ provider?: string;
79
+ /**
80
+ * setTimeout and clearTimeout for the waits before re-subscribing or retrying
81
+ * a watch. Defaults to the global timers; tests inject virtual ones.
82
+ */
83
+ setTimeoutImpl?: typeof setTimeout;
84
+ clearTimeoutImpl?: typeof clearTimeout;
85
+ }
55
86
  export declare class InsulaQuotaSource {
56
87
  #private;
57
88
  private readonly transport;
58
89
  private readonly key;
59
90
  constructor(transport: InsulaUsageTransport, key: string);
91
+ /**
92
+ * The background poll: at most one usage.get per minute per daemon and
93
+ * enrollment token. While a watch is open it reads nothing, because insula's
94
+ * notices announce every new reading. If insula stops announcing an account,
95
+ * that account's last entry grows older than the freshness limit `current()`
96
+ * applies, `current()` returns nothing for it, and the plugin falls back to
97
+ * its own provider poll for that account.
98
+ */
60
99
  refresh(now: number): Promise<void>;
100
+ /** How this source currently learns about usage changes, for logs. */
101
+ watchMode(): WatchMode;
102
+ /**
103
+ * Hold a `usage.watch` open while any watcher wants it, and re-read
104
+ * usage.get when a notice says something changed. Watchers of one source
105
+ * share one watch, since insula admits only 64 per process. Returns the
106
+ * release; the last release closes the watch.
107
+ */
108
+ watch(options?: InsulaWatchOptions): () => void;
61
109
  current(provider: string, accountId: string, now: number): {
62
110
  entry: InsulaUsageEntry;
63
111
  context: InsulaQuotaContext;
@@ -5,6 +5,8 @@ import { storeIdentity } from '@cortexkit/claustrum-client';
5
5
  // import makes bundlers emit a separate chunk that a running host loses when
6
6
  // its dist folder is rebuilt underneath it.
7
7
  import { SubcClient } from '@cortexkit/subc-client';
8
+ import { classifyVaultFailure } from './availability.js';
9
+ import { ChangeNoticeReader, CoalescedRead, RESUBSCRIBE_BACKOFF_MS, WATCH_RETRY_MS, } from './watch.js';
8
10
  /** Constructing this transport opens no connection; getUsage opens one per call. */
9
11
  export function insulaUsageTransport(options) {
10
12
  const identity = options.identity ??
@@ -49,6 +51,79 @@ export function insulaUsageTransport(options) {
49
51
  client?.close();
50
52
  }
51
53
  },
54
+ watchUsage(onNotice) {
55
+ // The watch holds its own connection for as long as it is open: each
56
+ // usage.get above opens and closes a short-lived one, and the scoped
57
+ // credential connection is never shared with insula. The method name
58
+ // alone makes the request a watch; subc-client does not yet set the
59
+ // SUBSCRIPTION flag, and insula does not require it.
60
+ let client;
61
+ let subscription;
62
+ let stopped = false;
63
+ const closed = (async () => {
64
+ let deadline;
65
+ const timeout = new Promise((_resolve, reject) => {
66
+ deadline = setTimeout(
67
+ // Coded as a timeout, so InsulaQuotaSource treats a watch that could
68
+ // not open in time as an unreachable insula and tries again later,
69
+ // not as a refusal.
70
+ () => reject(Object.assign(new Error('insula watch timeout'), {
71
+ code: 'timeout',
72
+ })), 3_000);
73
+ deadline.unref?.();
74
+ });
75
+ const open = (async () => {
76
+ const handshakeTimeoutMs = Math.min(options.handshakeTimeoutMs ?? 3_000, 3_000);
77
+ const connected = options.connector
78
+ ? await options.connector({
79
+ connectionFile: options.connectionFile,
80
+ handshakeTimeoutMs,
81
+ })
82
+ : await SubcClient.connect({
83
+ connectionFile: options.connectionFile,
84
+ handshakeTimeoutMs,
85
+ });
86
+ client = connected;
87
+ // A connection that arrives after stop() or after the open deadline
88
+ // must not keep a route or socket alive.
89
+ if (stopped) {
90
+ connected.close();
91
+ return;
92
+ }
93
+ const route = await connected.routeOpen({ kind: 'management_surface', module_id: 'insula' }, identity);
94
+ if (stopped) {
95
+ connected.close();
96
+ return;
97
+ }
98
+ subscription = connected.subscribe(route, { method: 'usage.watch' }, onNotice);
99
+ })();
100
+ try {
101
+ await Promise.race([open, timeout]);
102
+ }
103
+ catch (error) {
104
+ stopped = true;
105
+ throw error;
106
+ }
107
+ finally {
108
+ clearTimeout(deadline);
109
+ }
110
+ if (subscription)
111
+ await subscription.closed;
112
+ })().finally(() => {
113
+ stopped = true;
114
+ client?.close();
115
+ });
116
+ return {
117
+ stop() {
118
+ if (stopped)
119
+ return;
120
+ stopped = true;
121
+ subscription?.unsubscribe();
122
+ client?.close();
123
+ },
124
+ closed,
125
+ };
126
+ },
52
127
  };
53
128
  }
54
129
  const MINUTE = 60_000;
@@ -60,18 +135,92 @@ const attempts = new Map();
60
135
  function record(value) {
61
136
  return typeof value === 'object' && value !== null && !Array.isArray(value);
62
137
  }
138
+ function decodeUsageNotice(body) {
139
+ let value;
140
+ try {
141
+ value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ if (!record(value) ||
147
+ typeof value.kind !== 'string' ||
148
+ typeof value.epoch !== 'string' ||
149
+ value.epoch.length === 0 ||
150
+ typeof value.seq !== 'number' ||
151
+ !Number.isSafeInteger(value.seq) ||
152
+ value.seq < 1 ||
153
+ (value.provider !== undefined && typeof value.provider !== 'string'))
154
+ return undefined;
155
+ return {
156
+ kind: value.kind,
157
+ epoch: value.epoch,
158
+ seq: value.seq,
159
+ ...(typeof value.provider === 'string' && { provider: value.provider }),
160
+ };
161
+ }
162
+ function refusalCode(error) {
163
+ return error instanceof Error &&
164
+ 'code' in error &&
165
+ typeof error.code === 'string'
166
+ ? error.code
167
+ : undefined;
168
+ }
63
169
  export class InsulaQuotaSource {
64
170
  transport;
65
171
  key;
66
172
  #entries = [];
67
- #pending;
173
+ /** Every usage.get, polled or watch-driven, shares this one slot. */
174
+ #reads = new CoalescedRead(async () => {
175
+ try {
176
+ const reply = await this.transport.getUsage();
177
+ if (!record(reply) ||
178
+ !Array.isArray(reply.result) ||
179
+ reply.error !== undefined)
180
+ throw new Error('unavailable insula usage');
181
+ // Each response is a snapshot, not a delta. Missing accounts fall back;
182
+ // neither pending nor the entry count asserts provider completeness.
183
+ this.#entries = reply.result.filter((entry) => record(entry) && typeof entry.provider === 'string');
184
+ }
185
+ catch {
186
+ // Refusal is absence, even if the previous reading was still young.
187
+ this.#entries = [];
188
+ }
189
+ }, () => { });
190
+ #reader = new ChangeNoticeReader();
191
+ #mode = 'polling';
192
+ #watchers = 0;
193
+ /**
194
+ * How many registered watchers read every provider. Watchers limited to one
195
+ * provider are counted per provider in `#providers` instead.
196
+ */
197
+ #everyProvider = 0;
198
+ #providers = new Map();
199
+ #watch;
200
+ #timer;
201
+ #timers = {};
202
+ /**
203
+ * How many accepted watches in a row have closed; each one doubles the wait
204
+ * before the next re-subscribe. Reset once a watch brings a second notice.
205
+ */
206
+ #closes = 0;
68
207
  constructor(transport, key) {
69
208
  this.transport = transport;
70
209
  this.key = key;
71
210
  }
211
+ /**
212
+ * The background poll: at most one usage.get per minute per daemon and
213
+ * enrollment token. While a watch is open it reads nothing, because insula's
214
+ * notices announce every new reading. If insula stops announcing an account,
215
+ * that account's last entry grows older than the freshness limit `current()`
216
+ * applies, `current()` returns nothing for it, and the plugin falls back to
217
+ * its own provider poll for that account.
218
+ */
72
219
  refresh(now) {
73
- if (this.#pending)
74
- return this.#pending;
220
+ if (this.#reads.running)
221
+ return this.#reads.running;
222
+ if (this.#mode === 'watching')
223
+ return Promise.resolve();
75
224
  for (const [key, time] of attempts) {
76
225
  if (now - time >= MINUTE)
77
226
  attempts.delete(key);
@@ -81,25 +230,141 @@ export class InsulaQuotaSource {
81
230
  return Promise.resolve();
82
231
  // Reserve the minute before I/O, so concurrent projects cannot each poll.
83
232
  attempts.set(this.key, now);
84
- this.#pending = (async () => {
85
- try {
86
- const reply = await this.transport.getUsage();
87
- if (!record(reply) ||
88
- !Array.isArray(reply.result) ||
89
- reply.error !== undefined)
90
- throw new Error('unavailable insula usage');
91
- // Each response is a snapshot, not a delta. Missing accounts fall back;
92
- // neither pending nor the entry count asserts provider completeness.
93
- this.#entries = reply.result.filter((entry) => record(entry) && typeof entry.provider === 'string');
94
- }
95
- catch {
96
- // Refusal is absence, even if the previous reading was still young.
97
- this.#entries = [];
233
+ return this.#reads.request();
234
+ }
235
+ /** How this source currently learns about usage changes, for logs. */
236
+ watchMode() {
237
+ return this.#mode;
238
+ }
239
+ /**
240
+ * Hold a `usage.watch` open while any watcher wants it, and re-read
241
+ * usage.get when a notice says something changed. Watchers of one source
242
+ * share one watch, since insula admits only 64 per process. Returns the
243
+ * release; the last release closes the watch.
244
+ */
245
+ watch(options = {}) {
246
+ this.#watchers++;
247
+ const provider = options.provider;
248
+ if (provider === undefined)
249
+ this.#everyProvider++;
250
+ else
251
+ this.#providers.set(provider, (this.#providers.get(provider) ?? 0) + 1);
252
+ if (this.#watchers === 1) {
253
+ this.#timers = options;
254
+ this.#open();
255
+ }
256
+ let released = false;
257
+ return () => {
258
+ if (released)
259
+ return;
260
+ released = true;
261
+ if (provider === undefined)
262
+ this.#everyProvider--;
263
+ else {
264
+ const left = (this.#providers.get(provider) ?? 1) - 1;
265
+ if (left > 0)
266
+ this.#providers.set(provider, left);
267
+ else
268
+ this.#providers.delete(provider);
98
269
  }
99
- })().finally(() => {
100
- this.#pending = undefined;
101
- });
102
- return this.#pending;
270
+ if (--this.#watchers === 0)
271
+ this.#stopWatch();
272
+ };
273
+ }
274
+ #stopWatch() {
275
+ if (this.#timer)
276
+ (this.#timers.clearTimeoutImpl ?? clearTimeout)(this.#timer);
277
+ this.#timer = undefined;
278
+ const watch = this.#watch;
279
+ this.#watch = undefined;
280
+ watch?.handle?.stop();
281
+ this.#mode = 'polling';
282
+ }
283
+ #later(delay) {
284
+ this.#timer = (this.#timers.setTimeoutImpl ?? setTimeout)(() => {
285
+ this.#timer = undefined;
286
+ if (this.#watchers > 0 && !this.#watch)
287
+ this.#open();
288
+ }, delay);
289
+ this.#timer.unref?.();
290
+ }
291
+ #open() {
292
+ const watchUsage = this.transport.watchUsage;
293
+ if (!watchUsage) {
294
+ // A custom transport that cannot watch: poll exactly as before.
295
+ this.#mode = 'fallback';
296
+ return;
297
+ }
298
+ this.#mode = 'polling';
299
+ this.#reader.restart();
300
+ const watch = { notices: 0 };
301
+ this.#watch = watch;
302
+ let handle;
303
+ try {
304
+ handle = watchUsage.call(this.transport, (body) => this.#notice(watch, body));
305
+ }
306
+ catch (error) {
307
+ this.#closed(watch, error);
308
+ return;
309
+ }
310
+ watch.handle = handle;
311
+ handle.closed.then(() => this.#closed(watch, undefined), (error) => this.#closed(watch, error));
312
+ }
313
+ #notice(watch, body) {
314
+ // Frames already in transport can arrive after a local stop.
315
+ if (this.#watch !== watch)
316
+ return;
317
+ watch.notices++;
318
+ if (watch.notices === 1)
319
+ this.#mode = 'watching';
320
+ // A second notice means the watch stayed up past its acceptance.
321
+ if (watch.notices === 2)
322
+ this.#closes = 0;
323
+ const notice = decodeUsageNotice(body);
324
+ // An unreadable notice may hide any change: treat it as a break, and
325
+ // make the next notice read as one too.
326
+ if (!notice)
327
+ this.#reader.restart();
328
+ const continuous = notice ? this.#reader.continuous(notice) : false;
329
+ // The first notice of every watch names no provider, so it always reads:
330
+ // nothing was watched before it. After a gap or a new epoch notices were
331
+ // lost, so read even when this notice names another provider.
332
+ if (!continuous ||
333
+ notice?.provider === undefined ||
334
+ this.#everyProvider > 0 ||
335
+ this.#providers.has(notice.provider))
336
+ void this.#reads.request();
337
+ }
338
+ #closed(watch, error) {
339
+ if (this.#watch !== watch)
340
+ return;
341
+ this.#watch = undefined;
342
+ const code = refusalCode(error);
343
+ if (code === 'unknown_method' || code === 'usage_watch_limit') {
344
+ // An insula that predates usage.watch, or one already holding 64
345
+ // watches: poll exactly as before until every watcher has released.
346
+ this.#mode = 'fallback';
347
+ return;
348
+ }
349
+ if (watch.notices > 0) {
350
+ // An accepted watch that closed (insula shut down, or the connection
351
+ // dropped) gets one new watch, whose first notice reads usage.get.
352
+ const delay = Math.min(RESUBSCRIBE_BACKOFF_MS * 2 ** Math.min(this.#closes, 16), WATCH_RETRY_MS);
353
+ this.#closes++;
354
+ this.#mode = 'polling';
355
+ this.#later(delay);
356
+ return;
357
+ }
358
+ // Closed before any notice, so never accepted: read once to learn
359
+ // whether insula answers at all, then poll as before. A refusal insula
360
+ // answered stands until every watcher has released. A close it did not
361
+ // explain (a
362
+ // dropped connection, a timeout, a stream ended at once while insula
363
+ // shuts down) earns one new watch much later.
364
+ this.#mode = 'fallback';
365
+ void this.#reads.request();
366
+ if (error === undefined || classifyVaultFailure(error) === 'unreachable')
367
+ this.#later(WATCH_RETRY_MS);
103
368
  }
104
369
  current(provider, accountId, now) {
105
370
  for (const entry of this.#entries) {
@@ -0,0 +1,141 @@
1
+ import type { ClaustrumLogger } from './errors.js';
2
+ /**
3
+ * How a host currently learns about changes, for logs and status displays.
4
+ * `watching`: a watch was accepted and is open, so polling is reduced to a
5
+ * slow safety read. `polling`: no watch is open yet (not started, opening, or
6
+ * re-subscribing after a close), so the host polls at its usual interval.
7
+ * `fallback`: the watch is unavailable (an older module or client, a refusal,
8
+ * the watch limit, or repeated failures), so the host polls at its usual
9
+ * interval and may try a watch again later. Never carries secrets.
10
+ */
11
+ export type WatchMode = 'watching' | 'polling' | 'fallback';
12
+ /**
13
+ * While a vault watch is healthy, the roster is still read this often: the
14
+ * watch carries no history, so a lost notice or a silently dead stream is
15
+ * caught within this period.
16
+ */
17
+ export declare const SAFETY_POLL_MS: number;
18
+ /**
19
+ * After a watch fails without being accepted, the host polls and tries one
20
+ * new watch this much later, so a refusing or draining module is never asked
21
+ * in a tight loop.
22
+ */
23
+ export declare const WATCH_RETRY_MS: number;
24
+ /**
25
+ * First wait before re-subscribing a watch that closed after it had been
26
+ * accepted. It doubles for each further close in a row, up to WATCH_RETRY_MS.
27
+ */
28
+ export declare const RESUBSCRIBE_BACKOFF_MS = 1000;
29
+ /**
30
+ * The fields every change notice carries, from the vault's watchScoped and
31
+ * insula's usage.watch alike.
32
+ */
33
+ export interface ChangeNotice {
34
+ readonly kind: string;
35
+ readonly epoch: string;
36
+ readonly seq: number;
37
+ }
38
+ /**
39
+ * Tells whether a notice follows the previous one with nothing lost between
40
+ * them. `seq` restarts at 1 for every watch and `epoch` names the sending
41
+ * module's process lifetime. A sequence gap (notices are sheddable), a
42
+ * different epoch (the module restarted) or a notice of an unknown kind all
43
+ * mean changes may have been missed, so the caller must re-read everything.
44
+ */
45
+ export declare class ChangeNoticeReader {
46
+ #private;
47
+ /**
48
+ * Begin a new watch, whose first notice is seq 1 again. The epoch is kept,
49
+ * so a module restart between two watches still reads as a break.
50
+ */
51
+ restart(): void;
52
+ continuous(notice: ChangeNotice): boolean;
53
+ }
54
+ /**
55
+ * Runs `read` with at most one call in flight and at most one more queued
56
+ * behind it, however many requests arrive meanwhile. A request made while a
57
+ * read is running may have arrived after that read took its snapshot, so it
58
+ * earns exactly one more read; any further requests share that one.
59
+ */
60
+ export declare class CoalescedRead {
61
+ #private;
62
+ constructor(read: () => Promise<void>, onError: (error: unknown) => void);
63
+ /** The read in flight (including its queued follow-up), if any. */
64
+ get running(): Promise<void> | undefined;
65
+ request(): Promise<void>;
66
+ }
67
+ /** A vault watch notice: `view` is the same digest `list_scoped` returns. */
68
+ export interface ScopedWatchNoticeShape extends ChangeNotice {
69
+ readonly view: string;
70
+ }
71
+ /**
72
+ * How a vault watch ended, as claustrum-client 0.12.0 reports it. `refused`
73
+ * and `not_accepting` both mean the watch closed before its first notice; the
74
+ * client then made one `list_scoped` read to learn why, and `refused` carries
75
+ * that read's refusal.
76
+ */
77
+ export type ScopedWatchEnd = {
78
+ readonly kind: 'refused';
79
+ readonly error: unknown;
80
+ } | {
81
+ readonly kind: 'transport_lost';
82
+ readonly error: unknown;
83
+ } | {
84
+ readonly kind: 'ended' | 'not_accepting' | 'unsupported' | 'stopped';
85
+ };
86
+ export interface ScopedWatchHandle {
87
+ stop(): void;
88
+ readonly closed: Promise<ScopedWatchEnd>;
89
+ }
90
+ /**
91
+ * The part of a vault client that can watch. claustrum-client added
92
+ * `watchScoped` in 0.12.0; the peer range still admits 0.6.2, so it is
93
+ * optional and detected at run time.
94
+ */
95
+ export interface ScopedWatchSource {
96
+ watchScoped?(onNotice: (notice: ScopedWatchNoticeShape) => void, options?: {
97
+ enrollmentToken?: string;
98
+ }): ScopedWatchHandle;
99
+ }
100
+ export interface ScopedRosterWatchOptions {
101
+ client: ScopedWatchSource;
102
+ /** The enrollment token, read again for every watch so a new one is used. */
103
+ enrollmentToken: () => Promise<string>;
104
+ /** The view of the roster this host last discovered; undefined for none. */
105
+ view: () => string | undefined;
106
+ /**
107
+ * The host's existing discovery: `list_scoped`, the roster commit and
108
+ * everything that follows it. The watch never reads the vault list itself.
109
+ * `view` is the view the newest notice carried.
110
+ */
111
+ discover: (view: string) => Promise<unknown>;
112
+ /**
113
+ * The vault refused the watch, and the confirming `list_scoped` read it
114
+ * with this error (a revoked enrollment ends both with `not_found`). Handle
115
+ * it as discovery handles the same refusal from `list_scoped`.
116
+ */
117
+ onRefused: (error: unknown) => void;
118
+ onTransportLost?: () => void;
119
+ onReachable?: () => void;
120
+ onError?: (error: unknown) => void;
121
+ onMode?: (mode: WatchMode) => void;
122
+ logger?: ClaustrumLogger;
123
+ now?: () => number;
124
+ setTimeoutImpl?: typeof setTimeout;
125
+ clearTimeoutImpl?: typeof clearTimeout;
126
+ }
127
+ /**
128
+ * Keeps one vault `watchScoped` open for a host and runs the host's discovery
129
+ * when a notice shows the visible account list changed. Discovery runs only
130
+ * when the notice's view differs from the last discovered roster's view, when
131
+ * a notice shows a gap or a new epoch, or on the first notice of a watch that
132
+ * was re-subscribed after a close (the time between two watches was not
133
+ * observed). It never opens a second watch while one is open.
134
+ */
135
+ export declare class ScopedRosterWatch {
136
+ #private;
137
+ constructor(options: ScopedRosterWatchOptions);
138
+ get mode(): WatchMode;
139
+ start(): void;
140
+ stop(): void;
141
+ }