@cortexkit/opencode-openai-auth 1.1.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 +8 -8
- package/dist/{codex-request-BXDXToyt.d.ts → codex-request-B9D0e97Q.d.ts} +177 -28
- package/dist/{index-7c6sxtd6.js → index-1mbgsxsr.js} +2 -2
- package/dist/{index-4c8yw54k.js → index-2pgkrebq.js} +2 -2
- package/dist/index-4v2fs42x.js +65 -0
- package/dist/index-8x1aj7q3.js +2 -0
- package/dist/{index-f2fmb596.js → index-bc89akak.js} +1 -1
- package/dist/index-ex6nx3mq.js +2 -0
- package/dist/{index-zpm95mt5.js → index-ftkhhr6d.js} +1 -1
- package/dist/{index-msx2m78k.js → index-gn2b097f.js} +2 -2
- package/dist/{index-j8v2xwq6.js → index-m3ctrabv.js} +1 -1
- package/dist/index-psjyexj3.js +69 -0
- package/dist/{index-k6cydkgt.js → index-tddr876f.js} +2 -2
- package/dist/index-tn8x5tz8.js +90 -0
- package/dist/{index-csky2xpg.js → index-xwvze96g.js} +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +1 -1
- package/dist/{model-YRg7Uowo.d.ts → model-BdITTmeb.d.ts} +1 -1
- package/dist/{port-file-CxEpgSHY.d.ts → port-file-DbHIVC9a.d.ts} +1 -1
- package/dist/{protocol-CDV1Pc6h.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/rpc/rpc-dir.js +1 -1
- package/dist/{sidebar-state-DjTWUbmf.d.ts → sidebar-state-CVSok7zv.d.ts} +114 -5
- package/dist/sidebar-state.d.ts +2 -2
- package/dist/sidebar-state.js +1 -1
- package/dist/token-util-cf0nyh7n.js +1 -0
- package/dist/{token-rz4t3ay6.js → token-z6qq7a1d.js} +1 -1
- package/dist/tui-preferences.js +1 -1
- package/dist/tui.d.ts +2 -2
- package/dist/v2/server.d.ts +109 -2
- package/dist/v2/server.js +3 -3
- package/package.json +4 -4
- package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +203 -18
- package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +242 -16
- package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +168 -6
- 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/d5a8f1061ee2f114-index.js +1 -1
- 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/raw/sidebar-state.js +63 -1
- package/src/tui-compiled/raw/tui.tsx +82 -30
- package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +203 -18
- package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +242 -16
- package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +168 -6
- 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/d5a8f1061ee2f114-index.js +1 -1
- package/src/tui-compiled/runtime/shared/dc9290c2a7552ba6-watch.js +253 -0
- package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +4 -1
- package/src/tui-compiled/runtime/sidebar-state.js +63 -1
- package/src/tui-compiled/runtime/tui.js +245 -208
- package/dist/index-0yh85bwb.js +0 -69
- package/dist/index-7eb3dw37.js +0 -90
- package/dist/index-8re8rprk.js +0 -2
- package/dist/index-edbdscy7.js +0 -65
- package/dist/index-m5qmvr6c.js +0 -2
- package/dist/map-Buq-GW7l.d.ts +0 -56
- package/dist/token-util-tpy2c7h6.js +0 -1
|
@@ -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
|
}
|
|
@@ -83,13 +88,28 @@ function credentialTypeOf(row, family) {
|
|
|
83
88
|
return row.refreshAdapter === undefined ? 'api_key' : undefined;
|
|
84
89
|
return undefined;
|
|
85
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The grants named in the vault's list reply, or undefined when it names none.
|
|
93
|
+
* Both fields must be present: a reply carrying only one of them is treated as
|
|
94
|
+
* unknown rather than completed with a guess. claustrum-client already refuses
|
|
95
|
+
* a malformed value, so nothing here validates it again.
|
|
96
|
+
*/
|
|
97
|
+
function grantReachOf(listed) {
|
|
98
|
+
const { grants, grantTuples } = listed;
|
|
99
|
+
if (grants === undefined || grantTuples === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
return Object.freeze({
|
|
102
|
+
grants,
|
|
103
|
+
tuples: Object.freeze(grantTuples.map(({ selectorKind, selector, operation }) => Object.freeze({ selectorKind, selector, operation }))),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
86
106
|
/**
|
|
87
107
|
* Reads this consumer's vault credentials (list, fetch, 401 report), with the
|
|
88
|
-
* enrollment token as authorization. Used by every host of a plugin.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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.
|
|
93
113
|
*/
|
|
94
114
|
export class ClaustrumScopedCustody {
|
|
95
115
|
#client;
|
|
@@ -101,6 +121,16 @@ export class ClaustrumScopedCustody {
|
|
|
101
121
|
#logger;
|
|
102
122
|
#provenance = new WeakMap();
|
|
103
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;
|
|
104
134
|
#closed = false;
|
|
105
135
|
constructor(options) {
|
|
106
136
|
const tokenPath = options.tokenPath;
|
|
@@ -119,6 +149,63 @@ export class ClaustrumScopedCustody {
|
|
|
119
149
|
this.#requireAssertion = options.requireAssertion ?? false;
|
|
120
150
|
this.#now = options.now ?? Date.now;
|
|
121
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
|
+
});
|
|
122
209
|
}
|
|
123
210
|
#check(signal) {
|
|
124
211
|
if (this.#closed)
|
|
@@ -157,17 +244,24 @@ export class ClaustrumScopedCustody {
|
|
|
157
244
|
}
|
|
158
245
|
catch (error) {
|
|
159
246
|
this.#check(signal);
|
|
247
|
+
if (classifyVaultFailure(error) === 'unreachable')
|
|
248
|
+
this.transportLost(error);
|
|
249
|
+
else
|
|
250
|
+
this.reachable();
|
|
160
251
|
// Keep the vault's own refusals (ClaustrumCredentialError, carrying
|
|
161
252
|
// code, class and action); replace any other error, whose text may echo
|
|
162
253
|
// request params that include the enrollment token.
|
|
163
254
|
if (error instanceof ClaustrumCredentialError)
|
|
164
255
|
throw error;
|
|
256
|
+
if (classifyVaultFailure(error) === 'unreachable')
|
|
257
|
+
throw new ClaustrumUnreachableError();
|
|
165
258
|
throw new ClaustrumConsumerError('unavailable', 'Claustrum scoped operation unavailable');
|
|
166
259
|
}
|
|
167
260
|
finally {
|
|
168
261
|
removeAbortListener?.();
|
|
169
262
|
}
|
|
170
263
|
this.#check(signal);
|
|
264
|
+
this.reachable();
|
|
171
265
|
return result;
|
|
172
266
|
}
|
|
173
267
|
/**
|
|
@@ -177,8 +271,22 @@ export class ClaustrumScopedCustody {
|
|
|
177
271
|
* skipped records: they simply are not this consumer's.
|
|
178
272
|
*/
|
|
179
273
|
async discover(signal) {
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
}
|
|
182
290
|
const skipped = [];
|
|
183
291
|
const candidates = [];
|
|
184
292
|
const counts = new Map();
|
|
@@ -217,29 +325,128 @@ export class ClaustrumScopedCustody {
|
|
|
217
325
|
}
|
|
218
326
|
for (const record of skipped)
|
|
219
327
|
this.#logger.warn('skipped malformed vault record', record);
|
|
328
|
+
const grantReach = grantReachOf(inventory);
|
|
220
329
|
return {
|
|
221
330
|
view: inventory.view,
|
|
222
331
|
credentials: Object.freeze(credentials),
|
|
223
332
|
skipped: Object.freeze(skipped),
|
|
333
|
+
...(grantReach && { grantReach }),
|
|
224
334
|
};
|
|
225
335
|
}
|
|
226
336
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* refuse; the receipt records what the vault asserted separately from what
|
|
232
|
-
* the roster expected. With it, a reply that does not itself name the
|
|
233
|
-
* credential id and the expected account is refused.
|
|
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.
|
|
234
341
|
*/
|
|
235
|
-
|
|
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.
|
|
442
|
+
*/
|
|
443
|
+
async #authorizeFresh(identity, token, signal) {
|
|
236
444
|
if (!identity.credentialId)
|
|
237
445
|
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum dispatch requires a credential id');
|
|
238
446
|
// Capture the caller's fields before yielding so a later mutation cannot move the fence.
|
|
239
447
|
const { credentialId, credentialType, accountIdentity } = identity;
|
|
240
448
|
if (this.#requireAssertion && accountIdentity === undefined)
|
|
241
449
|
throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum dispatch requires a known account identity');
|
|
242
|
-
const token = await this.#token(signal);
|
|
243
450
|
const served = await this.#call(() => this.#client.getScoped({
|
|
244
451
|
credentialId,
|
|
245
452
|
enrollmentToken: token,
|
|
@@ -321,6 +528,21 @@ export class ClaustrumScopedCustody {
|
|
|
321
528
|
const token = this.#provenance.get(attempt);
|
|
322
529
|
if (!token)
|
|
323
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);
|
|
324
546
|
const pending = this.#reports.get(attempt);
|
|
325
547
|
if (pending)
|
|
326
548
|
return pending;
|
|
@@ -349,6 +571,10 @@ export class ClaustrumScopedCustody {
|
|
|
349
571
|
if (this.#closed)
|
|
350
572
|
return;
|
|
351
573
|
this.#closed = true;
|
|
574
|
+
if (this.#probeTimer)
|
|
575
|
+
this.#clearTimeout(this.#probeTimer);
|
|
576
|
+
this.#probeTimer = undefined;
|
|
577
|
+
this.#kept.clear();
|
|
352
578
|
this.#client.close();
|
|
353
579
|
}
|
|
354
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,12 +19,23 @@ 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;
|
|
27
|
+
/**
|
|
28
|
+
* The grants named by this instance's latest discovery, with the view that
|
|
29
|
+
* discovery committed. Memory only: the roster file never carries it.
|
|
30
|
+
*/
|
|
31
|
+
#grantReach;
|
|
24
32
|
#timer;
|
|
25
33
|
#started = false;
|
|
26
34
|
#insula;
|
|
35
|
+
#vaultWatch;
|
|
36
|
+
#releaseInsulaWatch;
|
|
37
|
+
/** When this instance last completed a discovery, by `now`. */
|
|
38
|
+
#discoveredAt;
|
|
27
39
|
constructor(options) {
|
|
28
40
|
this.#options = options;
|
|
29
41
|
this.#logger = options.logger ?? createLogger('claustrum');
|
|
@@ -78,6 +90,7 @@ export class ClaustrumConsumer {
|
|
|
78
90
|
client.close();
|
|
79
91
|
this.#assertOpen();
|
|
80
92
|
}
|
|
93
|
+
this.#client = client;
|
|
81
94
|
this.#custody = new ClaustrumScopedCustody({
|
|
82
95
|
client,
|
|
83
96
|
family: this.#options.family,
|
|
@@ -86,6 +99,8 @@ export class ClaustrumConsumer {
|
|
|
86
99
|
requireAssertion: this.#options.requireAssertion,
|
|
87
100
|
now: this.#options.now,
|
|
88
101
|
logger: this.#logger,
|
|
102
|
+
setTimeoutImpl: this.#options.setTimeoutImpl,
|
|
103
|
+
clearTimeoutImpl: this.#options.clearTimeoutImpl,
|
|
89
104
|
});
|
|
90
105
|
if (this.#options.insula && client.insula) {
|
|
91
106
|
this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
|
|
@@ -102,6 +117,43 @@ export class ClaustrumConsumer {
|
|
|
102
117
|
snapshot() {
|
|
103
118
|
return this.#roster;
|
|
104
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* What this consumer is granted, from its latest discovery, without any I/O.
|
|
122
|
+
* It explains an empty roster: `grants === 0` means no grant at all, while
|
|
123
|
+
* `grants > 0` means a grant that reaches no credential (`tuples` names its
|
|
124
|
+
* selector). Undefined means unknown, never zero: the vault or client is too
|
|
125
|
+
* old to send it, nothing was discovered yet, or the roster now in memory is
|
|
126
|
+
* a different view than the one this instance discovered (a peer process
|
|
127
|
+
* held the discovery lease and its newer roster was served instead).
|
|
128
|
+
*/
|
|
129
|
+
grantReach() {
|
|
130
|
+
const latest = this.#grantReach;
|
|
131
|
+
return latest && this.#roster?.view === latest.view
|
|
132
|
+
? latest.reach
|
|
133
|
+
: undefined;
|
|
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
|
+
}
|
|
105
157
|
/** Rows for `/routing`, from the last committed roster. */
|
|
106
158
|
routingRows() {
|
|
107
159
|
return vaultRoutingRows(this.#roster);
|
|
@@ -113,6 +165,8 @@ export class ClaustrumConsumer {
|
|
|
113
165
|
this.#refreshing = (async () => {
|
|
114
166
|
if (!(await this.#active())) {
|
|
115
167
|
this.#roster = undefined;
|
|
168
|
+
this.#grantReach = undefined;
|
|
169
|
+
this.#stopWatch();
|
|
116
170
|
this.#custody?.close();
|
|
117
171
|
this.#custody = undefined;
|
|
118
172
|
this.#insula?.release();
|
|
@@ -120,9 +174,17 @@ export class ClaustrumConsumer {
|
|
|
120
174
|
return undefined;
|
|
121
175
|
}
|
|
122
176
|
const custody = await this.#getCustody();
|
|
177
|
+
// Capture the inventory so its grant fields can be kept beside the roster;
|
|
178
|
+
// the roster file itself never stores them.
|
|
179
|
+
let discovered;
|
|
123
180
|
const roster = await refreshVaultRoster({
|
|
124
181
|
path: this.#options.rosterPath,
|
|
125
|
-
custody
|
|
182
|
+
custody: {
|
|
183
|
+
discover: async (signal) => {
|
|
184
|
+
discovered = await custody.discover(signal);
|
|
185
|
+
return discovered;
|
|
186
|
+
},
|
|
187
|
+
},
|
|
126
188
|
isActive: () => this.#active(),
|
|
127
189
|
signal: this.#shutdown.signal,
|
|
128
190
|
projection: () => ({
|
|
@@ -135,16 +197,35 @@ export class ClaustrumConsumer {
|
|
|
135
197
|
this.#assertOpen();
|
|
136
198
|
const changed = roster?.view !== this.#roster?.view;
|
|
137
199
|
this.#roster = roster;
|
|
200
|
+
custody.retain(roster?.rows ?? []);
|
|
201
|
+
// Each discovery replaces the previous value, including with unknown. A
|
|
202
|
+
// refresh that served a peer's roster discovered nothing and leaves it;
|
|
203
|
+
// grantReach() hides it once the roster's view no longer matches.
|
|
204
|
+
if (discovered && roster?.view !== undefined)
|
|
205
|
+
this.#grantReach = discovered.grantReach
|
|
206
|
+
? { view: roster.view, reach: discovered.grantReach }
|
|
207
|
+
: undefined;
|
|
138
208
|
// Notify on view changes only: subscribers rewrite shared state on each
|
|
139
209
|
// notification, and the poll runs every few seconds.
|
|
140
210
|
if (roster && changed)
|
|
141
211
|
this.#options.onRoster?.(roster);
|
|
212
|
+
this.#discoveredAt = (this.#options.now ?? Date.now)();
|
|
142
213
|
return roster;
|
|
143
214
|
})().finally(() => {
|
|
144
215
|
this.#refreshing = undefined;
|
|
145
216
|
});
|
|
146
217
|
return this.#refreshing;
|
|
147
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
|
+
*/
|
|
148
229
|
start() {
|
|
149
230
|
if (this.#started)
|
|
150
231
|
return;
|
|
@@ -152,8 +233,10 @@ export class ClaustrumConsumer {
|
|
|
152
233
|
this.#started = true;
|
|
153
234
|
const tick = async () => {
|
|
154
235
|
try {
|
|
155
|
-
await this
|
|
236
|
+
if (await this.#listDue())
|
|
237
|
+
await this.refresh();
|
|
156
238
|
await this.refreshInsulaQuota();
|
|
239
|
+
this.#openWatch();
|
|
157
240
|
}
|
|
158
241
|
catch (error) {
|
|
159
242
|
if (!this.#shutdown.signal.aborted)
|
|
@@ -172,6 +255,65 @@ export class ClaustrumConsumer {
|
|
|
172
255
|
};
|
|
173
256
|
void tick();
|
|
174
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
|
+
}
|
|
175
317
|
/**
|
|
176
318
|
* Fetch on a background tick, at most once per minute across projects with
|
|
177
319
|
* the same daemon and enrollment token path. Admission must use the cache
|
|
@@ -246,20 +388,33 @@ export class ClaustrumConsumer {
|
|
|
246
388
|
async authorize(routeId, signal) {
|
|
247
389
|
this.#assertOpen(signal);
|
|
248
390
|
const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
|
|
249
|
-
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;
|
|
250
397
|
throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
|
|
398
|
+
}
|
|
251
399
|
const current = await readVaultRoster(this.#options.rosterPath);
|
|
252
400
|
this.#assertOpen(signal);
|
|
253
401
|
const observed = roster.rows.find((row) => row.routeId === routeId);
|
|
254
402
|
const configured = current?.rows.find((row) => row.routeId === routeId);
|
|
255
|
-
if (configured && !configured.enabled)
|
|
403
|
+
if (configured && !configured.enabled) {
|
|
404
|
+
if (observed)
|
|
405
|
+
this.#custody?.forget(observed.credentialId);
|
|
256
406
|
throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
|
|
407
|
+
}
|
|
257
408
|
if (!observed ||
|
|
258
409
|
!configured ||
|
|
259
410
|
configured.state !== 'active' ||
|
|
260
411
|
configured.credentialId !== observed.credentialId ||
|
|
261
|
-
configured.accountIdentity !== observed.accountIdentity
|
|
412
|
+
configured.accountIdentity !== observed.accountIdentity ||
|
|
413
|
+
configured.credentialType !== observed.credentialType) {
|
|
414
|
+
if (observed)
|
|
415
|
+
this.#custody?.forget(observed.credentialId);
|
|
262
416
|
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
|
|
417
|
+
}
|
|
263
418
|
const custody = await this.#wait(this.#getCustody(), signal);
|
|
264
419
|
const combined = combineAbortSignals([
|
|
265
420
|
this.#shutdown.signal,
|
|
@@ -272,7 +427,7 @@ export class ClaustrumConsumer {
|
|
|
272
427
|
...(configured.accountIdentity !== undefined && {
|
|
273
428
|
accountIdentity: configured.accountIdentity,
|
|
274
429
|
}),
|
|
275
|
-
}, combined.signal);
|
|
430
|
+
}, combined.signal, routeId);
|
|
276
431
|
}
|
|
277
432
|
finally {
|
|
278
433
|
combined.dispose();
|
|
@@ -296,6 +451,10 @@ export class ClaustrumConsumer {
|
|
|
296
451
|
const signal = options.signal;
|
|
297
452
|
let served = await this.authorize(routeId, signal);
|
|
298
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
|
+
}
|
|
299
458
|
if (response.status === 401 && !signal?.aborted) {
|
|
300
459
|
let current;
|
|
301
460
|
try {
|
|
@@ -330,6 +489,7 @@ export class ClaustrumConsumer {
|
|
|
330
489
|
this.#assertOpen();
|
|
331
490
|
await declineVaultRoute(this.#options.rosterPath, routeId);
|
|
332
491
|
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
492
|
+
this.#custody?.retain(this.#roster?.rows ?? []);
|
|
333
493
|
}
|
|
334
494
|
async accept(routeId) {
|
|
335
495
|
this.#assertOpen();
|
|
@@ -385,9 +545,11 @@ export class ClaustrumConsumer {
|
|
|
385
545
|
if (this.#timer)
|
|
386
546
|
(this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
|
|
387
547
|
this.#timer = undefined;
|
|
548
|
+
this.#stopWatch();
|
|
388
549
|
this.#custody?.close();
|
|
389
550
|
this.#insula?.release();
|
|
390
551
|
this.#insula = undefined;
|
|
391
552
|
this.#roster = undefined;
|
|
553
|
+
this.#grantReach = undefined;
|
|
392
554
|
}
|
|
393
555
|
}
|