@cortexkit/antigravity-auth-core 3.0.29 → 3.0.30

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 (35) hide show
  1. package/dist/common-auth-embedded/claustrum/consumer.d.ts +12 -0
  2. package/dist/common-auth-embedded/claustrum/consumer.js +36 -0
  3. package/dist/common-auth-embedded/claustrum/custody.d.ts +16 -4
  4. package/dist/common-auth-embedded/claustrum/custody.js +74 -9
  5. package/dist/common-auth-embedded/claustrum/hub.d.ts +3 -2
  6. package/dist/common-auth-embedded/claustrum/hub.js +8 -0
  7. package/dist/common-auth-embedded/claustrum/index.d.ts +1 -0
  8. package/dist/common-auth-embedded/claustrum/roster.d.ts +2 -0
  9. package/dist/common-auth-embedded/claustrum/roster.js +2 -0
  10. package/dist/common-auth-embedded/claustrum/usage.d.ts +41 -0
  11. package/dist/common-auth-embedded/claustrum/usage.js +145 -0
  12. package/dist/common-auth-embedded/commands/builtins.d.ts +5 -0
  13. package/dist/common-auth-embedded/commands/builtins.js +38 -0
  14. package/dist/common-auth-embedded/fs/private-files.d.ts +5 -0
  15. package/dist/common-auth-embedded/fs/private-files.js +7 -0
  16. package/dist/common-auth-embedded/fs/refresh-file-lock.js +142 -47
  17. package/dist/common-auth-embedded/fs/with-lock.js +2 -5
  18. package/dist/common-auth-embedded/routing/admission.d.ts +7 -2
  19. package/dist/common-auth-embedded/routing/admission.js +17 -7
  20. package/dist/common-auth-embedded/source-output.json +126 -94
  21. package/dist/common-auth-embedded/store/attribution.js +3 -2
  22. package/dist/common-auth-embedded/store/errors.d.ts +1 -1
  23. package/dist/common-auth-embedded/store/hooks.d.ts +5 -1
  24. package/dist/common-auth-embedded/store/hooks.js +20 -5
  25. package/dist/common-auth-embedded/store/index.d.ts +1 -0
  26. package/dist/common-auth-embedded/store/limits-namespace.d.ts +7 -0
  27. package/dist/common-auth-embedded/store/limits-namespace.js +34 -0
  28. package/dist/common-auth-embedded/store/pool.d.ts +10 -1
  29. package/dist/common-auth-embedded/store/pool.js +2 -0
  30. package/dist/common-auth-embedded/store/provider-state.js +5 -5
  31. package/dist/common-auth-embedded/store/refresh.js +1 -1
  32. package/dist/common-auth-embedded/store/schema.js +9 -4
  33. package/dist/common-auth-embedded/store/settings.d.ts +2 -0
  34. package/dist/common-auth-embedded/store/settings.js +11 -4
  35. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { type ClaustrumLogger } from './errors.js';
6
6
  import type { CustodyLease } from './hub.js';
7
7
  import { type InsulaQuotaOptions, type InsulaQuotaResult } from './insula.js';
8
8
  import { type AccountMapper, type ProjectionOptions, type QuotaReceipt, type ReorderVaultRosterResult, type VaultRosterFile } from './roster.js';
9
+ import type { ClaustrumUsageReading, ClaustrumUsageReportState } from './usage.js';
9
10
  import { type WatchMode } from './watch.js';
10
11
  export interface ClaustrumConsumerOptions<Q = QuotaObservation> {
11
12
  /**
@@ -229,6 +230,17 @@ export declare class ClaustrumConsumer<Q = QuotaObservation> {
229
230
  * poll, and a route whose credential or account changed is refused.
230
231
  */
231
232
  authorize(routeId: string, signal?: AbortSignal): Promise<ClaustrumScopedAttempt>;
233
+ /**
234
+ * Queue reply quota metadata without waiting or throwing. Pass rate-limit
235
+ * headers only, never tokens: the library does not filter header names.
236
+ * The credential must have been served by this host and still belong to the route.
237
+ */
238
+ reportUsage(input: {
239
+ routeId: string;
240
+ credentialId: string;
241
+ reading: ClaustrumUsageReading;
242
+ }): void;
243
+ usageReportState(): ClaustrumUsageReportState;
232
244
  reportFailure(attempt: ClaustrumScopedAttempt, status: number, source: ClaustrumReporterSource): Promise<void>;
233
245
  /**
234
246
  * Send on a vault route. `dispatch` builds and sends the request with the
@@ -738,6 +738,42 @@ export class ClaustrumConsumer {
738
738
  combined.dispose();
739
739
  }
740
740
  }
741
+ /**
742
+ * Queue reply quota metadata without waiting or throwing. Pass rate-limit
743
+ * headers only, never tokens: the library does not filter header names.
744
+ * The credential must have been served by this host and still belong to the route.
745
+ */
746
+ reportUsage(input) {
747
+ if (this.#shutdown.signal.aborted)
748
+ return;
749
+ try {
750
+ const row = this.#roster?.rows.find((row) => row.routeId === input.routeId);
751
+ if (!row?.enabled ||
752
+ row.state !== 'active' ||
753
+ (row.credentialId !== input.credentialId &&
754
+ !row.aliases?.includes(input.credentialId)))
755
+ return;
756
+ if (this.#hub) {
757
+ if (this.#lease && this.#hub.leaseLive(this.#lease))
758
+ this.#lease.custody.reportUsage(input);
759
+ }
760
+ else
761
+ this.#custody?.reportUsage(input);
762
+ }
763
+ catch {
764
+ // Quota bookkeeping must never reject a provider request.
765
+ }
766
+ }
767
+ usageReportState() {
768
+ try {
769
+ return ((this.#hub
770
+ ? this.#lease?.custody
771
+ : this.#custody)?.usageReportState() ?? { pending: 0, inFlight: false, dropped: 0 });
772
+ }
773
+ catch {
774
+ return { pending: 0, inFlight: false, dropped: 0 };
775
+ }
776
+ }
741
777
  async reportFailure(attempt, status, source) {
742
778
  this.#assertOpen();
743
779
  if (this.#hub) {
@@ -1,14 +1,15 @@
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 ClaustrumUsageReading, type ClaustrumUsageReportState, type ScopedUsageSource } from './usage.js';
4
5
  import { type ScopedWatchSource, type WatchMode } from './watch.js';
5
6
  /**
6
- * `watchScoped` is present from claustrum-client 0.12.0. Hosts detect it at
7
- * run time and keep polling without it.
7
+ * claustrum-client adds watchScoped in 0.12.0 and reportUsageScoped in 0.14.0.
8
+ * Older clients keep polling and simply do not send reply quota readings.
8
9
  */
9
10
  export type ClaustrumScopedClient = Pick<ClaustrumClient, 'listScoped' | 'getScoped' | 'reportAuthFailureScoped' | 'close'> & {
10
11
  insula?: InsulaUsageTransport;
11
- } & ScopedWatchSource;
12
+ } & ScopedWatchSource & ScopedUsageSource;
12
13
  /** The two credential types a plugin's family can hold; routing differs by type. */
13
14
  export type VaultCredentialType = 'oauth' | 'api_key';
14
15
  /**
@@ -332,7 +333,18 @@ export declare class ClaustrumScopedCustody {
332
333
  * other mode the host keeps its usual list timer.
333
334
  */
334
335
  watchRoster(options: ClaustrumRosterWatchOptions): ClaustrumRosterWatch;
336
+ /**
337
+ * Queue reply quota metadata and return synchronously. Pass rate-limit
338
+ * headers only, never tokens: the library does not filter header names.
339
+ * Only credentials served by this custody and retained in its roster report.
340
+ * Hosts managing their own roster must call retain() when that roster changes.
341
+ */
342
+ reportUsage(input: {
343
+ credentialId: string;
344
+ reading: ClaustrumUsageReading;
345
+ }): void;
346
+ usageReportState(): ClaustrumUsageReportState;
335
347
  close(): void;
336
- /** Stop immediately; allow token reads 2 s and share an owner's timeout warning. */
348
+ /** Stop accepting work; give token reads and reply reports one shared 2 s drain. */
337
349
  dispose(onTimeout?: (pendingOperations: number) => void): Promise<void>;
338
350
  }
@@ -6,6 +6,7 @@ import { readClaustrumEnrollmentToken } from './enrollment.js';
6
6
  import { ClaustrumConsumerError } from './errors.js';
7
7
  import { CUSTODY_PLACEHOLDER_PREFIX } from './host-slot.js';
8
8
  import { drainShutdown, shutdownWarning } from './shutdown.js';
9
+ import { ScopedUsageReporter, } from './usage.js';
9
10
  import { ScopedRosterWatch, } from './watch.js';
10
11
  /** Only a new version of the same account may replace an in-flight 401. */
11
12
  export function isScopedCredentialRotation(served, current) {
@@ -28,6 +29,14 @@ function scopedRetryReason(served, current) {
28
29
  return 'rotated';
29
30
  }
30
31
  const defaultLogger = createLogger('claustrum');
32
+ function checkedEnrollmentToken(value) {
33
+ if (!/^[0-9a-f]{64}$/.test(value.token) ||
34
+ !Number.isSafeInteger(value.token_generation) ||
35
+ value.token_generation < 1) {
36
+ throw new ClaustrumConsumerError('invalid-token', 'Invalid Claustrum enrollment token');
37
+ }
38
+ return value.token;
39
+ }
31
40
  // Opt-in diagnostics let tests retain wait phases without logging each vault read.
32
41
  const phaseDiagnostics = channel('common-auth.claustrum.phases');
33
42
  /**
@@ -183,6 +192,8 @@ export class ClaustrumScopedCustody {
183
192
  #reports = new WeakMap();
184
193
  #kept = new Map();
185
194
  #keptAttempts = new WeakMap();
195
+ #usage;
196
+ #usageServed = new Map();
186
197
  #setTimeout;
187
198
  #clearTimeout;
188
199
  #outage = false;
@@ -197,6 +208,7 @@ export class ClaustrumScopedCustody {
197
208
  #watches = new Set();
198
209
  #tokenReads = new Set();
199
210
  #warnShutdown;
211
+ #onDrainTimeout;
200
212
  #disposing;
201
213
  constructor(options) {
202
214
  const tokenPath = options.tokenPath;
@@ -218,6 +230,14 @@ export class ClaustrumScopedCustody {
218
230
  this.#warnShutdown = shutdownWarning(this.#logger);
219
231
  this.#setTimeout = options.setTimeoutImpl ?? setTimeout;
220
232
  this.#clearTimeout = options.clearTimeoutImpl ?? clearTimeout;
233
+ this.#usage = new ScopedUsageReporter({
234
+ client: this.#client,
235
+ owns: (id) => this.#usageServed.has(id),
236
+ token: async () => checkedEnrollmentToken(await this.#readToken()),
237
+ logger: this.#logger,
238
+ setTimeout: this.#setTimeout,
239
+ clearTimeout: this.#clearTimeout,
240
+ });
221
241
  }
222
242
  /** A watch losing its transport is the same outage as an unreachable read. */
223
243
  transportLost(error) {
@@ -303,12 +323,7 @@ export class ClaustrumScopedCustody {
303
323
  });
304
324
  }
305
325
  this.#check(signal);
306
- if (!/^[0-9a-f]{64}$/.test(value.token) ||
307
- !Number.isSafeInteger(value.token_generation) ||
308
- value.token_generation < 1) {
309
- throw new ClaustrumConsumerError('invalid-token', 'Invalid Claustrum enrollment token');
310
- }
311
- return value.token;
326
+ return checkedEnrollmentToken(value);
312
327
  }
313
328
  async #call(operation, signal) {
314
329
  this.#check(signal);
@@ -370,6 +385,7 @@ export class ClaustrumScopedCustody {
370
385
  }
371
386
  catch (error) {
372
387
  this.#kept.clear();
388
+ this.#clearUsageOwnership();
373
389
  throw error;
374
390
  }
375
391
  if (phaseDiagnostics.hasSubscribers)
@@ -473,11 +489,19 @@ export class ClaustrumScopedCustody {
473
489
  else {
474
490
  this.reachable();
475
491
  this.#kept.clear();
492
+ this.#clearUsageOwnership();
476
493
  }
477
494
  }
478
495
  /** Discard bearer material when a route is removed, declined or rebound. */
479
496
  forget(credentialId) {
480
497
  this.#kept.delete(credentialId);
498
+ this.#usageServed.delete(credentialId);
499
+ this.#usage.forget(credentialId);
500
+ }
501
+ #clearUsageOwnership() {
502
+ for (const id of this.#usageServed.keys())
503
+ this.#usage.forget(id);
504
+ this.#usageServed.clear();
481
505
  }
482
506
  /** Apply an already-held roster; this never fetches credentials or writes it. */
483
507
  retain(identities) {
@@ -490,6 +514,14 @@ export class ClaustrumScopedCustody {
490
514
  identity.accountIdentity !== kept.attempt.expectedAccountIdentity)
491
515
  this.forget(id);
492
516
  }
517
+ for (const [id, served] of this.#usageServed) {
518
+ const identity = current.get(id);
519
+ if (!identity?.enabled ||
520
+ identity.state !== 'active' ||
521
+ identity.credentialType !== served.credentialType ||
522
+ identity.accountIdentity !== served.accountIdentity)
523
+ this.forget(id);
524
+ }
493
525
  }
494
526
  /** A frozen, non-secret summary for a plugin's status display. */
495
527
  keptReceiptState() {
@@ -527,6 +559,7 @@ export class ClaustrumScopedCustody {
527
559
  }
528
560
  catch (error) {
529
561
  this.#kept.clear();
562
+ this.#clearUsageOwnership();
530
563
  throw error;
531
564
  }
532
565
  if (this.#outage && kept) {
@@ -536,6 +569,7 @@ export class ClaustrumScopedCustody {
536
569
  try {
537
570
  const attempt = await this.#authorizeFresh({ credentialId, credentialType, accountIdentity }, token, signal);
538
571
  this.#kept.set(credentialId, { attempt, routeId, since: null });
572
+ this.#usageServed.set(credentialId, { ...identity });
539
573
  return attempt;
540
574
  }
541
575
  catch (error) {
@@ -750,6 +784,25 @@ export class ClaustrumScopedCustody {
750
784
  },
751
785
  };
752
786
  }
787
+ /**
788
+ * Queue reply quota metadata and return synchronously. Pass rate-limit
789
+ * headers only, never tokens: the library does not filter header names.
790
+ * Only credentials served by this custody and retained in its roster report.
791
+ * Hosts managing their own roster must call retain() when that roster changes.
792
+ */
793
+ reportUsage(input) {
794
+ if (this.#closed)
795
+ return;
796
+ try {
797
+ this.#usage.report(input.credentialId, input.reading);
798
+ }
799
+ catch {
800
+ // Quota bookkeeping must never reject a provider request.
801
+ }
802
+ }
803
+ usageReportState() {
804
+ return this.#usage.state();
805
+ }
753
806
  close() {
754
807
  if (this.#closed)
755
808
  return;
@@ -761,12 +814,24 @@ export class ClaustrumScopedCustody {
761
814
  watch.stop();
762
815
  this.#watches.clear();
763
816
  this.#kept.clear();
764
- this.#client.close();
817
+ const usage = this.#usage.close();
818
+ if (usage) {
819
+ this.#tokenReads.add(usage);
820
+ void usage.then(() => this.#tokenReads.delete(usage));
821
+ }
822
+ else
823
+ this.#client.close();
824
+ this.#disposing = drainShutdown(this.#tokenReads, (count) => (this.#onDrainTimeout ?? this.#warnShutdown)(count)).finally(() => {
825
+ this.#usage.drop();
826
+ this.#usageServed.clear();
827
+ if (usage)
828
+ this.#client.close();
829
+ });
765
830
  }
766
- /** Stop immediately; allow token reads 2 s and share an owner's timeout warning. */
831
+ /** Stop accepting work; give token reads and reply reports one shared 2 s drain. */
767
832
  async dispose(onTimeout = this.#warnShutdown) {
833
+ this.#onDrainTimeout = onTimeout;
768
834
  this.close();
769
- this.#disposing ??= drainShutdown(this.#tokenReads, onTimeout);
770
835
  await this.#disposing;
771
836
  }
772
837
  }
@@ -107,11 +107,12 @@ export interface ClaustrumHubMemberOptions<_Q = QuotaObservation> {
107
107
  logger?: ClaustrumLogger;
108
108
  }
109
109
  /** What a lease may do with the shared custody. */
110
- export type ClaustrumLeasedCustody = Pick<ClaustrumScopedCustody, 'authorize' | 'reportFailure' | 'forget' | 'keptReceiptState'>;
110
+ export type ClaustrumLeasedCustody = Pick<ClaustrumScopedCustody, 'authorize' | 'reportFailure' | 'reportUsage' | 'usageReportState' | 'forget' | 'keptReceiptState'>;
111
111
  /**
112
112
  * One acquisition of the hub's shared custody. `custody` throws `closed`
113
113
  * after `release()`, and once the connection it belongs to ended for any
114
- * reason, vault mode going off included. `release()` is idempotent.
114
+ * reason, vault mode going off included. Best-effort `reportUsage` instead
115
+ * ignores reports through an ended lease. `release()` is idempotent.
115
116
  */
116
117
  export interface CustodyLease {
117
118
  readonly custody: ClaustrumLeasedCustody;
@@ -523,6 +523,14 @@ export class ClaustrumDiscoveryHub {
523
523
  check();
524
524
  return custody.reportFailure(attempt, status, source);
525
525
  },
526
+ reportUsage: (input) => {
527
+ if (!state.released && !generation.ended)
528
+ custody.reportUsage(input);
529
+ },
530
+ usageReportState: () => {
531
+ check();
532
+ return custody.usageReportState();
533
+ },
526
534
  forget: (credentialId) => {
527
535
  check();
528
536
  custody.forget(credentialId);
@@ -12,6 +12,7 @@ export { acquireInsulaQuotaSource, type InsulaQuotaContext, type InsulaQuotaLook
12
12
  export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, } from './interlock.js';
13
13
  export { drainVaultQuota } from './quota-writer.js';
14
14
  export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, type MutateVaultRosterOptions, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, type ReorderVaultRosterOptions, type ReorderVaultRosterResult, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterLockContext, type VaultRosterRow, type VaultRosterSeed, vaultRosterSeed, vaultRoutingRows, } from './roster.js';
15
+ export type { ClaustrumUsageReading, ClaustrumUsageReportState, } from './usage.js';
15
16
  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';
16
17
  /** How long `connectClaustrumScopedClient` waits by default. */
17
18
  export declare const SCOPED_CONNECT_TIMEOUT_MS = 10000;
@@ -48,6 +48,8 @@ export interface VaultRosterFile {
48
48
  complete: boolean;
49
49
  /** The unusable records of an incomplete reply: a safe id and a fixed reason. */
50
50
  rejected?: SkippedVaultRecord[];
51
+ /** Opaque limits data; roster-only writers preserve even newer schemas. */
52
+ limits?: unknown;
51
53
  rows: VaultRosterRow[];
52
54
  declined: DeclinedAccount[];
53
55
  }
@@ -209,6 +209,7 @@ export function projectVaultRoster(previous, inventory, options = {}) {
209
209
  return {
210
210
  version: 1,
211
211
  view: inventory.view,
212
+ ...(previous?.limits !== undefined && { limits: previous.limits }),
212
213
  complete,
213
214
  ...(!complete && {
214
215
  rejected: inventory.skipped.map((record) => ({
@@ -319,6 +320,7 @@ function decodeRoster(value) {
319
320
  // that was used whole, so it reads as complete.
320
321
  complete: value.complete !== false,
321
322
  ...(rejected && { rejected }),
323
+ ...(value.limits !== undefined && { limits: value.limits }),
322
324
  rows,
323
325
  declined,
324
326
  };
@@ -0,0 +1,41 @@
1
+ import type { ClaustrumLogger } from './errors.js';
2
+ /** Reply quota metadata, never authorization headers or bearer material. */
3
+ export interface ClaustrumUsageReading {
4
+ provider: string;
5
+ observedAtMs: number;
6
+ headers?: Record<string, string>;
7
+ frame?: unknown;
8
+ account?: string;
9
+ }
10
+ /** Structural to also typecheck with the claustrum-client 0.6.2 peer minimum. */
11
+ export interface ScopedUsageSource {
12
+ reportUsageScoped?(input: {
13
+ credentialId: string;
14
+ reading: Record<string, unknown>;
15
+ enrollmentToken?: string;
16
+ }): Promise<void>;
17
+ }
18
+ export interface ClaustrumUsageReportState {
19
+ readonly pending: number;
20
+ readonly inFlight: boolean;
21
+ readonly dropped: number;
22
+ }
23
+ /** One queue per custody connection, shared by every hub member on it. */
24
+ export declare class ScopedUsageReporter {
25
+ #private;
26
+ private readonly options;
27
+ constructor(options: {
28
+ client: ScopedUsageSource;
29
+ owns: (credentialId: string) => boolean;
30
+ token: () => Promise<string>;
31
+ logger: ClaustrumLogger;
32
+ setTimeout: typeof setTimeout;
33
+ clearTimeout: typeof clearTimeout;
34
+ });
35
+ state(): ClaustrumUsageReportState;
36
+ report(credentialId: string, reading: ClaustrumUsageReading): void;
37
+ forget(credentialId: string): void;
38
+ /** Start the final flush; the custody's existing shutdown drain bounds it. */
39
+ close(): Promise<void> | undefined;
40
+ drop(): void;
41
+ }
@@ -0,0 +1,145 @@
1
+ import { ClaustrumCredentialError } from '@cortexkit/claustrum-client';
2
+ function plain(value) {
3
+ return (value !== null &&
4
+ typeof value === 'object' &&
5
+ (Object.getPrototypeOf(value) === Object.prototype ||
6
+ Object.getPrototypeOf(value) === null));
7
+ }
8
+ /** One queue per custody connection, shared by every hub member on it. */
9
+ export class ScopedUsageReporter {
10
+ options;
11
+ #queue = new Map();
12
+ #latest = new Map();
13
+ #errors = new Set();
14
+ #timer;
15
+ #running;
16
+ #closing = false;
17
+ #stopped = false;
18
+ #dropped = 0;
19
+ constructor(options) {
20
+ this.options = options;
21
+ }
22
+ state() {
23
+ return {
24
+ pending: this.#queue.size,
25
+ inFlight: this.#running !== undefined,
26
+ dropped: this.#dropped,
27
+ };
28
+ }
29
+ report(credentialId, reading) {
30
+ if (this.#closing || !this.options.client.reportUsageScoped)
31
+ return;
32
+ try {
33
+ if (!this.options.owns(credentialId)) {
34
+ this.#dropped++;
35
+ return;
36
+ }
37
+ if (!plain(reading) || 'credentialId' in reading || 'vouched' in reading)
38
+ throw new Error('invalid reading');
39
+ const json = JSON.stringify(reading);
40
+ if (Buffer.byteLength(json, 'utf8') > 15_360)
41
+ throw new Error('oversized reading');
42
+ // Snapshot now: a caller mutating its reply metadata later cannot change
43
+ // an already validated queued reading, including its ordering timestamp.
44
+ const copy = JSON.parse(json);
45
+ if (!plain(copy) ||
46
+ 'credentialId' in copy ||
47
+ 'vouched' in copy ||
48
+ typeof copy.observedAtMs !== 'number' ||
49
+ !Number.isFinite(copy.observedAtMs))
50
+ throw new Error('invalid reading');
51
+ if (copy.observedAtMs <= (this.#latest.get(credentialId) ?? -Infinity)) {
52
+ this.#dropped++;
53
+ return;
54
+ }
55
+ if (this.#queue.has(credentialId))
56
+ this.#dropped++;
57
+ this.#latest.set(credentialId, copy.observedAtMs);
58
+ this.#queue.set(credentialId, copy);
59
+ if (!this.#timer && !this.#running) {
60
+ this.#timer = this.options.setTimeout(() => {
61
+ this.#timer = undefined;
62
+ this.#flush();
63
+ }, 1_000);
64
+ this.#timer.unref?.();
65
+ }
66
+ }
67
+ catch {
68
+ this.#dropped++;
69
+ }
70
+ }
71
+ forget(credentialId) {
72
+ if (this.#queue.delete(credentialId))
73
+ this.#dropped++;
74
+ this.#latest.delete(credentialId);
75
+ }
76
+ #flush() {
77
+ if (this.#running || !this.#queue.size || this.#stopped)
78
+ return this.#running;
79
+ // Start on a microtask even during close, so all failures stay off the
80
+ // provider request path and only one send can be in flight on this client.
81
+ const running = Promise.resolve().then(async () => {
82
+ while (!this.#stopped && this.#queue.size) {
83
+ const entry = this.#queue.entries().next().value;
84
+ if (!entry)
85
+ break;
86
+ const [credentialId, reading] = entry;
87
+ this.#queue.delete(credentialId);
88
+ try {
89
+ const enrollmentToken = await this.options.token();
90
+ if (this.#stopped || !this.options.owns(credentialId)) {
91
+ if (!this.#stopped)
92
+ this.#dropped++;
93
+ continue;
94
+ }
95
+ await this.options.client.reportUsageScoped?.({
96
+ credentialId,
97
+ reading,
98
+ enrollmentToken,
99
+ });
100
+ }
101
+ catch (error) {
102
+ if (!this.#stopped)
103
+ this.#dropped++;
104
+ this.#log(error);
105
+ }
106
+ }
107
+ });
108
+ this.#running = running;
109
+ void running.finally(() => {
110
+ this.#running = undefined;
111
+ });
112
+ return running;
113
+ }
114
+ #log(error) {
115
+ // Only codes from credential refusals are exposed. Transport messages,
116
+ // arbitrary thrown objects and reply contents can contain secrets.
117
+ let code = 'transport_error';
118
+ try {
119
+ if (error instanceof ClaustrumCredentialError)
120
+ code = /^[a-z][a-z0-9_]{0,63}$/.test(error.code) ? error.code : code;
121
+ if (this.#errors.has(code))
122
+ return;
123
+ this.#errors.add(code);
124
+ this.options.logger.debug('claustrum usage report dropped', { code });
125
+ }
126
+ catch {
127
+ // A caller's logger must not turn best-effort reporting into a rejection.
128
+ }
129
+ }
130
+ /** Start the final flush; the custody's existing shutdown drain bounds it. */
131
+ close() {
132
+ this.#closing = true;
133
+ if (this.#timer)
134
+ this.options.clearTimeout(this.#timer);
135
+ this.#timer = undefined;
136
+ return this.#flush();
137
+ }
138
+ drop() {
139
+ this.#stopped = true;
140
+ this.#dropped += this.#queue.size + (this.#running ? 1 : 0);
141
+ this.#running = undefined;
142
+ this.#queue.clear();
143
+ this.#latest.clear();
144
+ }
145
+ }
@@ -41,6 +41,11 @@ export interface QuotaSectionOptions {
41
41
  check?(ids: readonly string[], invocation: CommandInvocation): Promise<void>;
42
42
  }
43
43
  export interface RoutingSectionOptions {
44
+ /**
45
+ * Show how long the plugin may wait for the current account's limit to
46
+ * reset before switching. Opt in only when the plugin supports that policy.
47
+ */
48
+ sameAccountWait?: boolean;
44
49
  /**
45
50
  * Further `routing.mode` values the plugin routes as `ordered` with the
46
51
  * former main row moved (`main-first`, `fallback-first`), offered between
@@ -468,6 +468,44 @@ function routingSection(options, snap) {
468
468
  : `Order set to ${result.ids.map(nameOf).join(', ')}.`;
469
469
  },
470
470
  });
471
+ if (options.routing?.sameAccountWait) {
472
+ const waitLabel = 'Longest wait on the same account';
473
+ const waitChoices = [
474
+ { value: '0', label: "Don't wait" },
475
+ { value: '15', label: '15 minutes' },
476
+ { value: 'max', label: 'Until the cache expires' },
477
+ ];
478
+ const rawWait = record(snap.settings.routing).maxSameAccountWait;
479
+ const wait = String(rawWait === 0 || rawWait === 'max' ? rawWait : 15);
480
+ const waitName = (value) => waitChoices.find((choice) => choice.value === value)?.label ?? value;
481
+ lines.push(`${waitLabel}: ${waitName(wait)}`);
482
+ actions.push({
483
+ id: 'maxSameAccountWait',
484
+ label: waitLabel,
485
+ group: ACTIONS_GROUP,
486
+ knobs: [
487
+ {
488
+ kind: 'choice',
489
+ id: 'maxSameAccountWait',
490
+ label: waitLabel,
491
+ choices: waitChoices,
492
+ value: wait,
493
+ },
494
+ ],
495
+ run: async ({ values }) => {
496
+ const value = String(values.maxSameAccountWait);
497
+ const maxSameAccountWait = value === 'max' ? 'max' : Number(value);
498
+ await store.updateSettings((settings) => {
499
+ settings.routing = {
500
+ ...record(settings.routing),
501
+ maxSameAccountWait,
502
+ };
503
+ return undefined;
504
+ }, locks);
505
+ return `${waitLabel} set to ${waitName(value)}.`;
506
+ },
507
+ });
508
+ }
471
509
  return {
472
510
  id: 'routing',
473
511
  slot: 'routing',
@@ -21,6 +21,11 @@ export declare class PrivateFileError extends Error {
21
21
  orphanPath?: string;
22
22
  constructor(path: string, stage: string, cause: unknown);
23
23
  }
24
+ /** Native sharing and delete-pending errors reveal neither ownership nor expiry. */
25
+ export declare function isWindowsSharingViolation(error: unknown): error is PrivateFileError & {
26
+ reason: 'io-error';
27
+ win32Code: 32 | 303;
28
+ };
24
29
  export declare function privateOperation<T>(path: string, stage: string, operation: () => Promise<T>): Promise<T>;
25
30
  /** Tests only: policy options used by safe-parent checks that pass none. */
26
31
  export declare function setSafeParentOptionsForTest(options?: PosixOwnerOnlyPathAsyncOptions): void;
@@ -93,6 +93,13 @@ export class PrivateFileError extends Error {
93
93
  this.reason = detail?.reason;
94
94
  }
95
95
  }
96
+ /** Native sharing and delete-pending errors reveal neither ownership nor expiry. */
97
+ export function isWindowsSharingViolation(error) {
98
+ return (windowsPrivateFiles &&
99
+ error instanceof PrivateFileError &&
100
+ error.reason === 'io-error' &&
101
+ (error.win32Code === 32 || error.win32Code === 303));
102
+ }
96
103
  export async function privateOperation(path, stage, operation) {
97
104
  try {
98
105
  return await operation();