@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
|
@@ -63,8 +63,12 @@ export function quotaWindowName(limit) {
|
|
|
63
63
|
function clampPercent(remaining) {
|
|
64
64
|
return Math.min(100, Math.max(0, remaining));
|
|
65
65
|
}
|
|
66
|
+
/** A remaining percentage as the whole number every form shows. */
|
|
67
|
+
function wholePercent(remaining) {
|
|
68
|
+
return Math.round(clampPercent(remaining));
|
|
69
|
+
}
|
|
66
70
|
function percentLeft(remaining) {
|
|
67
|
-
return `${
|
|
71
|
+
return `${wholePercent(remaining)}% left`;
|
|
68
72
|
}
|
|
69
73
|
function resetDetails(resetsAt, checkedAt, now) {
|
|
70
74
|
const at = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt);
|
|
@@ -98,6 +102,16 @@ function displayOrder(left, right) {
|
|
|
98
102
|
* and the optional age keep the shared formatter's wording.
|
|
99
103
|
*/
|
|
100
104
|
export function quotaWindowParts(quota, options) {
|
|
105
|
+
const { windows, age } = scopedWindowParts(quota, options);
|
|
106
|
+
const parts = windows.map(({ part }) => part);
|
|
107
|
+
return age === undefined ? { windows: parts } : { windows: parts, age };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* `quotaWindowParts` with each window's scope kept beside it. The narrow form
|
|
111
|
+
* needs the scope to name and pick model-scoped windows; the public parts do
|
|
112
|
+
* not carry it, so their shape stays as it was.
|
|
113
|
+
*/
|
|
114
|
+
function scopedWindowParts(quota, options) {
|
|
101
115
|
if (!quota)
|
|
102
116
|
return { windows: [] };
|
|
103
117
|
const { now } = options;
|
|
@@ -107,12 +121,18 @@ export function quotaWindowParts(quota, options) {
|
|
|
107
121
|
const windows = readings.map((limit) => {
|
|
108
122
|
const remainingPercent = clampPercent(limit.remainingPercent ?? 0);
|
|
109
123
|
return {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
124
|
+
scope: limit.scope,
|
|
125
|
+
...(limit.windowMinutes === undefined
|
|
126
|
+
? {}
|
|
127
|
+
: { windowMinutes: limit.windowMinutes }),
|
|
128
|
+
part: {
|
|
129
|
+
kind: 'window',
|
|
130
|
+
name: quotaWindowName(limit),
|
|
131
|
+
figure: percentLeft(limit.remainingPercent ?? 0),
|
|
132
|
+
remainingPercent,
|
|
133
|
+
checkedAt: limit.checkedAt,
|
|
134
|
+
...resetDetails(limit.resetsAt, limit.checkedAt, now),
|
|
135
|
+
},
|
|
116
136
|
};
|
|
117
137
|
});
|
|
118
138
|
const budget = quota.budget;
|
|
@@ -124,32 +144,196 @@ export function quotaWindowParts(quota, options) {
|
|
|
124
144
|
? `credits ${percentLeft(remainingPercent)}`
|
|
125
145
|
: 'credits available';
|
|
126
146
|
windows.push({
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
147
|
+
scope: ALL_SCOPE,
|
|
148
|
+
spent: budget.reached,
|
|
149
|
+
part: {
|
|
150
|
+
kind: 'budget',
|
|
151
|
+
name: 'credits',
|
|
152
|
+
figure,
|
|
153
|
+
...(remainingPercent === undefined
|
|
154
|
+
? {}
|
|
155
|
+
: { remainingPercent: clampPercent(remainingPercent) }),
|
|
156
|
+
checkedAt: budget.checkedAt,
|
|
157
|
+
...resetDetails(budget.resetsAt, budget.checkedAt, now),
|
|
158
|
+
},
|
|
135
159
|
});
|
|
136
160
|
}
|
|
137
161
|
if (windows.length > 0) {
|
|
138
|
-
const age = now - Math.min(...windows.map((part) => part.checkedAt));
|
|
162
|
+
const age = now - Math.min(...windows.map(({ part }) => part.checkedAt));
|
|
139
163
|
if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) {
|
|
140
164
|
return { windows, age: `checked ${shortSpan(age)} ago` };
|
|
141
165
|
}
|
|
142
166
|
}
|
|
143
167
|
return { windows };
|
|
144
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Whether the reading is stale: true exactly when the compact and full forms
|
|
171
|
+
* would end with `checked … ago`. The narrow form leaves that age out to stay
|
|
172
|
+
* short, so a caller drawing a narrow line uses this to dim it instead.
|
|
173
|
+
*/
|
|
174
|
+
export function isQuotaStale(quota, options) {
|
|
175
|
+
return scopedWindowParts(quota, options).age !== undefined;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The model a scope names, as a display name: any namespace before a colon
|
|
179
|
+
* is dropped (`model:claude-opus-4` → `claude-opus-4`) and the first letter is
|
|
180
|
+
* capitalized (`fable` → `Fable`); the rest stays as written.
|
|
181
|
+
*/
|
|
182
|
+
function scopeDisplayName(scope) {
|
|
183
|
+
const model = scope.slice(scope.indexOf(':') + 1);
|
|
184
|
+
return model.charAt(0).toUpperCase() + model.slice(1);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Short names for the model scopes in one row: the first two letters of each
|
|
188
|
+
* display name. Names that would read the same for different models are each
|
|
189
|
+
* extended one letter at a time until they differ (`Fable` and `Fabric` read
|
|
190
|
+
* `Fabl` and `Fabr`); a name that runs out of letters stays whole.
|
|
191
|
+
*/
|
|
192
|
+
function scopeAbbreviations(scopes) {
|
|
193
|
+
const names = [...new Set(scopes.map(scopeDisplayName))];
|
|
194
|
+
const lengths = new Map(names.map((name) => [name, 2]));
|
|
195
|
+
const short = (name) => name.slice(0, lengths.get(name));
|
|
196
|
+
for (;;) {
|
|
197
|
+
const byShort = new Map();
|
|
198
|
+
for (const name of names) {
|
|
199
|
+
byShort.set(short(name), [...(byShort.get(short(name)) ?? []), name]);
|
|
200
|
+
}
|
|
201
|
+
let extended = false;
|
|
202
|
+
for (const group of byShort.values()) {
|
|
203
|
+
if (group.length < 2)
|
|
204
|
+
continue;
|
|
205
|
+
for (const name of group) {
|
|
206
|
+
const length = lengths.get(name) ?? 2;
|
|
207
|
+
if (length < name.length) {
|
|
208
|
+
lengths.set(name, length + 1);
|
|
209
|
+
extended = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!extended)
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
return new Map(scopes.map((scope) => [scope, short(scopeDisplayName(scope))]));
|
|
217
|
+
}
|
|
218
|
+
/** The most groups a grouped narrow line shows. */
|
|
219
|
+
const NARROW_GROUP_LIMIT = 2;
|
|
220
|
+
/**
|
|
221
|
+
* The names the groups read under in the narrow line, one per listed group:
|
|
222
|
+
* each label's short name, derived exactly as a model window's is (`Gemini`
|
|
223
|
+
* → `Ge`, `Other models` → `Ot`, colliding names extended until they
|
|
224
|
+
* differ), computed over every listed group so a group keeps its name
|
|
225
|
+
* whichever groups show. A blank label falls back to the group's scope.
|
|
226
|
+
*/
|
|
227
|
+
function groupShortNames(groups) {
|
|
228
|
+
const labels = groups.map((group) => group.label.trim() || group.scope);
|
|
229
|
+
const short = scopeAbbreviations(labels);
|
|
230
|
+
return labels.map((label) => short.get(label) ?? label);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The narrow line of a reading whose windows belong to named groups and that
|
|
234
|
+
* has no account-wide window: for each group, its tightest window as
|
|
235
|
+
* `Ge 7d 62%`, with ` left` once at the end. The tightest window is the
|
|
236
|
+
* one with least left, compared before rounding; on a tie the first in
|
|
237
|
+
* display order (the shorter window) wins. At most two groups show: the two
|
|
238
|
+
* with least left, earlier-listed first on a tie, and always in the order
|
|
239
|
+
* the reading lists them, so the line does not reorder as the figures move.
|
|
240
|
+
* A group with no reported window is skipped. Windows outside the listed
|
|
241
|
+
* groups and the credit budget are left out. Undefined when the rule does
|
|
242
|
+
* not apply, so the caller uses the ordinary narrow rule.
|
|
243
|
+
*/
|
|
244
|
+
function groupedNarrowPieces(groups, windows) {
|
|
245
|
+
if (!groups || groups.length === 0)
|
|
246
|
+
return undefined;
|
|
247
|
+
const reported = windows.filter(({ part }) => part.kind === 'window');
|
|
248
|
+
if (reported.some(({ scope }) => scope === ALL_SCOPE))
|
|
249
|
+
return undefined;
|
|
250
|
+
const candidates = groups.flatMap((group, listed) => {
|
|
251
|
+
const inGroup = reported.filter(({ scope }) => scope === group.scope);
|
|
252
|
+
// `windows` is already in display order, and strictly lower wins, so on
|
|
253
|
+
// a tie the shorter window keeps the place.
|
|
254
|
+
const groupTightest = inGroup.reduce((least, window) => least === undefined ||
|
|
255
|
+
(window.part.remainingPercent ?? 0) < (least.part.remainingPercent ?? 0)
|
|
256
|
+
? window
|
|
257
|
+
: least, undefined);
|
|
258
|
+
return groupTightest ? [{ group, listed, window: groupTightest }] : [];
|
|
259
|
+
});
|
|
260
|
+
if (candidates.length === 0)
|
|
261
|
+
return undefined;
|
|
262
|
+
const names = groupShortNames(groups);
|
|
263
|
+
const left = (candidate) => candidate.window.part.remainingPercent ?? 0;
|
|
264
|
+
const shown = [...candidates]
|
|
265
|
+
.sort((a, b) => left(a) - left(b) || a.listed - b.listed)
|
|
266
|
+
.slice(0, NARROW_GROUP_LIMIT)
|
|
267
|
+
.sort((a, b) => a.listed - b.listed);
|
|
268
|
+
return shown.map((candidate, index) => {
|
|
269
|
+
const { windowMinutes } = candidate.window;
|
|
270
|
+
const piece = [
|
|
271
|
+
names[candidate.listed] ?? candidate.group.scope,
|
|
272
|
+
...(windowMinutes === undefined ? [] : [lengthName(windowMinutes)]),
|
|
273
|
+
`${wholePercent(left(candidate))}%`,
|
|
274
|
+
].join(' ');
|
|
275
|
+
return index === shown.length - 1 ? `${piece} left` : piece;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The narrow form's pieces: every account-wide window as `5h 92%`, then at
|
|
280
|
+
* most one more item, with ` left` added once to the last piece. That item is
|
|
281
|
+
* the tightest (least remaining) of the model-scoped windows and the credit
|
|
282
|
+
* budget, so the line stays bounded whatever the account carries: a scoped
|
|
283
|
+
* window reads under its short model name (`Fa 98%`), the budget as `cr 75%`
|
|
284
|
+
* (`cr 0%` when spent). On a tie a scoped window wins over the budget, and
|
|
285
|
+
* among scoped windows the first in display order wins. The choice depends
|
|
286
|
+
* only on the reading, never on a width. A budget without a percentage cannot
|
|
287
|
+
* be chosen; when nothing at all is shown it keeps its compact wording
|
|
288
|
+
* (`credits available`). No age is added; `isQuotaStale` says whether the
|
|
289
|
+
* reading is stale. A window whose reset arrived after the reading keeps its
|
|
290
|
+
* figure, as in the other forms. A reading that lists named groups and has no
|
|
291
|
+
* account-wide window reads one item per group instead (see
|
|
292
|
+
* `groupedNarrowPieces`).
|
|
293
|
+
*/
|
|
294
|
+
function narrowTextParts(quota, options) {
|
|
295
|
+
const { windows } = scopedWindowParts(quota, options);
|
|
296
|
+
const grouped = groupedNarrowPieces(quota?.groups, windows);
|
|
297
|
+
if (grouped)
|
|
298
|
+
return grouped;
|
|
299
|
+
const general = windows.filter(({ part, scope }) => part.kind === 'window' && scope === ALL_SCOPE);
|
|
300
|
+
const scoped = windows.filter(({ part, scope }) => part.kind === 'window' && scope !== ALL_SCOPE);
|
|
301
|
+
const abbreviations = scopeAbbreviations(scoped.map(({ scope }) => scope));
|
|
302
|
+
// The items competing for the one slot after the account-wide windows, in
|
|
303
|
+
// tie-break order: scoped windows in display order, then the budget. A
|
|
304
|
+
// spent budget counts as 0% left; one without a percentage cannot compete.
|
|
305
|
+
const budget = windows.find(({ part }) => part.kind === 'budget');
|
|
306
|
+
const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent;
|
|
307
|
+
const extras = [
|
|
308
|
+
...scoped.map(({ scope, part }) => ({
|
|
309
|
+
label: abbreviations.get(scope) ?? scope,
|
|
310
|
+
remaining: part.remainingPercent ?? 0,
|
|
311
|
+
})),
|
|
312
|
+
...(budgetLeft === undefined
|
|
313
|
+
? []
|
|
314
|
+
: [{ label: 'cr', remaining: budgetLeft }]),
|
|
315
|
+
];
|
|
316
|
+
// Strictly lower wins, so on a tie the earlier item keeps the slot.
|
|
317
|
+
const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined);
|
|
318
|
+
const pieces = general.map(({ part }) => `${part.name} ${wholePercent(part.remainingPercent ?? 0)}%`);
|
|
319
|
+
if (tightest) {
|
|
320
|
+
pieces.push(`${tightest.label} ${wholePercent(tightest.remaining)}%`);
|
|
321
|
+
}
|
|
322
|
+
if (pieces.length === 0)
|
|
323
|
+
return windows.map(({ part }) => part.figure);
|
|
324
|
+
return pieces.map((piece, index) => index === pieces.length - 1 ? `${piece} left` : piece);
|
|
325
|
+
}
|
|
145
326
|
/**
|
|
146
327
|
* The pieces of a quota text, in order: one per reported window
|
|
147
328
|
* (`5h 58% left, resets 2h`), then the credit budget when there is one, then
|
|
148
329
|
* `checked 3h ago` when the oldest piece shown is stale. Empty when nothing
|
|
149
330
|
* was reported; `formatQuota` turns that into a sentence. A caller printing
|
|
150
|
-
* one line per window (the terminal auth menu) uses these directly.
|
|
331
|
+
* one line per window (the terminal auth menu) uses these directly. In the
|
|
332
|
+
* narrow form the pieces are the narrow line's (`5h 92%`, `7d 66% left`).
|
|
151
333
|
*/
|
|
152
334
|
export function quotaTextParts(quota, options) {
|
|
335
|
+
if (options.form === 'narrow')
|
|
336
|
+
return narrowTextParts(quota, options);
|
|
153
337
|
const parts = quotaWindowParts(quota, options);
|
|
154
338
|
const full = (options.form ?? 'full') === 'full';
|
|
155
339
|
const text = parts.windows.map((part) => {
|
|
@@ -160,7 +344,8 @@ export function quotaTextParts(quota, options) {
|
|
|
160
344
|
}
|
|
161
345
|
/**
|
|
162
346
|
* A row's quota as one line: `5h 58% left · 7d 90% left` in the compact
|
|
163
|
-
* form, with `, resets 2h` after each window in the full form
|
|
347
|
+
* form, with `, resets 2h` after each window in the full form, and
|
|
348
|
+
* `5h 58% · 7d 90% left` in the narrow form. With nothing
|
|
164
349
|
* reported it is `NO_QUOTA_READING`, or `NO_LIMITS_REPORTED` when the
|
|
165
350
|
* provider answered without a limit for any window.
|
|
166
351
|
*/
|
|
@@ -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
|
}
|