@cortexkit/opencode-openai-auth 1.2.0 → 1.3.0
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/bin.js +2 -2
- package/dist/{codex-request-CjXETpTA.d.ts → codex-request-B9D0e97Q.d.ts} +145 -28
- package/dist/{index-ehjj89rx.js → index-1mbgsxsr.js} +1 -1
- package/dist/{index-htvcmtcx.js → index-2pgkrebq.js} +1 -1
- package/dist/{index-rwbrhkse.js → index-4v2fs42x.js} +1 -1
- package/dist/index-8x1aj7q3.js +2 -0
- package/dist/{index-6n4pnfkg.js → index-ex6nx3mq.js} +1 -1
- package/dist/{index-qxbchh2d.js → index-gn2b097f.js} +1 -1
- package/dist/{index-qp5hdpw8.js → index-m3ctrabv.js} +1 -1
- package/dist/{index-03em8hv1.js → index-psjyexj3.js} +15 -15
- package/dist/{index-ffg0sgg5.js → index-tddr876f.js} +1 -1
- package/dist/index-tn8x5tz8.js +90 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/{model-CQcw10oW.d.ts → model-BdITTmeb.d.ts} +1 -1
- package/dist/{port-file-BDJnzgHH.d.ts → port-file-DbHIVC9a.d.ts} +1 -1
- package/dist/{protocol-C-kagE6O.d.ts → protocol-MysYPAho.d.ts} +1 -1
- package/dist/rpc/port-file.d.ts +1 -1
- package/dist/rpc/port-file.js +1 -1
- package/dist/rpc/protocol.d.ts +1 -1
- package/dist/rpc/protocol.js +1 -1
- package/dist/rpc/rpc-client.d.ts +3 -3
- package/dist/rpc/rpc-client.js +1 -1
- package/dist/{sidebar-state-BfQ4mpZO.d.ts → sidebar-state-CVSok7zv.d.ts} +20 -4
- package/dist/sidebar-state.d.ts +1 -1
- package/dist/sidebar-state.js +1 -1
- package/dist/tui-preferences.js +1 -1
- package/dist/tui.d.ts +2 -2
- package/dist/v2/server.d.ts +2 -2
- package/dist/v2/server.js +1 -1
- package/package.json +4 -4
- package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +69 -1
- package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +225 -16
- package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +130 -5
- package/src/tui-compiled/raw/shared/375d9cca2def037d-private-files.js +10 -2
- package/src/tui-compiled/raw/shared/94f53cfd572ba4bb-availability.js +106 -0
- package/src/tui-compiled/raw/shared/d0f0b6be200311af-insula.js +286 -21
- package/src/tui-compiled/raw/shared/dc9290c2a7552ba6-watch.js +253 -0
- package/src/tui-compiled/raw/shared/e8d3c8da776ef9cd-index.js +4 -1
- package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +69 -1
- package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +225 -16
- package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +130 -5
- package/src/tui-compiled/runtime/shared/375d9cca2def037d-private-files.js +10 -2
- package/src/tui-compiled/runtime/shared/94f53cfd572ba4bb-availability.js +106 -0
- package/src/tui-compiled/runtime/shared/d0f0b6be200311af-insula.js +286 -21
- package/src/tui-compiled/runtime/shared/dc9290c2a7552ba6-watch.js +253 -0
- package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +4 -1
- package/dist/index-f21addzm.js +0 -90
- package/dist/index-r2s51ac1.js +0 -2
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { ClaustrumCredentialError, } from "@cortexkit/claustrum-client";
|
|
2
2
|
import { createLogger } from "./cc9e0702a752cea7-index.js";
|
|
3
|
+
import { ClaustrumUnreachableError, classifyVaultFailure, } from "./94f53cfd572ba4bb-availability.js";
|
|
3
4
|
import { readClaustrumEnrollmentToken } from "./b9588ad4725c1d10-enrollment.js";
|
|
4
5
|
import { ClaustrumConsumerError } from "./ca0fccaba1d87af4-errors.js";
|
|
5
6
|
import { CUSTODY_PLACEHOLDER_PREFIX } from "./d9d2892a4c371301-host-slot.js";
|
|
6
7
|
/** Only a new version of the same account may replace an in-flight 401. */
|
|
7
8
|
export function isScopedCredentialRotation(served, current) {
|
|
8
9
|
return (current !== undefined &&
|
|
10
|
+
!current.fromKeptReceipt &&
|
|
11
|
+
!served.fromKeptReceipt &&
|
|
9
12
|
current.credentialId === served.credentialId &&
|
|
10
13
|
current.accountIdentity === served.accountIdentity &&
|
|
11
14
|
current.recordVersion !== served.recordVersion);
|
|
@@ -45,6 +48,8 @@ export function decideScopedRetryAfter401(site, served, current, logger = defaul
|
|
|
45
48
|
* so it cannot expire while a long request is still using it.
|
|
46
49
|
*/
|
|
47
50
|
export const SERVING_MARGIN_MS = 300_000;
|
|
51
|
+
/** A kept receipt needs a minute of headroom, not the vault's refresh demand. */
|
|
52
|
+
export const KEPT_RECEIPT_MARGIN_MS = 60_000;
|
|
48
53
|
function invalidMaterial() {
|
|
49
54
|
return new ClaustrumConsumerError('invalid-material', 'Claustrum returned invalid credential material');
|
|
50
55
|
}
|
|
@@ -100,11 +105,11 @@ function grantReachOf(listed) {
|
|
|
100
105
|
}
|
|
101
106
|
/**
|
|
102
107
|
* Reads this consumer's vault credentials (list, fetch, 401 report), with the
|
|
103
|
-
* enrollment token as authorization. Used by every host of a plugin.
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
+
* enrollment token as authorization. Used by every host of a plugin. Every
|
|
109
|
+
* physical send asks the vault first while reachable. Only an unreachable vault permits
|
|
110
|
+
* reuse of the last validated receipt, in memory and bounded by its expiry.
|
|
111
|
+
* The enrollment token is re-read per operation so revocation and disconnect
|
|
112
|
+
* still take effect without a process restart.
|
|
108
113
|
*/
|
|
109
114
|
export class ClaustrumScopedCustody {
|
|
110
115
|
#client;
|
|
@@ -116,6 +121,16 @@ export class ClaustrumScopedCustody {
|
|
|
116
121
|
#logger;
|
|
117
122
|
#provenance = new WeakMap();
|
|
118
123
|
#reports = new WeakMap();
|
|
124
|
+
#kept = new Map();
|
|
125
|
+
#keptAttempts = new WeakMap();
|
|
126
|
+
#setTimeout;
|
|
127
|
+
#clearTimeout;
|
|
128
|
+
#outage = false;
|
|
129
|
+
#outageError = new ClaustrumUnreachableError();
|
|
130
|
+
#probeTimer;
|
|
131
|
+
#probing = false;
|
|
132
|
+
#lastProbeAt = -Infinity;
|
|
133
|
+
#probeDelay = 2_000;
|
|
119
134
|
#closed = false;
|
|
120
135
|
constructor(options) {
|
|
121
136
|
const tokenPath = options.tokenPath;
|
|
@@ -134,6 +149,63 @@ export class ClaustrumScopedCustody {
|
|
|
134
149
|
this.#requireAssertion = options.requireAssertion ?? false;
|
|
135
150
|
this.#now = options.now ?? Date.now;
|
|
136
151
|
this.#logger = options.logger ?? defaultLogger;
|
|
152
|
+
this.#setTimeout = options.setTimeoutImpl ?? setTimeout;
|
|
153
|
+
this.#clearTimeout = options.clearTimeoutImpl ?? clearTimeout;
|
|
154
|
+
}
|
|
155
|
+
/** A watch losing its transport is the same outage as an unreachable read. */
|
|
156
|
+
transportLost(error) {
|
|
157
|
+
if (this.#closed || this.#outage)
|
|
158
|
+
return;
|
|
159
|
+
this.#outage = true;
|
|
160
|
+
this.#outageError =
|
|
161
|
+
error instanceof ClaustrumCredentialError
|
|
162
|
+
? error
|
|
163
|
+
: new ClaustrumUnreachableError();
|
|
164
|
+
this.#logger.warn('serving kept vault receipt', { reason: 'unreachable' });
|
|
165
|
+
this.#scheduleProbe();
|
|
166
|
+
}
|
|
167
|
+
/** Any vault answer ends transport fallback, even when it refuses access. */
|
|
168
|
+
reachable() {
|
|
169
|
+
if (this.#closed || !this.#outage)
|
|
170
|
+
return;
|
|
171
|
+
this.#outage = false;
|
|
172
|
+
if (this.#probeTimer)
|
|
173
|
+
this.#clearTimeout(this.#probeTimer);
|
|
174
|
+
this.#probeTimer = undefined;
|
|
175
|
+
this.#probeDelay = 2_000;
|
|
176
|
+
for (const kept of this.#kept.values())
|
|
177
|
+
kept.since = null;
|
|
178
|
+
(this.#logger.info ?? defaultLogger.info)('vault receipt serving resumed', { reason: 'reachable' });
|
|
179
|
+
}
|
|
180
|
+
#scheduleProbe() {
|
|
181
|
+
if (this.#closed || !this.#outage || this.#probeTimer || this.#probing)
|
|
182
|
+
return;
|
|
183
|
+
this.#probeTimer = this.#setTimeout(() => {
|
|
184
|
+
this.#probeTimer = undefined;
|
|
185
|
+
this.#probe(true);
|
|
186
|
+
}, this.#probeDelay);
|
|
187
|
+
this.#probeTimer.unref?.();
|
|
188
|
+
}
|
|
189
|
+
#probe(idle = false) {
|
|
190
|
+
if (this.#closed || !this.#outage || this.#probing)
|
|
191
|
+
return;
|
|
192
|
+
if (this.#now() - this.#lastProbeAt < 1_000) {
|
|
193
|
+
this.#scheduleProbe();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.#lastProbeAt = this.#now();
|
|
197
|
+
this.#probing = true;
|
|
198
|
+
// Both triggers share list_scoped discovery, including its receipt-drop rules.
|
|
199
|
+
// Sends never await discovery or let idle backoff delay the next send probe.
|
|
200
|
+
void this.discover()
|
|
201
|
+
.catch(() => {
|
|
202
|
+
if (idle && this.#outage)
|
|
203
|
+
this.#probeDelay = Math.min(this.#probeDelay * 2, 30_000);
|
|
204
|
+
})
|
|
205
|
+
.finally(() => {
|
|
206
|
+
this.#probing = false;
|
|
207
|
+
this.#scheduleProbe();
|
|
208
|
+
});
|
|
137
209
|
}
|
|
138
210
|
#check(signal) {
|
|
139
211
|
if (this.#closed)
|
|
@@ -172,17 +244,24 @@ export class ClaustrumScopedCustody {
|
|
|
172
244
|
}
|
|
173
245
|
catch (error) {
|
|
174
246
|
this.#check(signal);
|
|
247
|
+
if (classifyVaultFailure(error) === 'unreachable')
|
|
248
|
+
this.transportLost(error);
|
|
249
|
+
else
|
|
250
|
+
this.reachable();
|
|
175
251
|
// Keep the vault's own refusals (ClaustrumCredentialError, carrying
|
|
176
252
|
// code, class and action); replace any other error, whose text may echo
|
|
177
253
|
// request params that include the enrollment token.
|
|
178
254
|
if (error instanceof ClaustrumCredentialError)
|
|
179
255
|
throw error;
|
|
256
|
+
if (classifyVaultFailure(error) === 'unreachable')
|
|
257
|
+
throw new ClaustrumUnreachableError();
|
|
180
258
|
throw new ClaustrumConsumerError('unavailable', 'Claustrum scoped operation unavailable');
|
|
181
259
|
}
|
|
182
260
|
finally {
|
|
183
261
|
removeAbortListener?.();
|
|
184
262
|
}
|
|
185
263
|
this.#check(signal);
|
|
264
|
+
this.reachable();
|
|
186
265
|
return result;
|
|
187
266
|
}
|
|
188
267
|
/**
|
|
@@ -192,8 +271,22 @@ export class ClaustrumScopedCustody {
|
|
|
192
271
|
* skipped records: they simply are not this consumer's.
|
|
193
272
|
*/
|
|
194
273
|
async discover(signal) {
|
|
195
|
-
|
|
196
|
-
|
|
274
|
+
let inventory;
|
|
275
|
+
let token;
|
|
276
|
+
try {
|
|
277
|
+
token = await this.#token(signal);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
this.#kept.clear();
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
inventory = await this.#call(() => this.#client.listScoped(token), signal);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
this.listFailed(error);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
197
290
|
const skipped = [];
|
|
198
291
|
const candidates = [];
|
|
199
292
|
const counts = new Map();
|
|
@@ -241,22 +334,119 @@ export class ClaustrumScopedCustody {
|
|
|
241
334
|
};
|
|
242
335
|
}
|
|
243
336
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
337
|
+
* Apply a failed account listing: any answer from the vault (a refusal such
|
|
338
|
+
* as a revoked enrollment's `not_found`) drops every kept receipt, while an
|
|
339
|
+
* unreachable vault keeps them. A vault watch that ends with the same
|
|
340
|
+
* refusal calls this too, so both paths treat it alike.
|
|
341
|
+
*/
|
|
342
|
+
listFailed(error) {
|
|
343
|
+
if (classifyVaultFailure(error) === 'unreachable')
|
|
344
|
+
this.transportLost(error);
|
|
345
|
+
else {
|
|
346
|
+
this.reachable();
|
|
347
|
+
this.#kept.clear();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** Discard bearer material when a route is removed, declined or rebound. */
|
|
351
|
+
forget(credentialId) {
|
|
352
|
+
this.#kept.delete(credentialId);
|
|
353
|
+
}
|
|
354
|
+
/** Apply an already-held roster; this never fetches credentials or writes it. */
|
|
355
|
+
retain(identities) {
|
|
356
|
+
const current = new Map(identities.map((identity) => [identity.credentialId, identity]));
|
|
357
|
+
for (const [id, kept] of this.#kept) {
|
|
358
|
+
const identity = current.get(id);
|
|
359
|
+
if (!identity?.enabled ||
|
|
360
|
+
identity.state !== 'active' ||
|
|
361
|
+
identity.credentialType !== kept.attempt.credentialType ||
|
|
362
|
+
identity.accountIdentity !== kept.attempt.expectedAccountIdentity)
|
|
363
|
+
this.forget(id);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/** A frozen, non-secret summary for a plugin's status display. */
|
|
367
|
+
keptReceiptState() {
|
|
368
|
+
let since = null;
|
|
369
|
+
for (const [id, kept] of this.#kept) {
|
|
370
|
+
if (!this.#usable(kept.attempt)) {
|
|
371
|
+
this.forget(id);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (kept.since !== null)
|
|
375
|
+
since = Math.min(since ?? kept.since, kept.since);
|
|
376
|
+
}
|
|
377
|
+
return Object.freeze({ active: since !== null, since });
|
|
378
|
+
}
|
|
379
|
+
#usable(attempt) {
|
|
380
|
+
return attempt.expiresAtMs === null
|
|
381
|
+
? attempt.credentialType === 'api_key'
|
|
382
|
+
: attempt.expiresAtMs - this.#now() > KEPT_RECEIPT_MARGIN_MS;
|
|
383
|
+
}
|
|
384
|
+
/** Ask the vault while reachable; retain one validated receipt in memory only. */
|
|
385
|
+
async authorize(identity, signal, routeId = 'vault') {
|
|
386
|
+
this.#check(signal);
|
|
387
|
+
const { credentialId, credentialType, accountIdentity } = identity;
|
|
388
|
+
let kept = this.#kept.get(credentialId);
|
|
389
|
+
if (kept &&
|
|
390
|
+
(kept.attempt.credentialType !== credentialType ||
|
|
391
|
+
kept.attempt.expectedAccountIdentity !== accountIdentity)) {
|
|
392
|
+
this.forget(credentialId);
|
|
393
|
+
kept = undefined;
|
|
394
|
+
}
|
|
395
|
+
// A missing or invalid enrollment is not a transport outage.
|
|
396
|
+
let token;
|
|
397
|
+
try {
|
|
398
|
+
token = await this.#token(signal);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
this.#kept.clear();
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
if (this.#outage && kept) {
|
|
405
|
+
this.#probe();
|
|
406
|
+
return this.#serveKept(credentialId, kept, token, routeId);
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
const attempt = await this.#authorizeFresh({ credentialId, credentialType, accountIdentity }, token, signal);
|
|
410
|
+
this.#kept.set(credentialId, { attempt, routeId, since: null });
|
|
411
|
+
return attempt;
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
this.#check(signal);
|
|
415
|
+
if (classifyVaultFailure(error) !== 'unreachable') {
|
|
416
|
+
this.forget(credentialId);
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
return this.#serveKept(credentialId, kept, token, routeId, error);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
#serveKept(credentialId, kept, token, routeId, error = this.#outageError) {
|
|
423
|
+
// A concurrent refusal or 401 may already have discarded this entry.
|
|
424
|
+
if (!kept ||
|
|
425
|
+
this.#kept.get(credentialId) !== kept ||
|
|
426
|
+
!this.#usable(kept.attempt)) {
|
|
427
|
+
this.forget(credentialId);
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
if (kept.since === null)
|
|
431
|
+
kept.since = this.#now();
|
|
432
|
+
const attempt = Object.freeze(Object.defineProperty({ ...kept.attempt, fromKeptReceipt: true }, 'accessToken', { value: kept.attempt.accessToken, enumerable: false }));
|
|
433
|
+
this.#provenance.set(attempt, token);
|
|
434
|
+
this.#keptAttempts.set(attempt, routeId);
|
|
435
|
+
return attempt;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Fetch and validate a fresh receipt before retaining it. The vault assertion
|
|
439
|
+
* (or the plugin's token parse) must agree with the roster when both exist.
|
|
440
|
+
* With requireAssertion, an absent assertion also refuses rather than
|
|
441
|
+
* treating the roster's expectation as proof of the served account.
|
|
251
442
|
*/
|
|
252
|
-
async
|
|
443
|
+
async #authorizeFresh(identity, token, signal) {
|
|
253
444
|
if (!identity.credentialId)
|
|
254
445
|
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum dispatch requires a credential id');
|
|
255
446
|
// Capture the caller's fields before yielding so a later mutation cannot move the fence.
|
|
256
447
|
const { credentialId, credentialType, accountIdentity } = identity;
|
|
257
448
|
if (this.#requireAssertion && accountIdentity === undefined)
|
|
258
449
|
throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum dispatch requires a known account identity');
|
|
259
|
-
const token = await this.#token(signal);
|
|
260
450
|
const served = await this.#call(() => this.#client.getScoped({
|
|
261
451
|
credentialId,
|
|
262
452
|
enrollmentToken: token,
|
|
@@ -338,6 +528,21 @@ export class ClaustrumScopedCustody {
|
|
|
338
528
|
const token = this.#provenance.get(attempt);
|
|
339
529
|
if (!token)
|
|
340
530
|
throw new ClaustrumConsumerError('no-receipt', 'Claustrum failure report requires an original dispatch receipt');
|
|
531
|
+
const keptRoute = this.#keptAttempts.get(attempt);
|
|
532
|
+
if (keptRoute !== undefined) {
|
|
533
|
+
this.forget(attempt.credentialId);
|
|
534
|
+
this.#logger.debug('scoped 401 not reported', {
|
|
535
|
+
routeId: keptRoute,
|
|
536
|
+
reason: 'kept-receipt',
|
|
537
|
+
});
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// The provider rejected this token, so it must not come back as a kept
|
|
541
|
+
// receipt during a later outage. Drop it before reporting, so a report that
|
|
542
|
+
// fails because the vault is unreachable still drops it.
|
|
543
|
+
const kept = this.#kept.get(attempt.credentialId);
|
|
544
|
+
if (kept && kept.attempt.recordVersion === attempt.recordVersion)
|
|
545
|
+
this.forget(attempt.credentialId);
|
|
341
546
|
const pending = this.#reports.get(attempt);
|
|
342
547
|
if (pending)
|
|
343
548
|
return pending;
|
|
@@ -366,6 +571,10 @@ export class ClaustrumScopedCustody {
|
|
|
366
571
|
if (this.#closed)
|
|
367
572
|
return;
|
|
368
573
|
this.#closed = true;
|
|
574
|
+
if (this.#probeTimer)
|
|
575
|
+
this.#clearTimeout(this.#probeTimer);
|
|
576
|
+
this.#probeTimer = undefined;
|
|
577
|
+
this.#kept.clear();
|
|
369
578
|
this.#client.close();
|
|
370
579
|
}
|
|
371
580
|
}
|
|
@@ -5,6 +5,7 @@ import { readClaustrumEnrollmentToken } from "./b9588ad4725c1d10-enrollment.js";
|
|
|
5
5
|
import { ClaustrumConsumerError } from "./ca0fccaba1d87af4-errors.js";
|
|
6
6
|
import { acquireInsulaQuotaSource } from "./d0f0b6be200311af-insula.js";
|
|
7
7
|
import { acceptVaultRoute, declineVaultRoute, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from "./30f5bc480f31f51c-roster.js";
|
|
8
|
+
import { SAFETY_POLL_MS, ScopedRosterWatch } from "./dc9290c2a7552ba6-watch.js";
|
|
8
9
|
/**
|
|
9
10
|
* One plugin host's view of its vault accounts. It turns vault credentials
|
|
10
11
|
* into rows for `/routing`, authorizes each physical send with the vault, and
|
|
@@ -18,6 +19,8 @@ export class ClaustrumConsumer {
|
|
|
18
19
|
#shutdown = new AbortController();
|
|
19
20
|
#logger;
|
|
20
21
|
#custody;
|
|
22
|
+
/** The custody's client, kept so `start()` can open its vault watch. */
|
|
23
|
+
#client;
|
|
21
24
|
#connecting;
|
|
22
25
|
#refreshing;
|
|
23
26
|
#roster;
|
|
@@ -29,6 +32,10 @@ export class ClaustrumConsumer {
|
|
|
29
32
|
#timer;
|
|
30
33
|
#started = false;
|
|
31
34
|
#insula;
|
|
35
|
+
#vaultWatch;
|
|
36
|
+
#releaseInsulaWatch;
|
|
37
|
+
/** When this instance last completed a discovery, by `now`. */
|
|
38
|
+
#discoveredAt;
|
|
32
39
|
constructor(options) {
|
|
33
40
|
this.#options = options;
|
|
34
41
|
this.#logger = options.logger ?? createLogger('claustrum');
|
|
@@ -83,6 +90,7 @@ export class ClaustrumConsumer {
|
|
|
83
90
|
client.close();
|
|
84
91
|
this.#assertOpen();
|
|
85
92
|
}
|
|
93
|
+
this.#client = client;
|
|
86
94
|
this.#custody = new ClaustrumScopedCustody({
|
|
87
95
|
client,
|
|
88
96
|
family: this.#options.family,
|
|
@@ -91,6 +99,8 @@ export class ClaustrumConsumer {
|
|
|
91
99
|
requireAssertion: this.#options.requireAssertion,
|
|
92
100
|
now: this.#options.now,
|
|
93
101
|
logger: this.#logger,
|
|
102
|
+
setTimeoutImpl: this.#options.setTimeoutImpl,
|
|
103
|
+
clearTimeoutImpl: this.#options.clearTimeoutImpl,
|
|
94
104
|
});
|
|
95
105
|
if (this.#options.insula && client.insula) {
|
|
96
106
|
this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
|
|
@@ -122,6 +132,28 @@ export class ClaustrumConsumer {
|
|
|
122
132
|
? latest.reach
|
|
123
133
|
: undefined;
|
|
124
134
|
}
|
|
135
|
+
/** How this consumer currently learns about changes, without I/O. */
|
|
136
|
+
watchState() {
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
vault: this.#vaultWatch?.mode ?? 'polling',
|
|
139
|
+
...(this.#options.insula && {
|
|
140
|
+
insula: this.#insula?.source.watchMode() ?? 'polling',
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** Close the watches with the connection they belong to. */
|
|
145
|
+
#stopWatch() {
|
|
146
|
+
this.#vaultWatch?.stop();
|
|
147
|
+
this.#vaultWatch = undefined;
|
|
148
|
+
this.#client = undefined;
|
|
149
|
+
this.#releaseInsulaWatch?.();
|
|
150
|
+
this.#releaseInsulaWatch = undefined;
|
|
151
|
+
}
|
|
152
|
+
/** Whether any account is using an in-memory outage receipt, without I/O. */
|
|
153
|
+
keptReceiptState() {
|
|
154
|
+
return (this.#custody?.keptReceiptState() ??
|
|
155
|
+
Object.freeze({ active: false, since: null }));
|
|
156
|
+
}
|
|
125
157
|
/** Rows for `/routing`, from the last committed roster. */
|
|
126
158
|
routingRows() {
|
|
127
159
|
return vaultRoutingRows(this.#roster);
|
|
@@ -134,6 +166,7 @@ export class ClaustrumConsumer {
|
|
|
134
166
|
if (!(await this.#active())) {
|
|
135
167
|
this.#roster = undefined;
|
|
136
168
|
this.#grantReach = undefined;
|
|
169
|
+
this.#stopWatch();
|
|
137
170
|
this.#custody?.close();
|
|
138
171
|
this.#custody = undefined;
|
|
139
172
|
this.#insula?.release();
|
|
@@ -164,6 +197,7 @@ export class ClaustrumConsumer {
|
|
|
164
197
|
this.#assertOpen();
|
|
165
198
|
const changed = roster?.view !== this.#roster?.view;
|
|
166
199
|
this.#roster = roster;
|
|
200
|
+
custody.retain(roster?.rows ?? []);
|
|
167
201
|
// Each discovery replaces the previous value, including with unknown. A
|
|
168
202
|
// refresh that served a peer's roster discovered nothing and leaves it;
|
|
169
203
|
// grantReach() hides it once the roster's view no longer matches.
|
|
@@ -175,12 +209,23 @@ export class ClaustrumConsumer {
|
|
|
175
209
|
// notification, and the poll runs every few seconds.
|
|
176
210
|
if (roster && changed)
|
|
177
211
|
this.#options.onRoster?.(roster);
|
|
212
|
+
this.#discoveredAt = (this.#options.now ?? Date.now)();
|
|
178
213
|
return roster;
|
|
179
214
|
})().finally(() => {
|
|
180
215
|
this.#refreshing = undefined;
|
|
181
216
|
});
|
|
182
217
|
return this.#refreshing;
|
|
183
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Poll in the background and open the vault and insula watches. Until the
|
|
221
|
+
* vault watch is accepted, and whenever it is unavailable, every tick lists
|
|
222
|
+
* the vault exactly as before watches existed. While it is open a tick
|
|
223
|
+
* lists only when five minutes have passed since the last discovery, since
|
|
224
|
+
* the watch keeps no history; it still checks `isCustodyActive`, so a switch
|
|
225
|
+
* to local mode closes the connection as quickly as before. Insula is
|
|
226
|
+
* polled on the tick only while its own watch is not open. A host not in
|
|
227
|
+
* vault mode makes no request.
|
|
228
|
+
*/
|
|
184
229
|
start() {
|
|
185
230
|
if (this.#started)
|
|
186
231
|
return;
|
|
@@ -188,8 +233,10 @@ export class ClaustrumConsumer {
|
|
|
188
233
|
this.#started = true;
|
|
189
234
|
const tick = async () => {
|
|
190
235
|
try {
|
|
191
|
-
await this
|
|
236
|
+
if (await this.#listDue())
|
|
237
|
+
await this.refresh();
|
|
192
238
|
await this.refreshInsulaQuota();
|
|
239
|
+
this.#openWatch();
|
|
193
240
|
}
|
|
194
241
|
catch (error) {
|
|
195
242
|
if (!this.#shutdown.signal.aborted)
|
|
@@ -208,6 +255,65 @@ export class ClaustrumConsumer {
|
|
|
208
255
|
};
|
|
209
256
|
void tick();
|
|
210
257
|
}
|
|
258
|
+
/** Whether a background tick must list the vault now. */
|
|
259
|
+
async #listDue() {
|
|
260
|
+
if (this.#vaultWatch?.mode !== 'watching')
|
|
261
|
+
return true;
|
|
262
|
+
const now = (this.#options.now ?? Date.now)();
|
|
263
|
+
if (this.#discoveredAt === undefined ||
|
|
264
|
+
now - this.#discoveredAt >= SAFETY_POLL_MS)
|
|
265
|
+
return true;
|
|
266
|
+
// refresh() is what closes the connection when the host left vault mode.
|
|
267
|
+
return !(await this.#active());
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Open the vault and insula watches once a vault connection exists. Only
|
|
271
|
+
* reached in vault mode: a host in local mode never connects.
|
|
272
|
+
*/
|
|
273
|
+
#openWatch() {
|
|
274
|
+
const client = this.#client;
|
|
275
|
+
if (this.#shutdown.signal.aborted || !client || !this.#custody)
|
|
276
|
+
return;
|
|
277
|
+
if (!this.#vaultWatch) {
|
|
278
|
+
const watch = new ScopedRosterWatch({
|
|
279
|
+
client,
|
|
280
|
+
enrollmentToken: async () => (await readClaustrumEnrollmentToken(this.#options.tokenPath)).token,
|
|
281
|
+
view: () => this.#roster?.view,
|
|
282
|
+
// The existing discovery, so a watched change commits the roster and
|
|
283
|
+
// re-applies it to kept receipts exactly as a poll does. A refresh
|
|
284
|
+
// already in flight may have listed before the change was announced,
|
|
285
|
+
// so wait for it and list again.
|
|
286
|
+
discover: async () => {
|
|
287
|
+
while (this.#refreshing)
|
|
288
|
+
await this.#refreshing.catch(() => { });
|
|
289
|
+
await this.refresh();
|
|
290
|
+
},
|
|
291
|
+
onRefused: (error) => {
|
|
292
|
+
// As a poll that met the same refusal from list_scoped would.
|
|
293
|
+
this.#custody?.listFailed(error);
|
|
294
|
+
this.#options.onError?.(error);
|
|
295
|
+
},
|
|
296
|
+
onTransportLost: () => this.#custody?.transportLost(),
|
|
297
|
+
onReachable: () => this.#custody?.reachable(),
|
|
298
|
+
onError: (error) => {
|
|
299
|
+
if (!this.#shutdown.signal.aborted)
|
|
300
|
+
this.#options.onError?.(error);
|
|
301
|
+
},
|
|
302
|
+
logger: this.#logger,
|
|
303
|
+
now: this.#options.now,
|
|
304
|
+
setTimeoutImpl: this.#options.setTimeoutImpl,
|
|
305
|
+
clearTimeoutImpl: this.#options.clearTimeoutImpl,
|
|
306
|
+
});
|
|
307
|
+
this.#vaultWatch = watch;
|
|
308
|
+
watch.start();
|
|
309
|
+
}
|
|
310
|
+
if (this.#options.insula && this.#insula && !this.#releaseInsulaWatch)
|
|
311
|
+
this.#releaseInsulaWatch = this.#insula.source.watch({
|
|
312
|
+
provider: this.#options.insula.provider,
|
|
313
|
+
setTimeoutImpl: this.#options.setTimeoutImpl,
|
|
314
|
+
clearTimeoutImpl: this.#options.clearTimeoutImpl,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
211
317
|
/**
|
|
212
318
|
* Fetch on a background tick, at most once per minute across projects with
|
|
213
319
|
* the same daemon and enrollment token path. Admission must use the cache
|
|
@@ -282,20 +388,33 @@ export class ClaustrumConsumer {
|
|
|
282
388
|
async authorize(routeId, signal) {
|
|
283
389
|
this.#assertOpen(signal);
|
|
284
390
|
const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
|
|
285
|
-
if (!roster || !(await this.#active()))
|
|
391
|
+
if (!roster || !(await this.#active())) {
|
|
392
|
+
this.#custody?.close();
|
|
393
|
+
this.#custody = undefined;
|
|
394
|
+
this.#stopWatch();
|
|
395
|
+
this.#insula?.release();
|
|
396
|
+
this.#insula = undefined;
|
|
286
397
|
throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
|
|
398
|
+
}
|
|
287
399
|
const current = await readVaultRoster(this.#options.rosterPath);
|
|
288
400
|
this.#assertOpen(signal);
|
|
289
401
|
const observed = roster.rows.find((row) => row.routeId === routeId);
|
|
290
402
|
const configured = current?.rows.find((row) => row.routeId === routeId);
|
|
291
|
-
if (configured && !configured.enabled)
|
|
403
|
+
if (configured && !configured.enabled) {
|
|
404
|
+
if (observed)
|
|
405
|
+
this.#custody?.forget(observed.credentialId);
|
|
292
406
|
throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
|
|
407
|
+
}
|
|
293
408
|
if (!observed ||
|
|
294
409
|
!configured ||
|
|
295
410
|
configured.state !== 'active' ||
|
|
296
411
|
configured.credentialId !== observed.credentialId ||
|
|
297
|
-
configured.accountIdentity !== observed.accountIdentity
|
|
412
|
+
configured.accountIdentity !== observed.accountIdentity ||
|
|
413
|
+
configured.credentialType !== observed.credentialType) {
|
|
414
|
+
if (observed)
|
|
415
|
+
this.#custody?.forget(observed.credentialId);
|
|
298
416
|
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
|
|
417
|
+
}
|
|
299
418
|
const custody = await this.#wait(this.#getCustody(), signal);
|
|
300
419
|
const combined = combineAbortSignals([
|
|
301
420
|
this.#shutdown.signal,
|
|
@@ -308,7 +427,7 @@ export class ClaustrumConsumer {
|
|
|
308
427
|
...(configured.accountIdentity !== undefined && {
|
|
309
428
|
accountIdentity: configured.accountIdentity,
|
|
310
429
|
}),
|
|
311
|
-
}, combined.signal);
|
|
430
|
+
}, combined.signal, routeId);
|
|
312
431
|
}
|
|
313
432
|
finally {
|
|
314
433
|
combined.dispose();
|
|
@@ -332,6 +451,10 @@ export class ClaustrumConsumer {
|
|
|
332
451
|
const signal = options.signal;
|
|
333
452
|
let served = await this.authorize(routeId, signal);
|
|
334
453
|
let response = await dispatch(served, signal);
|
|
454
|
+
if (response.status === 401 && served.fromKeptReceipt) {
|
|
455
|
+
await this.reportFailure(served, 401, options.reporterSource ?? 'direct');
|
|
456
|
+
return response;
|
|
457
|
+
}
|
|
335
458
|
if (response.status === 401 && !signal?.aborted) {
|
|
336
459
|
let current;
|
|
337
460
|
try {
|
|
@@ -366,6 +489,7 @@ export class ClaustrumConsumer {
|
|
|
366
489
|
this.#assertOpen();
|
|
367
490
|
await declineVaultRoute(this.#options.rosterPath, routeId);
|
|
368
491
|
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
492
|
+
this.#custody?.retain(this.#roster?.rows ?? []);
|
|
369
493
|
}
|
|
370
494
|
async accept(routeId) {
|
|
371
495
|
this.#assertOpen();
|
|
@@ -421,6 +545,7 @@ export class ClaustrumConsumer {
|
|
|
421
545
|
if (this.#timer)
|
|
422
546
|
(this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
|
|
423
547
|
this.#timer = undefined;
|
|
548
|
+
this.#stopWatch();
|
|
424
549
|
this.#custody?.close();
|
|
425
550
|
this.#insula?.release();
|
|
426
551
|
this.#insula = undefined;
|
|
@@ -184,19 +184,25 @@ export async function replaceWindowsFile(from, to, options = {}) {
|
|
|
184
184
|
to = resolve(to);
|
|
185
185
|
const now = options.now ?? (() => performance.now());
|
|
186
186
|
const start = now();
|
|
187
|
-
const
|
|
187
|
+
const leaseDeadline = options.deadline ?? Infinity;
|
|
188
|
+
let retryDeadline;
|
|
188
189
|
const delays = [0, 10, 20, 40, 80, 100];
|
|
189
190
|
let original;
|
|
190
191
|
let attempts = 0;
|
|
191
192
|
for (const delay of delays) {
|
|
193
|
+
const deadline = Math.min(retryDeadline ?? Infinity, leaseDeadline);
|
|
192
194
|
if (now() >= deadline)
|
|
193
195
|
break;
|
|
194
196
|
if (delay)
|
|
195
197
|
await (options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(Math.min(delay, Math.max(0, deadline - now())));
|
|
196
198
|
if (now() >= deadline)
|
|
197
199
|
break;
|
|
200
|
+
const fenceStart = now();
|
|
198
201
|
await options.beforeRename?.();
|
|
199
|
-
|
|
202
|
+
// Ownership checks fence every attempt but do not spend the sharing-retry budget.
|
|
203
|
+
if (retryDeadline !== undefined)
|
|
204
|
+
retryDeadline += now() - fenceStart;
|
|
205
|
+
if (now() >= leaseDeadline)
|
|
200
206
|
break;
|
|
201
207
|
attempts++;
|
|
202
208
|
try {
|
|
@@ -212,6 +218,8 @@ export async function replaceWindowsFile(from, to, options = {}) {
|
|
|
212
218
|
!detail.reason;
|
|
213
219
|
if (!candidate)
|
|
214
220
|
break;
|
|
221
|
+
// Start the retry window only once a rename has actually failed.
|
|
222
|
+
retryDeadline ??= now() + 500;
|
|
215
223
|
}
|
|
216
224
|
}
|
|
217
225
|
const error = new PrivateFileError(to, 'rename', original ??
|