@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.
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +12 -0
- package/dist/common-auth-embedded/claustrum/consumer.js +36 -0
- package/dist/common-auth-embedded/claustrum/custody.d.ts +16 -4
- package/dist/common-auth-embedded/claustrum/custody.js +74 -9
- package/dist/common-auth-embedded/claustrum/hub.d.ts +3 -2
- package/dist/common-auth-embedded/claustrum/hub.js +8 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +1 -0
- package/dist/common-auth-embedded/claustrum/roster.d.ts +2 -0
- package/dist/common-auth-embedded/claustrum/roster.js +2 -0
- package/dist/common-auth-embedded/claustrum/usage.d.ts +41 -0
- package/dist/common-auth-embedded/claustrum/usage.js +145 -0
- package/dist/common-auth-embedded/commands/builtins.d.ts +5 -0
- package/dist/common-auth-embedded/commands/builtins.js +38 -0
- package/dist/common-auth-embedded/fs/private-files.d.ts +5 -0
- package/dist/common-auth-embedded/fs/private-files.js +7 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +142 -47
- package/dist/common-auth-embedded/fs/with-lock.js +2 -5
- package/dist/common-auth-embedded/routing/admission.d.ts +7 -2
- package/dist/common-auth-embedded/routing/admission.js +17 -7
- package/dist/common-auth-embedded/source-output.json +126 -94
- package/dist/common-auth-embedded/store/attribution.js +3 -2
- package/dist/common-auth-embedded/store/errors.d.ts +1 -1
- package/dist/common-auth-embedded/store/hooks.d.ts +5 -1
- package/dist/common-auth-embedded/store/hooks.js +20 -5
- package/dist/common-auth-embedded/store/index.d.ts +1 -0
- package/dist/common-auth-embedded/store/limits-namespace.d.ts +7 -0
- package/dist/common-auth-embedded/store/limits-namespace.js +34 -0
- package/dist/common-auth-embedded/store/pool.d.ts +10 -1
- package/dist/common-auth-embedded/store/pool.js +2 -0
- package/dist/common-auth-embedded/store/provider-state.js +5 -5
- package/dist/common-auth-embedded/store/refresh.js +1 -1
- package/dist/common-auth-embedded/store/schema.js +9 -4
- package/dist/common-auth-embedded/store/settings.d.ts +2 -0
- package/dist/common-auth-embedded/store/settings.js +11 -4
- 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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
-
|
|
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.#
|
|
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
|
|
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. `
|
|
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();
|