@cortexkit/opencode-openai-auth 1.2.0 → 1.4.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-CjXETpTA.d.ts → codex-request-hw0XcldE.d.ts} +146 -29
- package/dist/index-1eq2s1cp.js +2 -0
- package/dist/{index-qp5hdpw8.js → index-1tdnrt68.js} +1 -1
- package/dist/index-5230x6s8.js +2 -0
- package/dist/{index-qxbchh2d.js → index-ag04nyv7.js} +7 -7
- package/dist/index-brbqqer5.js +90 -0
- package/dist/{index-xwvze96g.js → index-d98f1xdp.js} +1 -1
- package/dist/index-jcyrm58f.js +65 -0
- package/dist/{index-htvcmtcx.js → index-rsvnabbe.js} +2 -2
- package/dist/{index-ftkhhr6d.js → index-s88n2bjn.js} +1 -1
- package/dist/{index-bc89akak.js → index-t5m0dhmq.js} +1 -1
- package/dist/index-vy729a0b.js +69 -0
- package/dist/index-wgwze391.js +2 -0
- package/dist/{index-ffg0sgg5.js → index-yhw3hnsh.js} +2 -2
- package/dist/index.d.ts +11 -3
- package/dist/index.js +1 -1
- package/dist/{model-CQcw10oW.d.ts → model-DphbMQce.d.ts} +1 -1
- package/dist/{port-file-BDJnzgHH.d.ts → port-file-Cx-wwe5Q.d.ts} +1 -1
- package/dist/{protocol-C-kagE6O.d.ts → protocol-CDqdjh74.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-BfQ4mpZO.d.ts → sidebar-state-pTd9gg9W.d.ts} +71 -7
- package/dist/sidebar-state.d.ts +2 -2
- package/dist/sidebar-state.js +1 -1
- package/dist/{token-z6qq7a1d.js → token-0z5g59p8.js} +1 -1
- package/dist/token-util-jf9g6stk.js +1 -0
- package/dist/tui-preferences.js +1 -1
- package/dist/tui.d.ts +30 -3
- package/dist/v2/server.d.ts +2 -2
- package/dist/v2/server.js +2 -2
- package/package.json +6 -6
- package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +119 -6
- package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +294 -16
- package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +178 -26
- package/src/tui-compiled/raw/shared/2577ec69f9973ae9-quota-normalize.js +45 -1
- package/src/tui-compiled/raw/shared/30f5bc480f31f51c-roster.js +100 -43
- package/src/tui-compiled/raw/shared/375d9cca2def037d-private-files.js +10 -2
- package/src/tui-compiled/raw/shared/39c3cb05738fcf6d-projection.js +3 -1
- package/src/tui-compiled/raw/shared/42ae93ab554fcec2-open-url.js +7 -9
- package/src/tui-compiled/raw/shared/4825e03209d6ae3e-with-lock.js +2 -0
- package/src/tui-compiled/raw/shared/4f36d333d081d040-merge.js +17 -3
- package/src/tui-compiled/raw/shared/81dddd203f1b92a3-quota-writer.js +253 -0
- package/src/tui-compiled/raw/shared/905bffc0220f43d0-map.js +31 -6
- package/src/tui-compiled/raw/shared/94f53cfd572ba4bb-availability.js +106 -0
- package/src/tui-compiled/raw/shared/abd062d633e29fd2-pool-routing.js +116 -12
- package/src/tui-compiled/raw/shared/bfbdd0f9aae56b47-pool-quota.js +15 -3
- package/src/tui-compiled/raw/shared/d0f0b6be200311af-insula.js +348 -21
- package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +2 -2
- package/src/tui-compiled/raw/shared/dbc61cd405941828-accounts.js +5 -4
- package/src/tui-compiled/raw/shared/dc9290c2a7552ba6-watch.js +254 -0
- package/src/tui-compiled/raw/shared/e8d3c8da776ef9cd-index.js +5 -1
- package/src/tui-compiled/raw/sidebar-state.js +32 -3
- package/src/tui-compiled/raw/tui/v2-dialog.js +2 -1
- package/src/tui-compiled/raw/tui.tsx +170 -26
- package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +119 -6
- package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +294 -16
- package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +178 -26
- package/src/tui-compiled/runtime/shared/2577ec69f9973ae9-quota-normalize.js +45 -1
- package/src/tui-compiled/runtime/shared/30f5bc480f31f51c-roster.js +100 -43
- package/src/tui-compiled/runtime/shared/375d9cca2def037d-private-files.js +10 -2
- package/src/tui-compiled/runtime/shared/39c3cb05738fcf6d-projection.js +3 -1
- package/src/tui-compiled/runtime/shared/42ae93ab554fcec2-open-url.js +7 -9
- package/src/tui-compiled/runtime/shared/4825e03209d6ae3e-with-lock.js +2 -0
- package/src/tui-compiled/runtime/shared/4f36d333d081d040-merge.js +17 -3
- package/src/tui-compiled/runtime/shared/81dddd203f1b92a3-quota-writer.js +253 -0
- package/src/tui-compiled/runtime/shared/905bffc0220f43d0-map.js +31 -6
- package/src/tui-compiled/runtime/shared/94f53cfd572ba4bb-availability.js +106 -0
- package/src/tui-compiled/runtime/shared/abd062d633e29fd2-pool-routing.js +116 -12
- package/src/tui-compiled/runtime/shared/bfbdd0f9aae56b47-pool-quota.js +15 -3
- package/src/tui-compiled/runtime/shared/d0f0b6be200311af-insula.js +348 -21
- package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +2 -2
- package/src/tui-compiled/runtime/shared/dbc61cd405941828-accounts.js +5 -4
- package/src/tui-compiled/runtime/shared/dc9290c2a7552ba6-watch.js +254 -0
- package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +5 -1
- package/src/tui-compiled/runtime/sidebar-state.js +32 -3
- package/src/tui-compiled/runtime/tui/v2-dialog.js +2 -1
- package/src/tui-compiled/runtime/tui.js +350 -219
- package/dist/index-03em8hv1.js +0 -69
- package/dist/index-6n4pnfkg.js +0 -2
- package/dist/index-ehjj89rx.js +0 -2
- package/dist/index-f21addzm.js +0 -90
- package/dist/index-r2s51ac1.js +0 -2
- package/dist/index-rwbrhkse.js +0 -65
- package/dist/token-util-cf0nyh7n.js +0 -1
|
@@ -106,6 +106,25 @@ export function quotaWindowParts(quota, options) {
|
|
|
106
106
|
const parts = windows.map(({ part }) => part);
|
|
107
107
|
return age === undefined ? { windows: parts } : { windows: parts, age };
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The narrow label of each scoped window part, as `narrowTextParts` names
|
|
111
|
+
* them: model short names are worked out over every model-scoped window in
|
|
112
|
+
* the reading, so two models never share a short name.
|
|
113
|
+
*/
|
|
114
|
+
function withShortNames(windows) {
|
|
115
|
+
const abbreviations = scopeAbbreviations(windows
|
|
116
|
+
.filter(({ part, scope }) => part.kind === 'window' && scope !== ALL_SCOPE)
|
|
117
|
+
.map(({ scope }) => scope));
|
|
118
|
+
for (const window of windows) {
|
|
119
|
+
window.part.short =
|
|
120
|
+
window.part.kind === 'budget'
|
|
121
|
+
? 'cr'
|
|
122
|
+
: window.scope === ALL_SCOPE
|
|
123
|
+
? window.part.name
|
|
124
|
+
: (abbreviations.get(window.scope) ?? window.scope);
|
|
125
|
+
}
|
|
126
|
+
return windows;
|
|
127
|
+
}
|
|
109
128
|
/**
|
|
110
129
|
* `quotaWindowParts` with each window's scope kept beside it. The narrow form
|
|
111
130
|
* needs the scope to name and pick model-scoped windows; the public parts do
|
|
@@ -122,9 +141,13 @@ function scopedWindowParts(quota, options) {
|
|
|
122
141
|
const remainingPercent = clampPercent(limit.remainingPercent ?? 0);
|
|
123
142
|
return {
|
|
124
143
|
scope: limit.scope,
|
|
144
|
+
...(limit.windowMinutes === undefined
|
|
145
|
+
? {}
|
|
146
|
+
: { windowMinutes: limit.windowMinutes }),
|
|
125
147
|
part: {
|
|
126
148
|
kind: 'window',
|
|
127
149
|
name: quotaWindowName(limit),
|
|
150
|
+
short: '',
|
|
128
151
|
figure: percentLeft(limit.remainingPercent ?? 0),
|
|
129
152
|
remainingPercent,
|
|
130
153
|
checkedAt: limit.checkedAt,
|
|
@@ -146,6 +169,7 @@ function scopedWindowParts(quota, options) {
|
|
|
146
169
|
part: {
|
|
147
170
|
kind: 'budget',
|
|
148
171
|
name: 'credits',
|
|
172
|
+
short: '',
|
|
149
173
|
figure,
|
|
150
174
|
...(remainingPercent === undefined
|
|
151
175
|
? {}
|
|
@@ -155,6 +179,7 @@ function scopedWindowParts(quota, options) {
|
|
|
155
179
|
},
|
|
156
180
|
});
|
|
157
181
|
}
|
|
182
|
+
withShortNames(windows);
|
|
158
183
|
if (windows.length > 0) {
|
|
159
184
|
const age = now - Math.min(...windows.map(({ part }) => part.checkedAt));
|
|
160
185
|
if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) {
|
|
@@ -212,6 +237,66 @@ function scopeAbbreviations(scopes) {
|
|
|
212
237
|
}
|
|
213
238
|
return new Map(scopes.map((scope) => [scope, short(scopeDisplayName(scope))]));
|
|
214
239
|
}
|
|
240
|
+
/** The most groups a grouped narrow line shows. */
|
|
241
|
+
const NARROW_GROUP_LIMIT = 2;
|
|
242
|
+
/**
|
|
243
|
+
* The names the groups read under in the narrow line, one per listed group:
|
|
244
|
+
* each label's short name, derived exactly as a model window's is (`Gemini`
|
|
245
|
+
* → `Ge`, `Other models` → `Ot`, colliding names extended until they
|
|
246
|
+
* differ), computed over every listed group so a group keeps its name
|
|
247
|
+
* whichever groups show. A blank label falls back to the group's scope.
|
|
248
|
+
*/
|
|
249
|
+
function groupShortNames(groups) {
|
|
250
|
+
const labels = groups.map((group) => group.label.trim() || group.scope);
|
|
251
|
+
const short = scopeAbbreviations(labels);
|
|
252
|
+
return labels.map((label) => short.get(label) ?? label);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The narrow line of a reading whose windows belong to named groups and that
|
|
256
|
+
* has no account-wide window: for each group, its tightest window as
|
|
257
|
+
* `Ge 7d 62%`, with ` left` once at the end. The tightest window is the
|
|
258
|
+
* one with least left, compared before rounding; on a tie the first in
|
|
259
|
+
* display order (the shorter window) wins. At most two groups show: the two
|
|
260
|
+
* with least left, earlier-listed first on a tie, and always in the order
|
|
261
|
+
* the reading lists them, so the line does not reorder as the figures move.
|
|
262
|
+
* A group with no reported window is skipped. Windows outside the listed
|
|
263
|
+
* groups and the credit budget are left out. Undefined when the rule does
|
|
264
|
+
* not apply, so the caller uses the ordinary narrow rule.
|
|
265
|
+
*/
|
|
266
|
+
function groupedNarrowPieces(groups, windows) {
|
|
267
|
+
if (!groups || groups.length === 0)
|
|
268
|
+
return undefined;
|
|
269
|
+
const reported = windows.filter(({ part }) => part.kind === 'window');
|
|
270
|
+
if (reported.some(({ scope }) => scope === ALL_SCOPE))
|
|
271
|
+
return undefined;
|
|
272
|
+
const candidates = groups.flatMap((group, listed) => {
|
|
273
|
+
const inGroup = reported.filter(({ scope }) => scope === group.scope);
|
|
274
|
+
// `windows` is already in display order, and strictly lower wins, so on
|
|
275
|
+
// a tie the shorter window keeps the place.
|
|
276
|
+
const groupTightest = inGroup.reduce((least, window) => least === undefined ||
|
|
277
|
+
(window.part.remainingPercent ?? 0) < (least.part.remainingPercent ?? 0)
|
|
278
|
+
? window
|
|
279
|
+
: least, undefined);
|
|
280
|
+
return groupTightest ? [{ group, listed, window: groupTightest }] : [];
|
|
281
|
+
});
|
|
282
|
+
if (candidates.length === 0)
|
|
283
|
+
return undefined;
|
|
284
|
+
const names = groupShortNames(groups);
|
|
285
|
+
const left = (candidate) => candidate.window.part.remainingPercent ?? 0;
|
|
286
|
+
const shown = [...candidates]
|
|
287
|
+
.sort((a, b) => left(a) - left(b) || a.listed - b.listed)
|
|
288
|
+
.slice(0, NARROW_GROUP_LIMIT)
|
|
289
|
+
.sort((a, b) => a.listed - b.listed);
|
|
290
|
+
return shown.map((candidate, index) => {
|
|
291
|
+
const { windowMinutes } = candidate.window;
|
|
292
|
+
const piece = [
|
|
293
|
+
names[candidate.listed] ?? candidate.group.scope,
|
|
294
|
+
...(windowMinutes === undefined ? [] : [lengthName(windowMinutes)]),
|
|
295
|
+
`${wholePercent(left(candidate))}%`,
|
|
296
|
+
].join(' ');
|
|
297
|
+
return index === shown.length - 1 ? `${piece} left` : piece;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
215
300
|
/**
|
|
216
301
|
* The narrow form's pieces: every account-wide window as `5h 92%`, then at
|
|
217
302
|
* most one more item, with ` left` added once to the last piece. That item is
|
|
@@ -224,26 +309,32 @@ function scopeAbbreviations(scopes) {
|
|
|
224
309
|
* be chosen; when nothing at all is shown it keeps its compact wording
|
|
225
310
|
* (`credits available`). No age is added; `isQuotaStale` says whether the
|
|
226
311
|
* reading is stale. A window whose reset arrived after the reading keeps its
|
|
227
|
-
* figure, as in the other forms.
|
|
312
|
+
* figure, as in the other forms. A reading that lists named groups and has no
|
|
313
|
+
* account-wide window reads one item per group instead (see
|
|
314
|
+
* `groupedNarrowPieces`).
|
|
228
315
|
*/
|
|
229
316
|
function narrowTextParts(quota, options) {
|
|
230
317
|
const { windows } = scopedWindowParts(quota, options);
|
|
318
|
+
const grouped = groupedNarrowPieces(quota?.groups, windows);
|
|
319
|
+
if (grouped)
|
|
320
|
+
return grouped;
|
|
231
321
|
const general = windows.filter(({ part, scope }) => part.kind === 'window' && scope === ALL_SCOPE);
|
|
232
322
|
const scoped = windows.filter(({ part, scope }) => part.kind === 'window' && scope !== ALL_SCOPE);
|
|
233
|
-
const abbreviations = scopeAbbreviations(scoped.map(({ scope }) => scope));
|
|
234
323
|
// The items competing for the one slot after the account-wide windows, in
|
|
235
324
|
// tie-break order: scoped windows in display order, then the budget. A
|
|
236
325
|
// spent budget counts as 0% left; one without a percentage cannot compete.
|
|
326
|
+
// Their labels are the parts' short names, so the narrow line and a layout
|
|
327
|
+
// reading `short` always agree.
|
|
237
328
|
const budget = windows.find(({ part }) => part.kind === 'budget');
|
|
238
329
|
const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent;
|
|
239
330
|
const extras = [
|
|
240
|
-
...scoped.map(({
|
|
241
|
-
label:
|
|
331
|
+
...scoped.map(({ part }) => ({
|
|
332
|
+
label: part.short,
|
|
242
333
|
remaining: part.remainingPercent ?? 0,
|
|
243
334
|
})),
|
|
244
|
-
...(budgetLeft === undefined
|
|
335
|
+
...(budgetLeft === undefined || budget === undefined
|
|
245
336
|
? []
|
|
246
|
-
: [{ label:
|
|
337
|
+
: [{ label: budget.part.short, remaining: budgetLeft }]),
|
|
247
338
|
];
|
|
248
339
|
// Strictly lower wins, so on a tie the earlier item keeps the slot.
|
|
249
340
|
const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined);
|
|
@@ -289,3 +380,25 @@ export function formatQuota(quota, options) {
|
|
|
289
380
|
? NO_LIMITS_REPORTED
|
|
290
381
|
: NO_QUOTA_READING;
|
|
291
382
|
}
|
|
383
|
+
/**
|
|
384
|
+
* Prepaid credits as one short value, worded as the official Codex client's
|
|
385
|
+
* status view words them: `Unlimited` for unlimited credits, `<n> credits`
|
|
386
|
+
* for a finite positive balance (rounded to a whole number), `Available` when
|
|
387
|
+
* the account has credits but no usable balance was reported, and undefined
|
|
388
|
+
* when the account has no credits (or `credits` is undefined), so a caller
|
|
389
|
+
* shows no credits row at all. Unlimited wins even when `hasCredits` is false.
|
|
390
|
+
* No other formatter adds this text; a caller that wants a credits row asks
|
|
391
|
+
* for it.
|
|
392
|
+
*/
|
|
393
|
+
export function quotaCreditsText(credits) {
|
|
394
|
+
if (credits === undefined)
|
|
395
|
+
return undefined;
|
|
396
|
+
if (credits.unlimited)
|
|
397
|
+
return 'Unlimited';
|
|
398
|
+
if (!credits.hasCredits)
|
|
399
|
+
return undefined;
|
|
400
|
+
const { balance } = credits;
|
|
401
|
+
return typeof balance === 'number' && Number.isFinite(balance) && balance > 0
|
|
402
|
+
? `${Math.round(balance)} credits`
|
|
403
|
+
: 'Available';
|
|
404
|
+
}
|
|
@@ -1,11 +1,15 @@
|
|
|
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";
|
|
7
|
+
import { ScopedRosterWatch, } from "./dc9290c2a7552ba6-watch.js";
|
|
6
8
|
/** Only a new version of the same account may replace an in-flight 401. */
|
|
7
9
|
export function isScopedCredentialRotation(served, current) {
|
|
8
10
|
return (current !== undefined &&
|
|
11
|
+
!current.fromKeptReceipt &&
|
|
12
|
+
!served.fromKeptReceipt &&
|
|
9
13
|
current.credentialId === served.credentialId &&
|
|
10
14
|
current.accountIdentity === served.accountIdentity &&
|
|
11
15
|
current.recordVersion !== served.recordVersion);
|
|
@@ -45,6 +49,8 @@ export function decideScopedRetryAfter401(site, served, current, logger = defaul
|
|
|
45
49
|
* so it cannot expire while a long request is still using it.
|
|
46
50
|
*/
|
|
47
51
|
export const SERVING_MARGIN_MS = 300_000;
|
|
52
|
+
/** A kept receipt needs a minute of headroom, not the vault's refresh demand. */
|
|
53
|
+
export const KEPT_RECEIPT_MARGIN_MS = 60_000;
|
|
48
54
|
function invalidMaterial() {
|
|
49
55
|
return new ClaustrumConsumerError('invalid-material', 'Claustrum returned invalid credential material');
|
|
50
56
|
}
|
|
@@ -100,11 +106,11 @@ function grantReachOf(listed) {
|
|
|
100
106
|
}
|
|
101
107
|
/**
|
|
102
108
|
* 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
|
-
*
|
|
109
|
+
* enrollment token as authorization. Used by every host of a plugin. Every
|
|
110
|
+
* physical send asks the vault first while reachable. Only an unreachable vault permits
|
|
111
|
+
* reuse of the last validated receipt, in memory and bounded by its expiry.
|
|
112
|
+
* The enrollment token is re-read per operation so revocation and disconnect
|
|
113
|
+
* still take effect without a process restart.
|
|
108
114
|
*/
|
|
109
115
|
export class ClaustrumScopedCustody {
|
|
110
116
|
#client;
|
|
@@ -116,7 +122,20 @@ export class ClaustrumScopedCustody {
|
|
|
116
122
|
#logger;
|
|
117
123
|
#provenance = new WeakMap();
|
|
118
124
|
#reports = new WeakMap();
|
|
125
|
+
#kept = new Map();
|
|
126
|
+
#keptAttempts = new WeakMap();
|
|
127
|
+
#setTimeout;
|
|
128
|
+
#clearTimeout;
|
|
129
|
+
#outage = false;
|
|
130
|
+
#outageError = new ClaustrumUnreachableError();
|
|
131
|
+
#probeTimer;
|
|
132
|
+
#probing = false;
|
|
133
|
+
#lastProbeAt = -Infinity;
|
|
134
|
+
#probeDelay = 2_000;
|
|
119
135
|
#closed = false;
|
|
136
|
+
/** The view of the last successful `discover()`, for `watchRoster`. */
|
|
137
|
+
#discoveredView;
|
|
138
|
+
#watches = new Set();
|
|
120
139
|
constructor(options) {
|
|
121
140
|
const tokenPath = options.tokenPath;
|
|
122
141
|
if (options.readToken) {
|
|
@@ -134,6 +153,65 @@ export class ClaustrumScopedCustody {
|
|
|
134
153
|
this.#requireAssertion = options.requireAssertion ?? false;
|
|
135
154
|
this.#now = options.now ?? Date.now;
|
|
136
155
|
this.#logger = options.logger ?? defaultLogger;
|
|
156
|
+
this.#setTimeout = options.setTimeoutImpl ?? setTimeout;
|
|
157
|
+
this.#clearTimeout = options.clearTimeoutImpl ?? clearTimeout;
|
|
158
|
+
}
|
|
159
|
+
/** A watch losing its transport is the same outage as an unreachable read. */
|
|
160
|
+
transportLost(error) {
|
|
161
|
+
if (this.#closed || this.#outage)
|
|
162
|
+
return;
|
|
163
|
+
this.#outage = true;
|
|
164
|
+
this.#outageError =
|
|
165
|
+
error instanceof ClaustrumCredentialError
|
|
166
|
+
? error
|
|
167
|
+
: new ClaustrumUnreachableError();
|
|
168
|
+
this.#logger.warn('serving kept vault receipt', { reason: 'unreachable' });
|
|
169
|
+
this.#scheduleProbe();
|
|
170
|
+
}
|
|
171
|
+
/** Any vault answer ends transport fallback, even when it refuses access. */
|
|
172
|
+
reachable() {
|
|
173
|
+
if (this.#closed || !this.#outage)
|
|
174
|
+
return;
|
|
175
|
+
this.#outage = false;
|
|
176
|
+
if (this.#probeTimer)
|
|
177
|
+
this.#clearTimeout(this.#probeTimer);
|
|
178
|
+
this.#probeTimer = undefined;
|
|
179
|
+
this.#probeDelay = 2_000;
|
|
180
|
+
for (const kept of this.#kept.values())
|
|
181
|
+
kept.since = null;
|
|
182
|
+
(this.#logger.info ?? defaultLogger.info)('vault receipt serving resumed', { reason: 'reachable' });
|
|
183
|
+
}
|
|
184
|
+
#scheduleProbe() {
|
|
185
|
+
if (this.#closed || !this.#outage || this.#probeTimer || this.#probing)
|
|
186
|
+
return;
|
|
187
|
+
this.#probeTimer = this.#setTimeout(() => {
|
|
188
|
+
this.#probeTimer = undefined;
|
|
189
|
+
this.#probe(true);
|
|
190
|
+
}, this.#probeDelay);
|
|
191
|
+
this.#probeTimer.unref?.();
|
|
192
|
+
}
|
|
193
|
+
#probe(idle = false) {
|
|
194
|
+
if (this.#closed || !this.#outage || this.#probing)
|
|
195
|
+
return;
|
|
196
|
+
if (this.#now() - this.#lastProbeAt < 1_000) {
|
|
197
|
+
this.#scheduleProbe();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
this.#lastProbeAt = this.#now();
|
|
201
|
+
this.#probing = true;
|
|
202
|
+
// Both triggers share list_scoped discovery, including its receipt-drop rules.
|
|
203
|
+
// Sends never await discovery or let idle backoff delay the next send probe.
|
|
204
|
+
// A probe commits no roster, so it is not the host's discovery and does
|
|
205
|
+
// not move the view a roster watch compares notices against.
|
|
206
|
+
void this.#list()
|
|
207
|
+
.catch(() => {
|
|
208
|
+
if (idle && this.#outage)
|
|
209
|
+
this.#probeDelay = Math.min(this.#probeDelay * 2, 30_000);
|
|
210
|
+
})
|
|
211
|
+
.finally(() => {
|
|
212
|
+
this.#probing = false;
|
|
213
|
+
this.#scheduleProbe();
|
|
214
|
+
});
|
|
137
215
|
}
|
|
138
216
|
#check(signal) {
|
|
139
217
|
if (this.#closed)
|
|
@@ -172,17 +250,24 @@ export class ClaustrumScopedCustody {
|
|
|
172
250
|
}
|
|
173
251
|
catch (error) {
|
|
174
252
|
this.#check(signal);
|
|
253
|
+
if (classifyVaultFailure(error) === 'unreachable')
|
|
254
|
+
this.transportLost(error);
|
|
255
|
+
else
|
|
256
|
+
this.reachable();
|
|
175
257
|
// Keep the vault's own refusals (ClaustrumCredentialError, carrying
|
|
176
258
|
// code, class and action); replace any other error, whose text may echo
|
|
177
259
|
// request params that include the enrollment token.
|
|
178
260
|
if (error instanceof ClaustrumCredentialError)
|
|
179
261
|
throw error;
|
|
262
|
+
if (classifyVaultFailure(error) === 'unreachable')
|
|
263
|
+
throw new ClaustrumUnreachableError();
|
|
180
264
|
throw new ClaustrumConsumerError('unavailable', 'Claustrum scoped operation unavailable');
|
|
181
265
|
}
|
|
182
266
|
finally {
|
|
183
267
|
removeAbortListener?.();
|
|
184
268
|
}
|
|
185
269
|
this.#check(signal);
|
|
270
|
+
this.reachable();
|
|
186
271
|
return result;
|
|
187
272
|
}
|
|
188
273
|
/**
|
|
@@ -192,8 +277,27 @@ export class ClaustrumScopedCustody {
|
|
|
192
277
|
* skipped records: they simply are not this consumer's.
|
|
193
278
|
*/
|
|
194
279
|
async discover(signal) {
|
|
195
|
-
const
|
|
196
|
-
|
|
280
|
+
const inventory = await this.#list(signal);
|
|
281
|
+
this.#discoveredView = inventory.view;
|
|
282
|
+
return inventory;
|
|
283
|
+
}
|
|
284
|
+
async #list(signal) {
|
|
285
|
+
let inventory;
|
|
286
|
+
let token;
|
|
287
|
+
try {
|
|
288
|
+
token = await this.#token(signal);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
this.#kept.clear();
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
inventory = await this.#call(() => this.#client.listScoped(token), signal);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
this.listFailed(error);
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
197
301
|
const skipped = [];
|
|
198
302
|
const candidates = [];
|
|
199
303
|
const counts = new Map();
|
|
@@ -241,22 +345,119 @@ export class ClaustrumScopedCustody {
|
|
|
241
345
|
};
|
|
242
346
|
}
|
|
243
347
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* refuse; the receipt records what the vault asserted separately from what
|
|
249
|
-
* the roster expected. With it, a reply that does not itself name the
|
|
250
|
-
* credential id and the expected account is refused.
|
|
348
|
+
* Apply a failed account listing: any answer from the vault (a refusal such
|
|
349
|
+
* as a revoked enrollment's `not_found`) drops every kept receipt, while an
|
|
350
|
+
* unreachable vault keeps them. A vault watch that ends with the same
|
|
351
|
+
* refusal calls this too, so both paths treat it alike.
|
|
251
352
|
*/
|
|
252
|
-
|
|
353
|
+
listFailed(error) {
|
|
354
|
+
if (classifyVaultFailure(error) === 'unreachable')
|
|
355
|
+
this.transportLost(error);
|
|
356
|
+
else {
|
|
357
|
+
this.reachable();
|
|
358
|
+
this.#kept.clear();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** Discard bearer material when a route is removed, declined or rebound. */
|
|
362
|
+
forget(credentialId) {
|
|
363
|
+
this.#kept.delete(credentialId);
|
|
364
|
+
}
|
|
365
|
+
/** Apply an already-held roster; this never fetches credentials or writes it. */
|
|
366
|
+
retain(identities) {
|
|
367
|
+
const current = new Map(identities.map((identity) => [identity.credentialId, identity]));
|
|
368
|
+
for (const [id, kept] of this.#kept) {
|
|
369
|
+
const identity = current.get(id);
|
|
370
|
+
if (!identity?.enabled ||
|
|
371
|
+
identity.state !== 'active' ||
|
|
372
|
+
identity.credentialType !== kept.attempt.credentialType ||
|
|
373
|
+
identity.accountIdentity !== kept.attempt.expectedAccountIdentity)
|
|
374
|
+
this.forget(id);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** A frozen, non-secret summary for a plugin's status display. */
|
|
378
|
+
keptReceiptState() {
|
|
379
|
+
let since = null;
|
|
380
|
+
for (const [id, kept] of this.#kept) {
|
|
381
|
+
if (!this.#usable(kept.attempt)) {
|
|
382
|
+
this.forget(id);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (kept.since !== null)
|
|
386
|
+
since = Math.min(since ?? kept.since, kept.since);
|
|
387
|
+
}
|
|
388
|
+
return Object.freeze({ active: since !== null, since });
|
|
389
|
+
}
|
|
390
|
+
#usable(attempt) {
|
|
391
|
+
return attempt.expiresAtMs === null
|
|
392
|
+
? attempt.credentialType === 'api_key'
|
|
393
|
+
: attempt.expiresAtMs - this.#now() > KEPT_RECEIPT_MARGIN_MS;
|
|
394
|
+
}
|
|
395
|
+
/** Ask the vault while reachable; retain one validated receipt in memory only. */
|
|
396
|
+
async authorize(identity, signal, routeId = 'vault') {
|
|
397
|
+
this.#check(signal);
|
|
398
|
+
const { credentialId, credentialType, accountIdentity } = identity;
|
|
399
|
+
let kept = this.#kept.get(credentialId);
|
|
400
|
+
if (kept &&
|
|
401
|
+
(kept.attempt.credentialType !== credentialType ||
|
|
402
|
+
kept.attempt.expectedAccountIdentity !== accountIdentity)) {
|
|
403
|
+
this.forget(credentialId);
|
|
404
|
+
kept = undefined;
|
|
405
|
+
}
|
|
406
|
+
// A missing or invalid enrollment is not a transport outage.
|
|
407
|
+
let token;
|
|
408
|
+
try {
|
|
409
|
+
token = await this.#token(signal);
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
this.#kept.clear();
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
if (this.#outage && kept) {
|
|
416
|
+
this.#probe();
|
|
417
|
+
return this.#serveKept(credentialId, kept, token, routeId);
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
const attempt = await this.#authorizeFresh({ credentialId, credentialType, accountIdentity }, token, signal);
|
|
421
|
+
this.#kept.set(credentialId, { attempt, routeId, since: null });
|
|
422
|
+
return attempt;
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
this.#check(signal);
|
|
426
|
+
if (classifyVaultFailure(error) !== 'unreachable') {
|
|
427
|
+
this.forget(credentialId);
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
return this.#serveKept(credentialId, kept, token, routeId, error);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
#serveKept(credentialId, kept, token, routeId, error = this.#outageError) {
|
|
434
|
+
// A concurrent refusal or 401 may already have discarded this entry.
|
|
435
|
+
if (!kept ||
|
|
436
|
+
this.#kept.get(credentialId) !== kept ||
|
|
437
|
+
!this.#usable(kept.attempt)) {
|
|
438
|
+
this.forget(credentialId);
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
if (kept.since === null)
|
|
442
|
+
kept.since = this.#now();
|
|
443
|
+
const attempt = Object.freeze(Object.defineProperty({ ...kept.attempt, fromKeptReceipt: true }, 'accessToken', { value: kept.attempt.accessToken, enumerable: false }));
|
|
444
|
+
this.#provenance.set(attempt, token);
|
|
445
|
+
this.#keptAttempts.set(attempt, routeId);
|
|
446
|
+
return attempt;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Fetch and validate a fresh receipt before retaining it. The vault assertion
|
|
450
|
+
* (or the plugin's token parse) must agree with the roster when both exist.
|
|
451
|
+
* With requireAssertion, an absent assertion also refuses rather than
|
|
452
|
+
* treating the roster's expectation as proof of the served account.
|
|
453
|
+
*/
|
|
454
|
+
async #authorizeFresh(identity, token, signal) {
|
|
253
455
|
if (!identity.credentialId)
|
|
254
456
|
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum dispatch requires a credential id');
|
|
255
457
|
// Capture the caller's fields before yielding so a later mutation cannot move the fence.
|
|
256
458
|
const { credentialId, credentialType, accountIdentity } = identity;
|
|
257
459
|
if (this.#requireAssertion && accountIdentity === undefined)
|
|
258
460
|
throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum dispatch requires a known account identity');
|
|
259
|
-
const token = await this.#token(signal);
|
|
260
461
|
const served = await this.#call(() => this.#client.getScoped({
|
|
261
462
|
credentialId,
|
|
262
463
|
enrollmentToken: token,
|
|
@@ -338,6 +539,21 @@ export class ClaustrumScopedCustody {
|
|
|
338
539
|
const token = this.#provenance.get(attempt);
|
|
339
540
|
if (!token)
|
|
340
541
|
throw new ClaustrumConsumerError('no-receipt', 'Claustrum failure report requires an original dispatch receipt');
|
|
542
|
+
const keptRoute = this.#keptAttempts.get(attempt);
|
|
543
|
+
if (keptRoute !== undefined) {
|
|
544
|
+
this.forget(attempt.credentialId);
|
|
545
|
+
this.#logger.debug('scoped 401 not reported', {
|
|
546
|
+
routeId: keptRoute,
|
|
547
|
+
reason: 'kept-receipt',
|
|
548
|
+
});
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
// The provider rejected this token, so it must not come back as a kept
|
|
552
|
+
// receipt during a later outage. Drop it before reporting, so a report that
|
|
553
|
+
// fails because the vault is unreachable still drops it.
|
|
554
|
+
const kept = this.#kept.get(attempt.credentialId);
|
|
555
|
+
if (kept && kept.attempt.recordVersion === attempt.recordVersion)
|
|
556
|
+
this.forget(attempt.credentialId);
|
|
341
557
|
const pending = this.#reports.get(attempt);
|
|
342
558
|
if (pending)
|
|
343
559
|
return pending;
|
|
@@ -362,10 +578,72 @@ export class ClaustrumScopedCustody {
|
|
|
362
578
|
throw error;
|
|
363
579
|
}
|
|
364
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* Watch this consumer's visible vault accounts instead of listing them on a
|
|
583
|
+
* short timer. Opens one `watchScoped` on this custody's client and calls
|
|
584
|
+
* `onView` when the host must run its discovery (see
|
|
585
|
+
* `ClaustrumRosterWatchOptions`). A claustrum-client without `watchScoped`
|
|
586
|
+
* (before 0.12.0) or a vault that answers `unknown_method` reports
|
|
587
|
+
* `fallback` and opens nothing. A watch that closes before any notice was
|
|
588
|
+
* never accepted: the client reads once to learn why, and the watch reports
|
|
589
|
+
* `fallback` and tries one new watch after `WATCH_RETRY_MS`, never in a
|
|
590
|
+
* loop. A watch that closes after notices is re-opened after
|
|
591
|
+
* `RESUBSCRIBE_BACKOFF_MS`, doubling per close in a row. A lost transport
|
|
592
|
+
* starts this custody's outage, exactly as an unreachable call does, and
|
|
593
|
+
* the first notice of a watch ends it. Timers are unreferenced; `close()`
|
|
594
|
+
* on the returned watch or on this custody stops it.
|
|
595
|
+
*
|
|
596
|
+
* The watch keeps no history, so the host still lists on a slow timer while
|
|
597
|
+
* the mode is `watching`: every `SAFETY_POLL_MS` (five minutes). In any
|
|
598
|
+
* other mode the host keeps its usual list timer.
|
|
599
|
+
*/
|
|
600
|
+
watchRoster(options) {
|
|
601
|
+
this.#check();
|
|
602
|
+
const watch = new ScopedRosterWatch({
|
|
603
|
+
client: this.#client,
|
|
604
|
+
enrollmentToken: () => this.#token(),
|
|
605
|
+
view: options.view ?? (() => this.#discoveredView),
|
|
606
|
+
discover: async (view) => {
|
|
607
|
+
await options.onView(view);
|
|
608
|
+
},
|
|
609
|
+
onRefused: (error) => {
|
|
610
|
+
// Apply a refused watch as discover() applies a refused account
|
|
611
|
+
// listing: every kept receipt is dropped.
|
|
612
|
+
this.listFailed(error);
|
|
613
|
+
options.onRefused?.(error);
|
|
614
|
+
},
|
|
615
|
+
onTransportLost: () => this.transportLost(),
|
|
616
|
+
onReachable: () => this.reachable(),
|
|
617
|
+
onError: options.onError,
|
|
618
|
+
onMode: options.onMode,
|
|
619
|
+
logger: this.#logger,
|
|
620
|
+
now: this.#now,
|
|
621
|
+
setTimeoutImpl: this.#setTimeout,
|
|
622
|
+
clearTimeoutImpl: this.#clearTimeout,
|
|
623
|
+
});
|
|
624
|
+
this.#watches.add(watch);
|
|
625
|
+
watch.start();
|
|
626
|
+
return {
|
|
627
|
+
get mode() {
|
|
628
|
+
return watch.mode;
|
|
629
|
+
},
|
|
630
|
+
close: () => {
|
|
631
|
+
this.#watches.delete(watch);
|
|
632
|
+
watch.stop();
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
}
|
|
365
636
|
close() {
|
|
366
637
|
if (this.#closed)
|
|
367
638
|
return;
|
|
368
639
|
this.#closed = true;
|
|
640
|
+
if (this.#probeTimer)
|
|
641
|
+
this.#clearTimeout(this.#probeTimer);
|
|
642
|
+
this.#probeTimer = undefined;
|
|
643
|
+
for (const watch of this.#watches)
|
|
644
|
+
watch.stop();
|
|
645
|
+
this.#watches.clear();
|
|
646
|
+
this.#kept.clear();
|
|
369
647
|
this.#client.close();
|
|
370
648
|
}
|
|
371
649
|
}
|