@cortexkit/antigravity-auth-core 3.0.11 → 3.0.13
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/account-selector.d.ts +9 -1
- package/dist/account-selector.d.ts.map +1 -1
- package/dist/account-selector.js +23 -8
- package/dist/account-selector.js.map +1 -1
- package/dist/agy-transport.d.ts.map +1 -1
- package/dist/agy-transport.js +4 -0
- package/dist/agy-transport.js.map +1 -1
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +83 -2
- package/dist/common-auth-embedded/claustrum/consumer.js +337 -37
- package/dist/common-auth-embedded/claustrum/custody.d.ts +36 -1
- package/dist/common-auth-embedded/claustrum/custody.js +95 -11
- package/dist/common-auth-embedded/claustrum/enrollment.js +37 -12
- package/dist/common-auth-embedded/claustrum/errors.d.ts +1 -1
- package/dist/common-auth-embedded/claustrum/hub.d.ts +195 -0
- package/dist/common-auth-embedded/claustrum/hub.js +988 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +13 -3
- package/dist/common-auth-embedded/claustrum/index.js +37 -4
- package/dist/common-auth-embedded/claustrum/quota-writer.d.ts +8 -1
- package/dist/common-auth-embedded/claustrum/quota-writer.js +38 -66
- package/dist/common-auth-embedded/claustrum/roster.d.ts +80 -1
- package/dist/common-auth-embedded/claustrum/roster.js +259 -12
- package/dist/common-auth-embedded/claustrum/shutdown.js +42 -0
- package/dist/common-auth-embedded/commands/builtins.d.ts +37 -0
- package/dist/common-auth-embedded/commands/builtins.js +95 -13
- package/dist/common-auth-embedded/commands/index.d.ts +1 -1
- package/dist/common-auth-embedded/fs/file-read-cache.js +57 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +1 -1
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +20 -11
- package/dist/common-auth-embedded/quota/commit-interval.d.ts +9 -0
- package/dist/common-auth-embedded/quota/commit-interval.js +18 -0
- package/dist/common-auth-embedded/quota/pending.js +48 -0
- package/dist/common-auth-embedded/source-output.json +152 -88
- package/dist/common-auth-embedded/store/attribution.d.ts +5 -0
- package/dist/common-auth-embedded/store/attribution.js +39 -25
- package/dist/common-auth-embedded/store/mutate.d.ts +2 -0
- package/dist/common-auth-embedded/store/mutate.js +4 -0
- package/dist/common-auth-embedded/store/pool.d.ts +22 -0
- package/dist/common-auth-embedded/store/pool.js +18 -3
- package/dist/common-auth-embedded/store/pull.js +3 -1
- package/dist/common-auth-embedded/store/quota-writer.js +312 -0
- package/dist/common-auth-embedded/store/runtime.d.ts +2 -0
- package/dist/common-auth-embedded/store/settings.d.ts +1 -1
- package/dist/common-auth-embedded/store/settings.js +16 -6
- package/dist/model-registry.d.ts +2 -2
- package/dist/model-registry.d.ts.map +1 -1
- package/dist/model-registry.js +6 -4
- package/dist/model-registry.js.map +1 -1
- package/dist/pre-response-deadline.d.ts.map +1 -1
- package/dist/pre-response-deadline.js +6 -5
- package/dist/pre-response-deadline.js.map +1 -1
- package/dist/quota-manager.d.ts +1 -1
- package/dist/quota-manager.d.ts.map +1 -1
- package/dist/quota-manager.js +12 -4
- package/dist/quota-manager.js.map +1 -1
- package/dist/quota-presentation.d.ts.map +1 -1
- package/dist/quota-presentation.js +8 -0
- package/dist/quota-presentation.js.map +1 -1
- package/dist/quota-types.d.ts +1 -1
- package/dist/quota-types.d.ts.map +1 -1
- package/dist/quota-types.js +5 -0
- package/dist/quota-types.js.map +1 -1
- package/dist/vault-account-source.d.ts +6 -1
- package/dist/vault-account-source.d.ts.map +1 -1
- package/dist/vault-account-source.js +9 -0
- package/dist/vault-account-source.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,12 +1,39 @@
|
|
|
1
1
|
import { createLogger } from '../logger/index.js';
|
|
2
2
|
import { combineAbortSignals } from '../util/any-signal.js';
|
|
3
|
+
import { ClaustrumUnreachableError } from './availability.js';
|
|
3
4
|
import { ClaustrumScopedCustody, decideScopedRetryAfter401, } from './custody.js';
|
|
4
5
|
import { readClaustrumEnrollmentToken } from './enrollment.js';
|
|
5
6
|
import { ClaustrumConsumerError } from './errors.js';
|
|
6
7
|
import { acquireInsulaQuotaSource, } from './insula.js';
|
|
7
8
|
import { pendingVaultQuota, retainVaultQuotaWriter } from './quota-writer.js';
|
|
8
|
-
import { acceptVaultRoute, applyVaultQuota, declineVaultRoute, forgetPendingVaultDiscovery, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from './roster.js';
|
|
9
|
+
import { acceptVaultRoute, applyVaultQuota, declineVaultRoute, forgetPendingVaultDiscovery, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, vaultRoutingRows, } from './roster.js';
|
|
10
|
+
import { drainShutdown, shutdownWarning } from './shutdown.js';
|
|
9
11
|
import { SAFETY_POLL_MS } from './watch.js';
|
|
12
|
+
/**
|
|
13
|
+
* How long one connect attempt may take by default. It bounds a connect that
|
|
14
|
+
* never settles; the client's own handshake limit is not guaranteed to cover
|
|
15
|
+
* every stall, and callers waiting on discovery need an answer either way.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
18
|
+
/** An unreachable vault, raised when a connect passed its time limit. */
|
|
19
|
+
function unreachableConnect() {
|
|
20
|
+
return new ClaustrumUnreachableError();
|
|
21
|
+
}
|
|
22
|
+
let buildHubMember;
|
|
23
|
+
/**
|
|
24
|
+
* Build the consumer for one hub member. `options` holds only what the member
|
|
25
|
+
* uses: the hub's roster path for this member and the member's own fields.
|
|
26
|
+
* Hub-mode paths never read `tokenPath`, `family` or `connect`, which the hub
|
|
27
|
+
* owns. Package-internal: only `ClaustrumDiscoveryHub.member` calls it.
|
|
28
|
+
*/
|
|
29
|
+
export function createHubMember(port, options) {
|
|
30
|
+
if (!buildHubMember)
|
|
31
|
+
throw new Error('ClaustrumConsumer is not initialized');
|
|
32
|
+
return buildHubMember(port, options);
|
|
33
|
+
}
|
|
34
|
+
function notActive() {
|
|
35
|
+
return new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
|
|
36
|
+
}
|
|
10
37
|
/**
|
|
11
38
|
* One plugin host's view of its vault accounts. It turns vault credentials
|
|
12
39
|
* into rows for `/routing`, authorizes each physical send with the vault, and
|
|
@@ -19,6 +46,7 @@ export class ClaustrumConsumer {
|
|
|
19
46
|
#options;
|
|
20
47
|
#shutdown = new AbortController();
|
|
21
48
|
#logger;
|
|
49
|
+
#warnShutdown;
|
|
22
50
|
#custody;
|
|
23
51
|
#connecting;
|
|
24
52
|
#refreshing;
|
|
@@ -30,6 +58,8 @@ export class ClaustrumConsumer {
|
|
|
30
58
|
#grantReach;
|
|
31
59
|
#timer;
|
|
32
60
|
#started = false;
|
|
61
|
+
#pending = new Set();
|
|
62
|
+
#disposing;
|
|
33
63
|
#insula;
|
|
34
64
|
#vaultWatch;
|
|
35
65
|
#releaseInsulaWatch;
|
|
@@ -37,9 +67,23 @@ export class ClaustrumConsumer {
|
|
|
37
67
|
/** When this instance last completed a discovery, by `now`. */
|
|
38
68
|
#discoveredAt;
|
|
39
69
|
#releaseQuotaWriter;
|
|
70
|
+
/** Set only on a member built by `ClaustrumDiscoveryHub.member`. */
|
|
71
|
+
#hub;
|
|
72
|
+
/** A hub member's one custody lease at a time. */
|
|
73
|
+
#lease;
|
|
74
|
+
/** The lease that issued each receipt, so a 401 goes back through it. */
|
|
75
|
+
#issuedBy = new WeakMap();
|
|
76
|
+
static {
|
|
77
|
+
buildHubMember = (port, options) => {
|
|
78
|
+
const consumer = new ClaustrumConsumer(options);
|
|
79
|
+
consumer.#hub = port;
|
|
80
|
+
return { consumer, hooks: consumer.#hubHooks() };
|
|
81
|
+
};
|
|
82
|
+
}
|
|
40
83
|
constructor(options) {
|
|
41
84
|
this.#options = options;
|
|
42
85
|
this.#logger = options.logger ?? createLogger('claustrum');
|
|
86
|
+
this.#warnShutdown = shutdownWarning(this.#logger, this.#shutdown.signal);
|
|
43
87
|
this.#releaseQuotaWriter = retainVaultQuotaWriter(options.rosterPath, (publication) => {
|
|
44
88
|
this.#roster = applyVaultQuota(this.#roster, publication).roster;
|
|
45
89
|
});
|
|
@@ -48,6 +92,11 @@ export class ClaustrumConsumer {
|
|
|
48
92
|
this.#shutdown.signal.throwIfAborted();
|
|
49
93
|
signal?.throwIfAborted();
|
|
50
94
|
}
|
|
95
|
+
#track(pending) {
|
|
96
|
+
this.#pending.add(pending);
|
|
97
|
+
void pending.then(() => this.#pending.delete(pending), () => this.#pending.delete(pending));
|
|
98
|
+
return pending;
|
|
99
|
+
}
|
|
51
100
|
async #active() {
|
|
52
101
|
return (await this.#options.isCustodyActive?.()) ?? true;
|
|
53
102
|
}
|
|
@@ -87,35 +136,69 @@ export class ClaustrumConsumer {
|
|
|
87
136
|
if (this.#custody)
|
|
88
137
|
return this.#custody;
|
|
89
138
|
if (!this.#connecting) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
this.#custody = new ClaustrumScopedCustody({
|
|
98
|
-
client,
|
|
99
|
-
family: this.#options.family,
|
|
100
|
-
tokenPath: this.#options.tokenPath,
|
|
101
|
-
parseIdentity: this.#options.parseIdentity,
|
|
102
|
-
requireAssertion: this.#options.requireAssertion,
|
|
103
|
-
now: this.#options.now,
|
|
104
|
-
logger: this.#logger,
|
|
105
|
-
setTimeoutImpl: this.#options.setTimeoutImpl,
|
|
106
|
-
clearTimeoutImpl: this.#options.clearTimeoutImpl,
|
|
107
|
-
});
|
|
108
|
-
if (this.#options.insula && client.insula) {
|
|
109
|
-
this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
|
|
110
|
-
}
|
|
111
|
-
return this.#custody;
|
|
112
|
-
})
|
|
113
|
-
.finally(() => {
|
|
114
|
-
this.#connecting = undefined;
|
|
115
|
-
});
|
|
139
|
+
// A connect that never settles must not hold #connecting open: every
|
|
140
|
+
// later refresh would wait on the same promise. The race below bounds
|
|
141
|
+
// each attempt, and only a client that arrives in time is installed.
|
|
142
|
+
const attempt = { timedOut: false };
|
|
143
|
+
this.#connecting = this.#boundedConnect(attempt);
|
|
144
|
+
this.#track(this.#connecting);
|
|
116
145
|
}
|
|
117
146
|
return this.#wait(this.#connecting);
|
|
118
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* One `connect()` attempt under `connectTimeoutMs`. The late promise is still
|
|
150
|
+
* observed after the deadline, so a client that arrives then is closed
|
|
151
|
+
* rather than leaked.
|
|
152
|
+
*/
|
|
153
|
+
#boundedConnect(attempt) {
|
|
154
|
+
const setTimer = this.#options.setTimeoutImpl ?? setTimeout;
|
|
155
|
+
const clearTimer = this.#options.clearTimeoutImpl ?? clearTimeout;
|
|
156
|
+
const pending = this.#options.connect().then((client) => {
|
|
157
|
+
if (this.#shutdown.signal.aborted || attempt.timedOut) {
|
|
158
|
+
client.close();
|
|
159
|
+
if (!attempt.timedOut)
|
|
160
|
+
this.#assertOpen();
|
|
161
|
+
throw unreachableConnect();
|
|
162
|
+
}
|
|
163
|
+
this.#custody = new ClaustrumScopedCustody({
|
|
164
|
+
client,
|
|
165
|
+
family: this.#options.family,
|
|
166
|
+
tokenPath: this.#options.tokenPath,
|
|
167
|
+
parseIdentity: this.#options.parseIdentity,
|
|
168
|
+
requireAssertion: this.#options.requireAssertion,
|
|
169
|
+
now: this.#options.now,
|
|
170
|
+
logger: this.#logger,
|
|
171
|
+
setTimeoutImpl: this.#options.setTimeoutImpl,
|
|
172
|
+
clearTimeoutImpl: this.#options.clearTimeoutImpl,
|
|
173
|
+
});
|
|
174
|
+
if (this.#options.insula && client.insula) {
|
|
175
|
+
this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
|
|
176
|
+
}
|
|
177
|
+
return this.#custody;
|
|
178
|
+
});
|
|
179
|
+
let timer;
|
|
180
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
181
|
+
timer = setTimer(() => {
|
|
182
|
+
attempt.timedOut = true;
|
|
183
|
+
if (this.#shutdown.signal.aborted)
|
|
184
|
+
return;
|
|
185
|
+
this.#logger.warn('vault connect timed out', {
|
|
186
|
+
reason: 'unreachable',
|
|
187
|
+
connectTimeoutMs: this.#connectTimeoutMs(),
|
|
188
|
+
});
|
|
189
|
+
reject(unreachableConnect());
|
|
190
|
+
}, this.#connectTimeoutMs());
|
|
191
|
+
timer.unref?.();
|
|
192
|
+
});
|
|
193
|
+
return Promise.race([pending, deadline]).finally(() => {
|
|
194
|
+
if (timer)
|
|
195
|
+
clearTimer(timer);
|
|
196
|
+
this.#connecting = undefined;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
#connectTimeoutMs() {
|
|
200
|
+
return this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
201
|
+
}
|
|
119
202
|
/** The last committed roster this instance saw, without any I/O. */
|
|
120
203
|
snapshot() {
|
|
121
204
|
return this.#roster;
|
|
@@ -130,6 +213,8 @@ export class ClaustrumConsumer {
|
|
|
130
213
|
* held the discovery lease and its newer roster was served instead).
|
|
131
214
|
*/
|
|
132
215
|
grantReach() {
|
|
216
|
+
if (this.#hub)
|
|
217
|
+
return this.#hub.grantReach(this.#roster?.view);
|
|
133
218
|
const latest = this.#grantReach;
|
|
134
219
|
return latest && this.#roster?.view === latest.view
|
|
135
220
|
? latest.reach
|
|
@@ -137,6 +222,8 @@ export class ClaustrumConsumer {
|
|
|
137
222
|
}
|
|
138
223
|
/** How this consumer currently learns about changes, without I/O. */
|
|
139
224
|
watchState() {
|
|
225
|
+
if (this.#hub)
|
|
226
|
+
return Object.freeze({ vault: this.#hub.watchMode() });
|
|
140
227
|
return Object.freeze({
|
|
141
228
|
vault: this.#vaultWatch?.mode ?? 'polling',
|
|
142
229
|
...(this.#options.insula && {
|
|
@@ -153,6 +240,13 @@ export class ClaustrumConsumer {
|
|
|
153
240
|
}
|
|
154
241
|
/** Whether any account is using an in-memory outage receipt, without I/O. */
|
|
155
242
|
keptReceiptState() {
|
|
243
|
+
if (this.#hub) {
|
|
244
|
+
// Kept receipts live in the one shared custody, whichever lease reads it.
|
|
245
|
+
const lease = this.#lease;
|
|
246
|
+
return lease && this.#hub.leaseLive(lease)
|
|
247
|
+
? lease.custody.keptReceiptState()
|
|
248
|
+
: this.#hub.keptReceiptState();
|
|
249
|
+
}
|
|
156
250
|
return (this.#custody?.keptReceiptState() ??
|
|
157
251
|
Object.freeze({ active: false, since: null }));
|
|
158
252
|
}
|
|
@@ -162,6 +256,8 @@ export class ClaustrumConsumer {
|
|
|
162
256
|
}
|
|
163
257
|
refresh() {
|
|
164
258
|
this.#assertOpen();
|
|
259
|
+
if (this.#hub)
|
|
260
|
+
return this.#track(this.#hubRefresh(this.#hub));
|
|
165
261
|
if (this.#refreshing)
|
|
166
262
|
return this.#refreshing;
|
|
167
263
|
this.#refreshing = (async () => {
|
|
@@ -169,7 +265,10 @@ export class ClaustrumConsumer {
|
|
|
169
265
|
this.#roster = undefined;
|
|
170
266
|
this.#grantReach = undefined;
|
|
171
267
|
this.#stopWatch();
|
|
172
|
-
this.#custody
|
|
268
|
+
if (this.#custody) {
|
|
269
|
+
this.#custody.close();
|
|
270
|
+
this.#track(this.#custody.dispose(this.#warnShutdown));
|
|
271
|
+
}
|
|
173
272
|
this.#custody = undefined;
|
|
174
273
|
this.#insula?.release();
|
|
175
274
|
this.#insula = undefined;
|
|
@@ -214,6 +313,7 @@ export class ClaustrumConsumer {
|
|
|
214
313
|
})().finally(() => {
|
|
215
314
|
this.#refreshing = undefined;
|
|
216
315
|
});
|
|
316
|
+
this.#track(this.#refreshing);
|
|
217
317
|
return this.#refreshing;
|
|
218
318
|
}
|
|
219
319
|
/**
|
|
@@ -231,6 +331,11 @@ export class ClaustrumConsumer {
|
|
|
231
331
|
return;
|
|
232
332
|
this.#assertOpen();
|
|
233
333
|
this.#started = true;
|
|
334
|
+
// A hub member has no tick or watch of its own: the hub drives it.
|
|
335
|
+
if (this.#hub) {
|
|
336
|
+
this.#hub.start();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
234
339
|
const tick = async () => {
|
|
235
340
|
try {
|
|
236
341
|
if (await this.#listDue())
|
|
@@ -249,11 +354,11 @@ export class ClaustrumConsumer {
|
|
|
249
354
|
return;
|
|
250
355
|
this.#timer = (this.#options.setTimeoutImpl ?? setTimeout)(() => {
|
|
251
356
|
this.#timer = undefined;
|
|
252
|
-
void tick();
|
|
357
|
+
void this.#track(tick());
|
|
253
358
|
}, delay);
|
|
254
359
|
this.#timer.unref?.();
|
|
255
360
|
};
|
|
256
|
-
void tick();
|
|
361
|
+
void this.#track(tick());
|
|
257
362
|
}
|
|
258
363
|
/** Whether a background tick must list the vault now. */
|
|
259
364
|
async #listDue() {
|
|
@@ -282,11 +387,13 @@ export class ClaustrumConsumer {
|
|
|
282
387
|
// re-applies it to kept receipts exactly as a poll does. A refresh
|
|
283
388
|
// already in flight may have listed before the change was announced,
|
|
284
389
|
// so wait for it and list again.
|
|
285
|
-
onView: async () => {
|
|
390
|
+
onView: () => this.#track((async () => {
|
|
286
391
|
while (this.#refreshing)
|
|
287
392
|
await this.#refreshing.catch(() => { });
|
|
393
|
+
if (this.#shutdown.signal.aborted)
|
|
394
|
+
return;
|
|
288
395
|
await this.refresh();
|
|
289
|
-
},
|
|
396
|
+
})()),
|
|
290
397
|
// Custody has dropped kept receipts, as a poll meeting the same
|
|
291
398
|
// refusal from list_scoped would.
|
|
292
399
|
onRefused: (error) => this.#options.onError?.(error),
|
|
@@ -412,6 +519,8 @@ export class ClaustrumConsumer {
|
|
|
412
519
|
* poll, and a route whose credential or account changed is refused.
|
|
413
520
|
*/
|
|
414
521
|
async authorize(routeId, signal) {
|
|
522
|
+
if (this.#hub)
|
|
523
|
+
return this.#hubAuthorize(this.#hub, routeId, signal);
|
|
415
524
|
this.#assertOpen(signal);
|
|
416
525
|
const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
|
|
417
526
|
if (!roster || !(await this.#active())) {
|
|
@@ -459,8 +568,168 @@ export class ClaustrumConsumer {
|
|
|
459
568
|
combined.dispose();
|
|
460
569
|
}
|
|
461
570
|
}
|
|
571
|
+
/** The hub's handle on this member's memory. */
|
|
572
|
+
#hubHooks() {
|
|
573
|
+
return {
|
|
574
|
+
install: (roster) => {
|
|
575
|
+
if (this.#shutdown.signal.aborted)
|
|
576
|
+
return;
|
|
577
|
+
const changed = roster.view !== this.#roster?.view;
|
|
578
|
+
this.#roster = pendingVaultQuota(this.#options.rosterPath, roster);
|
|
579
|
+
// One subscriber's failure must not stop the hub's fan-out.
|
|
580
|
+
if (changed)
|
|
581
|
+
try {
|
|
582
|
+
this.#options.onRoster?.(roster);
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
this.#options.onError?.(error);
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
reset: () => {
|
|
589
|
+
this.#roster = undefined;
|
|
590
|
+
},
|
|
591
|
+
roster: () => this.#roster,
|
|
592
|
+
projection: () => ({
|
|
593
|
+
reservedRouteIds: new Set(this.#options.reservedRouteIds?.() ?? []),
|
|
594
|
+
routePrefix: this.#options.routePrefix,
|
|
595
|
+
mapAccount: this.#options.mapAccount,
|
|
596
|
+
now: (this.#options.now ?? Date.now)(),
|
|
597
|
+
}),
|
|
598
|
+
onError: (error) => {
|
|
599
|
+
if (!this.#shutdown.signal.aborted)
|
|
600
|
+
this.#options.onError?.(error);
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* A hub member's refresh is the hub's: the hub has already pushed this
|
|
606
|
+
* member's committed roster into memory when it resolves.
|
|
607
|
+
*/
|
|
608
|
+
async #hubRefresh(hub) {
|
|
609
|
+
const outcome = await hub.refresh();
|
|
610
|
+
this.#assertOpen();
|
|
611
|
+
if (!outcome)
|
|
612
|
+
return undefined;
|
|
613
|
+
if (outcome.errors.has(hub.provider))
|
|
614
|
+
throw outcome.errors.get(hub.provider);
|
|
615
|
+
return this.#roster;
|
|
616
|
+
}
|
|
617
|
+
/** Release this member's lease, or only `lease` when one is named. */
|
|
618
|
+
#dropLease(lease) {
|
|
619
|
+
if (lease && this.#lease !== lease)
|
|
620
|
+
return;
|
|
621
|
+
this.#lease?.release();
|
|
622
|
+
this.#lease = undefined;
|
|
623
|
+
}
|
|
624
|
+
/** This member's lease, re-acquired once its connection has ended. */
|
|
625
|
+
async #currentLease(hub, signal) {
|
|
626
|
+
const held = this.#lease;
|
|
627
|
+
if (held && hub.leaseLive(held))
|
|
628
|
+
return held;
|
|
629
|
+
this.#dropLease();
|
|
630
|
+
const lease = await hub.acquire(signal);
|
|
631
|
+
if (this.#shutdown.signal.aborted) {
|
|
632
|
+
lease.release();
|
|
633
|
+
this.#assertOpen();
|
|
634
|
+
}
|
|
635
|
+
// A concurrent authorize may have acquired first; keep one lease.
|
|
636
|
+
const raced = this.#lease;
|
|
637
|
+
if (raced && hub.leaseLive(raced)) {
|
|
638
|
+
lease.release();
|
|
639
|
+
return raced;
|
|
640
|
+
}
|
|
641
|
+
this.#lease = lease;
|
|
642
|
+
return lease;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* `authorize` for a hub member. Only the hub's own authority read decides
|
|
646
|
+
* that vault mode is off. If the hub's refresh still leaves this member
|
|
647
|
+
* without a roster, it fails with `route-unavailable` before acquiring a
|
|
648
|
+
* lease or asking for a credential, and no other member is affected.
|
|
649
|
+
*/
|
|
650
|
+
async #hubAuthorize(hub, routeId, signal) {
|
|
651
|
+
this.#assertOpen(signal);
|
|
652
|
+
// A failed list rejects here; this member's own failed commit leaves it
|
|
653
|
+
// without a roster, handled below.
|
|
654
|
+
if (!this.#roster)
|
|
655
|
+
await this.#wait(hub.refresh(), signal);
|
|
656
|
+
const roster = this.#roster;
|
|
657
|
+
if (!(await hub.readAuthority())) {
|
|
658
|
+
this.#dropLease();
|
|
659
|
+
throw notActive();
|
|
660
|
+
}
|
|
661
|
+
this.#assertOpen(signal);
|
|
662
|
+
if (!roster)
|
|
663
|
+
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum has no roster for this provider yet');
|
|
664
|
+
const current = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
|
|
665
|
+
this.#assertOpen(signal);
|
|
666
|
+
const observed = roster.rows.find((row) => row.routeId === routeId);
|
|
667
|
+
const configured = current?.rows.find((row) => row.routeId === routeId);
|
|
668
|
+
if (configured && !configured.enabled) {
|
|
669
|
+
if (observed)
|
|
670
|
+
hub.forget(observed.credentialId);
|
|
671
|
+
throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
|
|
672
|
+
}
|
|
673
|
+
if (!observed ||
|
|
674
|
+
!configured ||
|
|
675
|
+
configured.state !== 'active' ||
|
|
676
|
+
configured.credentialId !== observed.credentialId ||
|
|
677
|
+
configured.accountIdentity !== observed.accountIdentity ||
|
|
678
|
+
configured.credentialType !== observed.credentialType) {
|
|
679
|
+
if (observed)
|
|
680
|
+
hub.forget(observed.credentialId);
|
|
681
|
+
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
|
|
682
|
+
}
|
|
683
|
+
const identity = {
|
|
684
|
+
credentialId: configured.credentialId,
|
|
685
|
+
credentialType: configured.credentialType,
|
|
686
|
+
...(configured.accountIdentity !== undefined && {
|
|
687
|
+
accountIdentity: configured.accountIdentity,
|
|
688
|
+
}),
|
|
689
|
+
};
|
|
690
|
+
const combined = combineAbortSignals([
|
|
691
|
+
this.#shutdown.signal,
|
|
692
|
+
...(signal ? [signal] : []),
|
|
693
|
+
]);
|
|
694
|
+
try {
|
|
695
|
+
for (let attempt = 0;; attempt++) {
|
|
696
|
+
const lease = await this.#currentLease(hub, combined.signal);
|
|
697
|
+
try {
|
|
698
|
+
const served = await lease.custody.authorize(identity, combined.signal, routeId);
|
|
699
|
+
this.#issuedBy.set(served, lease);
|
|
700
|
+
return served;
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
// Only a lease whose connection ended meanwhile earns one more
|
|
704
|
+
// try, in the current connection; anything else, or a second
|
|
705
|
+
// closed, is the answer.
|
|
706
|
+
if (attempt > 0 ||
|
|
707
|
+
combined.signal.aborted ||
|
|
708
|
+
hub.leaseLive(lease) ||
|
|
709
|
+
!(error instanceof ClaustrumConsumerError) ||
|
|
710
|
+
error.kind !== 'closed')
|
|
711
|
+
throw error;
|
|
712
|
+
this.#dropLease(lease);
|
|
713
|
+
if (!(await hub.readAuthority()))
|
|
714
|
+
throw notActive();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
finally {
|
|
719
|
+
combined.dispose();
|
|
720
|
+
}
|
|
721
|
+
}
|
|
462
722
|
async reportFailure(attempt, status, source) {
|
|
463
723
|
this.#assertOpen();
|
|
724
|
+
if (this.#hub) {
|
|
725
|
+
// Only the lease that issued the receipt may report it; once that
|
|
726
|
+
// lease was released or its connection ended, its view throws closed.
|
|
727
|
+
const lease = this.#issuedBy.get(attempt);
|
|
728
|
+
if (!lease)
|
|
729
|
+
throw new ClaustrumConsumerError('no-receipt', 'Claustrum dispatch receipt has no active owner');
|
|
730
|
+
await lease.custody.reportFailure(attempt, status, source);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
464
733
|
if (!this.#custody)
|
|
465
734
|
throw new ClaustrumConsumerError('no-receipt', 'Claustrum dispatch receipt has no active owner');
|
|
466
735
|
await this.#custody.reportFailure(attempt, status, source);
|
|
@@ -485,8 +754,12 @@ export class ClaustrumConsumer {
|
|
|
485
754
|
let current;
|
|
486
755
|
try {
|
|
487
756
|
current = await this.authorize(routeId, signal);
|
|
757
|
+
// Cancellation can land after authorize settles, before this continuation.
|
|
758
|
+
signal?.throwIfAborted();
|
|
488
759
|
}
|
|
489
|
-
catch {
|
|
760
|
+
catch (error) {
|
|
761
|
+
if (signal?.aborted)
|
|
762
|
+
throw error;
|
|
490
763
|
// Re-authorization failed: keep this 401 and report it against the
|
|
491
764
|
// receipt (served record version) this request was sent with.
|
|
492
765
|
}
|
|
@@ -515,13 +788,29 @@ export class ClaustrumConsumer {
|
|
|
515
788
|
this.#assertOpen();
|
|
516
789
|
await declineVaultRoute(this.#options.rosterPath, routeId);
|
|
517
790
|
this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
|
|
518
|
-
|
|
791
|
+
// A hub member shares kept receipts with every other member, so they are
|
|
792
|
+
// retained against all members' rosters, never against this one alone.
|
|
793
|
+
if (this.#hub)
|
|
794
|
+
this.#hub.retainUnion();
|
|
795
|
+
else
|
|
796
|
+
this.#custody?.retain(this.#roster?.rows ?? []);
|
|
519
797
|
}
|
|
520
798
|
async accept(routeId) {
|
|
521
799
|
this.#assertOpen();
|
|
522
800
|
await acceptVaultRoute(this.#options.rosterPath, routeId);
|
|
523
801
|
this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
|
|
524
802
|
}
|
|
803
|
+
/**
|
|
804
|
+
* Set the order vault accounts are tried in (`reorderVaultRoster`), then
|
|
805
|
+
* re-read the roster, so the next routing decision in this process uses
|
|
806
|
+
* the new order without waiting for a discovery. Never contacts the vault.
|
|
807
|
+
*/
|
|
808
|
+
async reorder(routeIds) {
|
|
809
|
+
this.#assertOpen();
|
|
810
|
+
const result = await reorderVaultRoster(this.#options.rosterPath, routeIds);
|
|
811
|
+
this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
|
|
812
|
+
return result;
|
|
813
|
+
}
|
|
525
814
|
/**
|
|
526
815
|
* Store a quota or profile observation for a vault route. The receipt the
|
|
527
816
|
* reading was taken with is required: the observation lands only while the
|
|
@@ -566,7 +855,14 @@ export class ClaustrumConsumer {
|
|
|
566
855
|
(this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
|
|
567
856
|
this.#timer = undefined;
|
|
568
857
|
this.#stopWatch();
|
|
569
|
-
this.#custody
|
|
858
|
+
if (this.#custody) {
|
|
859
|
+
this.#custody.close();
|
|
860
|
+
this.#track(this.#custody.dispose(this.#warnShutdown));
|
|
861
|
+
}
|
|
862
|
+
// A hub member gives back its lease; the shared custody stays open.
|
|
863
|
+
this.#lease?.release();
|
|
864
|
+
this.#lease = undefined;
|
|
865
|
+
this.#hub?.detach();
|
|
570
866
|
this.#insula?.release();
|
|
571
867
|
this.#insula = undefined;
|
|
572
868
|
this.#roster = undefined;
|
|
@@ -574,9 +870,13 @@ export class ClaustrumConsumer {
|
|
|
574
870
|
void this.#releaseQuotaWriter();
|
|
575
871
|
forgetPendingVaultDiscovery(this.#options.rosterPath);
|
|
576
872
|
}
|
|
577
|
-
/** Close immediately;
|
|
873
|
+
/** Close immediately; background work gets 2 s and the quota writer its own 1 s. */
|
|
578
874
|
async dispose() {
|
|
579
875
|
this.close();
|
|
580
|
-
|
|
876
|
+
this.#disposing ??= Promise.all([
|
|
877
|
+
drainShutdown(this.#pending, this.#warnShutdown),
|
|
878
|
+
this.#releaseQuotaWriter(),
|
|
879
|
+
]).then(() => { });
|
|
880
|
+
await this.#disposing;
|
|
581
881
|
}
|
|
582
882
|
}
|
|
@@ -22,10 +22,35 @@ export interface ClaustrumFamily {
|
|
|
22
22
|
category: string;
|
|
23
23
|
apiKeys?: boolean;
|
|
24
24
|
}
|
|
25
|
+
/** Non-secret classification facts, including rows decoded by older clients. */
|
|
26
|
+
export interface VaultRowFacts {
|
|
27
|
+
readonly credentialId: string;
|
|
28
|
+
readonly credentialType: string;
|
|
29
|
+
readonly authMethod?: string;
|
|
30
|
+
readonly refreshAdapter?: string;
|
|
31
|
+
readonly providerIds?: readonly string[];
|
|
32
|
+
readonly categories: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
/** A policy that partitions one category without requiring one OAuth adapter. */
|
|
35
|
+
export interface ClassifyingClaustrumFamily<Code extends string> {
|
|
36
|
+
category: string;
|
|
37
|
+
refusalCodes: readonly Code[];
|
|
38
|
+
classify(row: VaultRowFacts): {
|
|
39
|
+
kind: 'not-mine';
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'mine';
|
|
42
|
+
member: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: 'refused';
|
|
45
|
+
code: Code;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export type ClaustrumFamilyInput = ClaustrumFamily | ClassifyingClaustrumFamily<string>;
|
|
25
49
|
/** One vault credential this consumer may serve. Never carries bearer material. */
|
|
26
50
|
export interface VaultCredential {
|
|
27
51
|
readonly credentialId: string;
|
|
28
52
|
readonly credentialType: VaultCredentialType;
|
|
53
|
+
readonly member?: string;
|
|
29
54
|
/**
|
|
30
55
|
* The provider account the credential logs into, when the vault's adapter
|
|
31
56
|
* claims one. Absent means the adapter makes no claim, not a mismatch.
|
|
@@ -47,6 +72,7 @@ export type SkippedVaultReason = 'empty credential id' | 'duplicate credential i
|
|
|
47
72
|
*/
|
|
48
73
|
export interface SkippedVaultRecord {
|
|
49
74
|
readonly credentialId?: string;
|
|
75
|
+
readonly member?: string;
|
|
50
76
|
readonly reason: SkippedVaultReason;
|
|
51
77
|
}
|
|
52
78
|
/**
|
|
@@ -82,6 +108,13 @@ export interface VaultInventory {
|
|
|
82
108
|
readonly view: string;
|
|
83
109
|
readonly credentials: readonly VaultCredential[];
|
|
84
110
|
readonly skipped: readonly SkippedVaultRecord[];
|
|
111
|
+
/** Classifying families only: fixed refusal codes, never classifier errors. */
|
|
112
|
+
readonly refused?: readonly {
|
|
113
|
+
credentialId?: string;
|
|
114
|
+
code: string;
|
|
115
|
+
}[];
|
|
116
|
+
/** Classifying families only: rows outside this family's responsibility. */
|
|
117
|
+
readonly notMine?: number;
|
|
85
118
|
/**
|
|
86
119
|
* The consumer's grants, as the vault's list reply named them. Undefined means unknown
|
|
87
120
|
* (the vault or client predates the fields), never zero grants.
|
|
@@ -221,7 +254,7 @@ export declare class ClaustrumScopedCustody {
|
|
|
221
254
|
#private;
|
|
222
255
|
constructor(options: {
|
|
223
256
|
client: ClaustrumScopedClient;
|
|
224
|
-
family:
|
|
257
|
+
family: ClaustrumFamilyInput;
|
|
225
258
|
tokenPath?: string;
|
|
226
259
|
readToken?: () => Promise<EnrollmentTokenFile>;
|
|
227
260
|
parseIdentity?: IdentityParser;
|
|
@@ -296,4 +329,6 @@ export declare class ClaustrumScopedCustody {
|
|
|
296
329
|
*/
|
|
297
330
|
watchRoster(options: ClaustrumRosterWatchOptions): ClaustrumRosterWatch;
|
|
298
331
|
close(): void;
|
|
332
|
+
/** Stop immediately; allow token reads 2 s and share an owner's timeout warning. */
|
|
333
|
+
dispose(onTimeout?: (pendingOperations: number) => void): Promise<void>;
|
|
299
334
|
}
|