@cortexkit/opencode-openai-auth 1.0.5 → 1.2.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 +9 -8
- package/dist/{pool-lifecycle-C_6YRmsh.d.ts → codex-request-CjXETpTA.d.ts} +190 -27
- package/dist/index-03em8hv1.js +69 -0
- package/dist/index-6n4pnfkg.js +2 -0
- package/dist/{index-pg305s90.js → index-bc89akak.js} +1 -1
- package/dist/{index-y4gxyxqd.js → index-ehjj89rx.js} +2 -2
- package/dist/index-f21addzm.js +90 -0
- package/dist/{index-fsky1rsh.js → index-ffg0sgg5.js} +2 -2
- package/dist/{index-nx2qh047.js → index-ftkhhr6d.js} +1 -1
- package/dist/{index-826s1yxa.js → index-htvcmtcx.js} +2 -2
- package/dist/{index-fsxbncpw.js → index-qp5hdpw8.js} +1 -1
- package/dist/index-qxbchh2d.js +8 -0
- package/dist/{index-116q849r.js → index-r2s51ac1.js} +2 -2
- package/dist/index-rwbrhkse.js +65 -0
- package/dist/{index-66asrje2.js → index-xwvze96g.js} +1 -1
- package/dist/index.d.ts +18 -8
- package/dist/index.js +1 -1
- package/dist/{protocol-DctqemRI.d.ts → model-CQcw10oW.d.ts} +67 -25
- package/dist/{port-file-ZPlxXB4G.d.ts → port-file-BDJnzgHH.d.ts} +1 -1
- package/dist/protocol-C-kagE6O.d.ts +29 -0
- 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-z_M7Trnd.d.ts → sidebar-state-BfQ4mpZO.d.ts} +109 -10
- 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-bzhkgc29.js → token-z6qq7a1d.js} +1 -1
- package/dist/tui-preferences.js +1 -1
- package/dist/tui.d.ts +20 -2
- package/dist/v2/server.d.ts +180 -1
- package/dist/v2/server.js +3 -3
- package/package.json +3 -3
- package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +148 -26
- package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +371 -0
- package/src/tui-compiled/raw/shared/134b008900698fe0-backoff.js +92 -0
- package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +430 -0
- package/src/tui-compiled/raw/shared/2577ec69f9973ae9-quota-normalize.js +154 -0
- package/src/tui-compiled/raw/shared/2dce6e9613993329-quota-manager.js +473 -0
- package/src/tui-compiled/raw/shared/30f5bc480f31f51c-roster.js +566 -0
- package/src/tui-compiled/raw/shared/43fc9e6b994519c7-refresh-lock.js +0 -1
- package/src/tui-compiled/raw/shared/4593a4d71edf69fd-tombstone.js +25 -0
- package/src/tui-compiled/raw/shared/4d05c2d7a531aece-oauth.js +500 -0
- package/src/tui-compiled/raw/shared/6371b24a0573d79e-any-signal.js +42 -0
- package/src/tui-compiled/raw/shared/7c140680fc81be92-interlock.js +29 -0
- package/src/tui-compiled/raw/shared/85512cc5263ba259-provider.js +87 -0
- package/src/tui-compiled/raw/shared/abd062d633e29fd2-pool-routing.js +192 -0
- package/src/tui-compiled/raw/shared/b9588ad4725c1d10-enrollment.js +806 -0
- package/src/tui-compiled/raw/shared/bfbdd0f9aae56b47-pool-quota.js +85 -0
- package/src/tui-compiled/raw/shared/c852a15cfe489d12-pool.js +0 -1
- package/src/tui-compiled/raw/shared/ca0fccaba1d87af4-errors.js +8 -0
- package/src/tui-compiled/raw/shared/d0f0b6be200311af-insula.js +152 -0
- package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +1 -1
- package/src/tui-compiled/raw/shared/d9d2892a4c371301-host-slot.js +72 -0
- package/src/tui-compiled/raw/shared/dbc61cd405941828-accounts.js +1470 -0
- package/src/tui-compiled/raw/shared/e8cd4929507508d8-refresh-file-lock.js +26 -12
- package/src/tui-compiled/raw/shared/e8d3c8da776ef9cd-index.js +24 -0
- package/src/tui-compiled/raw/sidebar-state.js +103 -14
- package/src/tui-compiled/raw/tui/command-dialogs.tsx +3 -34
- package/src/tui-compiled/raw/tui/section-text.js +21 -0
- package/src/tui-compiled/raw/tui/v2-dialog.js +137 -0
- package/src/tui-compiled/raw/tui/v2.tsx +133 -0
- package/src/tui-compiled/raw/tui.tsx +228 -70
- package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +148 -26
- package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +371 -0
- package/src/tui-compiled/runtime/shared/134b008900698fe0-backoff.js +92 -0
- package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +430 -0
- package/src/tui-compiled/runtime/shared/2577ec69f9973ae9-quota-normalize.js +154 -0
- package/src/tui-compiled/runtime/shared/2dce6e9613993329-quota-manager.js +473 -0
- package/src/tui-compiled/runtime/shared/30f5bc480f31f51c-roster.js +566 -0
- package/src/tui-compiled/runtime/shared/43fc9e6b994519c7-refresh-lock.js +0 -1
- package/src/tui-compiled/runtime/shared/4593a4d71edf69fd-tombstone.js +25 -0
- package/src/tui-compiled/runtime/shared/4d05c2d7a531aece-oauth.js +500 -0
- package/src/tui-compiled/runtime/shared/6371b24a0573d79e-any-signal.js +42 -0
- package/src/tui-compiled/runtime/shared/7c140680fc81be92-interlock.js +29 -0
- package/src/tui-compiled/runtime/shared/85512cc5263ba259-provider.js +87 -0
- package/src/tui-compiled/runtime/shared/abd062d633e29fd2-pool-routing.js +192 -0
- package/src/tui-compiled/runtime/shared/b9588ad4725c1d10-enrollment.js +806 -0
- package/src/tui-compiled/runtime/shared/bfbdd0f9aae56b47-pool-quota.js +85 -0
- package/src/tui-compiled/runtime/shared/c852a15cfe489d12-pool.js +0 -1
- package/src/tui-compiled/runtime/shared/ca0fccaba1d87af4-errors.js +8 -0
- package/src/tui-compiled/runtime/shared/d0f0b6be200311af-insula.js +152 -0
- package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +1 -1
- package/src/tui-compiled/runtime/shared/d9d2892a4c371301-host-slot.js +72 -0
- package/src/tui-compiled/runtime/shared/dbc61cd405941828-accounts.js +1470 -0
- package/src/tui-compiled/runtime/shared/e8cd4929507508d8-refresh-file-lock.js +26 -12
- package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +24 -0
- package/src/tui-compiled/runtime/sidebar-state.js +103 -14
- package/src/tui-compiled/runtime/tui/command-dialogs.js +3 -15
- package/src/tui-compiled/runtime/tui/section-text.js +21 -0
- package/src/tui-compiled/runtime/tui/v2-dialog.js +137 -0
- package/src/tui-compiled/runtime/tui/v2.js +95 -0
- package/src/tui-compiled/runtime/tui.js +351 -222
- package/dist/index-4fnghj9e.js +0 -65
- package/dist/index-a69gr76b.js +0 -2
- package/dist/index-h263aftt.js +0 -157
- package/dist/index-ssj7m2rd.js +0 -8
- package/dist/index-ydz3hbhc.js +0 -2
- package/dist/map-m6f5a_IA.d.ts +0 -56
- package/dist/token-util-4a2xxnpg.js +0 -1
|
@@ -22,18 +22,23 @@ export const NO_QUOTA_READING = 'no quota reading yet';
|
|
|
22
22
|
*/
|
|
23
23
|
export const NO_LIMITS_REPORTED = 'no limits reported';
|
|
24
24
|
/**
|
|
25
|
-
* A
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* A time span in at most its two largest units: `45m`, `2h29m`, `5h`, `3d23h`,
|
|
26
|
+
* `7d`. Every unit is rounded down, so a reset is never shown later than it is;
|
|
27
|
+
* the one exception is a span under a minute, which reads `1m` rather than
|
|
28
|
+
* zero. A zero second unit is left out.
|
|
28
29
|
*/
|
|
29
30
|
function shortSpan(ms) {
|
|
30
|
-
const minutes = Math.max(1, Math.
|
|
31
|
+
const minutes = Math.max(1, Math.floor(ms / 60_000));
|
|
31
32
|
if (minutes < 60)
|
|
32
33
|
return `${minutes}m`;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
if (minutes < 1440) {
|
|
35
|
+
const hours = Math.floor(minutes / 60);
|
|
36
|
+
const rest = minutes % 60;
|
|
37
|
+
return rest === 0 ? `${hours}h` : `${hours}h${rest}m`;
|
|
38
|
+
}
|
|
39
|
+
const days = Math.floor(minutes / 1440);
|
|
40
|
+
const hours = Math.floor((minutes % 1440) / 60);
|
|
41
|
+
return hours === 0 ? `${days}d` : `${days}d${hours}h`;
|
|
37
42
|
}
|
|
38
43
|
/** A stored window length in minutes as its exact name: `5h`, `7d`, `90m`. */
|
|
39
44
|
function lengthName(minutes) {
|
|
@@ -58,8 +63,12 @@ export function quotaWindowName(limit) {
|
|
|
58
63
|
function clampPercent(remaining) {
|
|
59
64
|
return Math.min(100, Math.max(0, remaining));
|
|
60
65
|
}
|
|
66
|
+
/** A remaining percentage as the whole number every form shows. */
|
|
67
|
+
function wholePercent(remaining) {
|
|
68
|
+
return Math.round(clampPercent(remaining));
|
|
69
|
+
}
|
|
61
70
|
function percentLeft(remaining) {
|
|
62
|
-
return `${
|
|
71
|
+
return `${wholePercent(remaining)}% left`;
|
|
63
72
|
}
|
|
64
73
|
function resetDetails(resetsAt, checkedAt, now) {
|
|
65
74
|
const at = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt);
|
|
@@ -93,6 +102,16 @@ function displayOrder(left, right) {
|
|
|
93
102
|
* and the optional age keep the shared formatter's wording.
|
|
94
103
|
*/
|
|
95
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) {
|
|
96
115
|
if (!quota)
|
|
97
116
|
return { windows: [] };
|
|
98
117
|
const { now } = options;
|
|
@@ -102,12 +121,15 @@ export function quotaWindowParts(quota, options) {
|
|
|
102
121
|
const windows = readings.map((limit) => {
|
|
103
122
|
const remainingPercent = clampPercent(limit.remainingPercent ?? 0);
|
|
104
123
|
return {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
124
|
+
scope: limit.scope,
|
|
125
|
+
part: {
|
|
126
|
+
kind: 'window',
|
|
127
|
+
name: quotaWindowName(limit),
|
|
128
|
+
figure: percentLeft(limit.remainingPercent ?? 0),
|
|
129
|
+
remainingPercent,
|
|
130
|
+
checkedAt: limit.checkedAt,
|
|
131
|
+
...resetDetails(limit.resetsAt, limit.checkedAt, now),
|
|
132
|
+
},
|
|
111
133
|
};
|
|
112
134
|
});
|
|
113
135
|
const budget = quota.budget;
|
|
@@ -119,32 +141,131 @@ export function quotaWindowParts(quota, options) {
|
|
|
119
141
|
? `credits ${percentLeft(remainingPercent)}`
|
|
120
142
|
: 'credits available';
|
|
121
143
|
windows.push({
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
scope: ALL_SCOPE,
|
|
145
|
+
spent: budget.reached,
|
|
146
|
+
part: {
|
|
147
|
+
kind: 'budget',
|
|
148
|
+
name: 'credits',
|
|
149
|
+
figure,
|
|
150
|
+
...(remainingPercent === undefined
|
|
151
|
+
? {}
|
|
152
|
+
: { remainingPercent: clampPercent(remainingPercent) }),
|
|
153
|
+
checkedAt: budget.checkedAt,
|
|
154
|
+
...resetDetails(budget.resetsAt, budget.checkedAt, now),
|
|
155
|
+
},
|
|
130
156
|
});
|
|
131
157
|
}
|
|
132
158
|
if (windows.length > 0) {
|
|
133
|
-
const age = now - Math.min(...windows.map((part) => part.checkedAt));
|
|
159
|
+
const age = now - Math.min(...windows.map(({ part }) => part.checkedAt));
|
|
134
160
|
if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) {
|
|
135
161
|
return { windows, age: `checked ${shortSpan(age)} ago` };
|
|
136
162
|
}
|
|
137
163
|
}
|
|
138
164
|
return { windows };
|
|
139
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Whether the reading is stale: true exactly when the compact and full forms
|
|
168
|
+
* would end with `checked … ago`. The narrow form leaves that age out to stay
|
|
169
|
+
* short, so a caller drawing a narrow line uses this to dim it instead.
|
|
170
|
+
*/
|
|
171
|
+
export function isQuotaStale(quota, options) {
|
|
172
|
+
return scopedWindowParts(quota, options).age !== undefined;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The model a scope names, as a display name: any namespace before a colon
|
|
176
|
+
* is dropped (`model:claude-opus-4` → `claude-opus-4`) and the first letter is
|
|
177
|
+
* capitalized (`fable` → `Fable`); the rest stays as written.
|
|
178
|
+
*/
|
|
179
|
+
function scopeDisplayName(scope) {
|
|
180
|
+
const model = scope.slice(scope.indexOf(':') + 1);
|
|
181
|
+
return model.charAt(0).toUpperCase() + model.slice(1);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Short names for the model scopes in one row: the first two letters of each
|
|
185
|
+
* display name. Names that would read the same for different models are each
|
|
186
|
+
* extended one letter at a time until they differ (`Fable` and `Fabric` read
|
|
187
|
+
* `Fabl` and `Fabr`); a name that runs out of letters stays whole.
|
|
188
|
+
*/
|
|
189
|
+
function scopeAbbreviations(scopes) {
|
|
190
|
+
const names = [...new Set(scopes.map(scopeDisplayName))];
|
|
191
|
+
const lengths = new Map(names.map((name) => [name, 2]));
|
|
192
|
+
const short = (name) => name.slice(0, lengths.get(name));
|
|
193
|
+
for (;;) {
|
|
194
|
+
const byShort = new Map();
|
|
195
|
+
for (const name of names) {
|
|
196
|
+
byShort.set(short(name), [...(byShort.get(short(name)) ?? []), name]);
|
|
197
|
+
}
|
|
198
|
+
let extended = false;
|
|
199
|
+
for (const group of byShort.values()) {
|
|
200
|
+
if (group.length < 2)
|
|
201
|
+
continue;
|
|
202
|
+
for (const name of group) {
|
|
203
|
+
const length = lengths.get(name) ?? 2;
|
|
204
|
+
if (length < name.length) {
|
|
205
|
+
lengths.set(name, length + 1);
|
|
206
|
+
extended = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!extended)
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
return new Map(scopes.map((scope) => [scope, short(scopeDisplayName(scope))]));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The narrow form's pieces: every account-wide window as `5h 92%`, then at
|
|
217
|
+
* most one more item, with ` left` added once to the last piece. That item is
|
|
218
|
+
* the tightest (least remaining) of the model-scoped windows and the credit
|
|
219
|
+
* budget, so the line stays bounded whatever the account carries: a scoped
|
|
220
|
+
* window reads under its short model name (`Fa 98%`), the budget as `cr 75%`
|
|
221
|
+
* (`cr 0%` when spent). On a tie a scoped window wins over the budget, and
|
|
222
|
+
* among scoped windows the first in display order wins. The choice depends
|
|
223
|
+
* only on the reading, never on a width. A budget without a percentage cannot
|
|
224
|
+
* be chosen; when nothing at all is shown it keeps its compact wording
|
|
225
|
+
* (`credits available`). No age is added; `isQuotaStale` says whether the
|
|
226
|
+
* reading is stale. A window whose reset arrived after the reading keeps its
|
|
227
|
+
* figure, as in the other forms.
|
|
228
|
+
*/
|
|
229
|
+
function narrowTextParts(quota, options) {
|
|
230
|
+
const { windows } = scopedWindowParts(quota, options);
|
|
231
|
+
const general = windows.filter(({ part, scope }) => part.kind === 'window' && scope === ALL_SCOPE);
|
|
232
|
+
const scoped = windows.filter(({ part, scope }) => part.kind === 'window' && scope !== ALL_SCOPE);
|
|
233
|
+
const abbreviations = scopeAbbreviations(scoped.map(({ scope }) => scope));
|
|
234
|
+
// The items competing for the one slot after the account-wide windows, in
|
|
235
|
+
// tie-break order: scoped windows in display order, then the budget. A
|
|
236
|
+
// spent budget counts as 0% left; one without a percentage cannot compete.
|
|
237
|
+
const budget = windows.find(({ part }) => part.kind === 'budget');
|
|
238
|
+
const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent;
|
|
239
|
+
const extras = [
|
|
240
|
+
...scoped.map(({ scope, part }) => ({
|
|
241
|
+
label: abbreviations.get(scope) ?? scope,
|
|
242
|
+
remaining: part.remainingPercent ?? 0,
|
|
243
|
+
})),
|
|
244
|
+
...(budgetLeft === undefined
|
|
245
|
+
? []
|
|
246
|
+
: [{ label: 'cr', remaining: budgetLeft }]),
|
|
247
|
+
];
|
|
248
|
+
// Strictly lower wins, so on a tie the earlier item keeps the slot.
|
|
249
|
+
const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined);
|
|
250
|
+
const pieces = general.map(({ part }) => `${part.name} ${wholePercent(part.remainingPercent ?? 0)}%`);
|
|
251
|
+
if (tightest) {
|
|
252
|
+
pieces.push(`${tightest.label} ${wholePercent(tightest.remaining)}%`);
|
|
253
|
+
}
|
|
254
|
+
if (pieces.length === 0)
|
|
255
|
+
return windows.map(({ part }) => part.figure);
|
|
256
|
+
return pieces.map((piece, index) => index === pieces.length - 1 ? `${piece} left` : piece);
|
|
257
|
+
}
|
|
140
258
|
/**
|
|
141
259
|
* The pieces of a quota text, in order: one per reported window
|
|
142
260
|
* (`5h 58% left, resets 2h`), then the credit budget when there is one, then
|
|
143
261
|
* `checked 3h ago` when the oldest piece shown is stale. Empty when nothing
|
|
144
262
|
* was reported; `formatQuota` turns that into a sentence. A caller printing
|
|
145
|
-
* one line per window (the terminal auth menu) uses these directly.
|
|
263
|
+
* one line per window (the terminal auth menu) uses these directly. In the
|
|
264
|
+
* narrow form the pieces are the narrow line's (`5h 92%`, `7d 66% left`).
|
|
146
265
|
*/
|
|
147
266
|
export function quotaTextParts(quota, options) {
|
|
267
|
+
if (options.form === 'narrow')
|
|
268
|
+
return narrowTextParts(quota, options);
|
|
148
269
|
const parts = quotaWindowParts(quota, options);
|
|
149
270
|
const full = (options.form ?? 'full') === 'full';
|
|
150
271
|
const text = parts.windows.map((part) => {
|
|
@@ -155,7 +276,8 @@ export function quotaTextParts(quota, options) {
|
|
|
155
276
|
}
|
|
156
277
|
/**
|
|
157
278
|
* A row's quota as one line: `5h 58% left · 7d 90% left` in the compact
|
|
158
|
-
* form, with `, resets 2h` after each window in the full form
|
|
279
|
+
* form, with `, resets 2h` after each window in the full form, and
|
|
280
|
+
* `5h 58% · 7d 90% left` in the narrow form. With nothing
|
|
159
281
|
* reported it is `NO_QUOTA_READING`, or `NO_LIMITS_REPORTED` when the
|
|
160
282
|
* provider answered without a limit for any window.
|
|
161
283
|
*/
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { ClaustrumCredentialError, } from "@cortexkit/claustrum-client";
|
|
2
|
+
import { createLogger } from "./cc9e0702a752cea7-index.js";
|
|
3
|
+
import { readClaustrumEnrollmentToken } from "./b9588ad4725c1d10-enrollment.js";
|
|
4
|
+
import { ClaustrumConsumerError } from "./ca0fccaba1d87af4-errors.js";
|
|
5
|
+
import { CUSTODY_PLACEHOLDER_PREFIX } from "./d9d2892a4c371301-host-slot.js";
|
|
6
|
+
/** Only a new version of the same account may replace an in-flight 401. */
|
|
7
|
+
export function isScopedCredentialRotation(served, current) {
|
|
8
|
+
return (current !== undefined &&
|
|
9
|
+
current.credentialId === served.credentialId &&
|
|
10
|
+
current.accountIdentity === served.accountIdentity &&
|
|
11
|
+
current.recordVersion !== served.recordVersion);
|
|
12
|
+
}
|
|
13
|
+
function scopedRetryReason(served, current) {
|
|
14
|
+
if (current === undefined)
|
|
15
|
+
return 'reauthorize-failed';
|
|
16
|
+
if (current.credentialId !== served.credentialId)
|
|
17
|
+
return 'credential-changed';
|
|
18
|
+
if (current.accountIdentity !== served.accountIdentity)
|
|
19
|
+
return 'account-changed';
|
|
20
|
+
if (current.recordVersion === served.recordVersion)
|
|
21
|
+
return 'version-unchanged';
|
|
22
|
+
return 'rotated';
|
|
23
|
+
}
|
|
24
|
+
const defaultLogger = createLogger('claustrum');
|
|
25
|
+
/**
|
|
26
|
+
* Decide whether a request that got a 401 should retry with the freshly
|
|
27
|
+
* re-authorized receipt, and log the decision. The vault can refresh a
|
|
28
|
+
* credential between the send and the 401; the log line lets that refresh be
|
|
29
|
+
* matched against the consumer's retry. `site` names the kind of request.
|
|
30
|
+
*/
|
|
31
|
+
export function decideScopedRetryAfter401(site, served, current, logger = defaultLogger) {
|
|
32
|
+
const retry = isScopedCredentialRotation(served, current);
|
|
33
|
+
logger.debug('scoped 401 re-authorized', {
|
|
34
|
+
site,
|
|
35
|
+
credentialId: served.credentialId,
|
|
36
|
+
servedVersion: served.recordVersion,
|
|
37
|
+
currentVersion: current?.recordVersion ?? null,
|
|
38
|
+
retry,
|
|
39
|
+
reason: scopedRetryReason(served, current),
|
|
40
|
+
});
|
|
41
|
+
return retry;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* How long a served OAuth token must stay valid after the vault hands it out,
|
|
45
|
+
* so it cannot expire while a long request is still using it.
|
|
46
|
+
*/
|
|
47
|
+
export const SERVING_MARGIN_MS = 300_000;
|
|
48
|
+
function invalidMaterial() {
|
|
49
|
+
return new ClaustrumConsumerError('invalid-material', 'Claustrum returned invalid credential material');
|
|
50
|
+
}
|
|
51
|
+
function accessTokenFromMaterial(material) {
|
|
52
|
+
let access = material.trim();
|
|
53
|
+
if (access.startsWith('{')) {
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(access);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw invalidMaterial();
|
|
60
|
+
}
|
|
61
|
+
if (!parsed || typeof parsed !== 'object')
|
|
62
|
+
throw invalidMaterial();
|
|
63
|
+
const record = parsed;
|
|
64
|
+
const value = record.access_token ?? record.access;
|
|
65
|
+
access = typeof value === 'string' ? value : '';
|
|
66
|
+
}
|
|
67
|
+
// A header-safe token only: control characters could split the request,
|
|
68
|
+
// and the custody placeholder must never be sent as a credential.
|
|
69
|
+
if (!/^[\x21-\x7e]+$/.test(access) ||
|
|
70
|
+
access.startsWith(CUSTODY_PLACEHOLDER_PREFIX)) {
|
|
71
|
+
throw invalidMaterial();
|
|
72
|
+
}
|
|
73
|
+
return access;
|
|
74
|
+
}
|
|
75
|
+
function credentialTypeOf(row, family) {
|
|
76
|
+
if (!row.operations.includes('read'))
|
|
77
|
+
return undefined;
|
|
78
|
+
if (!row.categories.includes(family.category))
|
|
79
|
+
return undefined;
|
|
80
|
+
if (row.credentialType === 'oauth')
|
|
81
|
+
return row.refreshAdapter === family.refreshAdapter ? 'oauth' : undefined;
|
|
82
|
+
if (row.credentialType === 'api_key' && family.apiKeys)
|
|
83
|
+
return row.refreshAdapter === undefined ? 'api_key' : undefined;
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The grants named in the vault's list reply, or undefined when it names none.
|
|
88
|
+
* Both fields must be present: a reply carrying only one of them is treated as
|
|
89
|
+
* unknown rather than completed with a guess. claustrum-client already refuses
|
|
90
|
+
* a malformed value, so nothing here validates it again.
|
|
91
|
+
*/
|
|
92
|
+
function grantReachOf(listed) {
|
|
93
|
+
const { grants, grantTuples } = listed;
|
|
94
|
+
if (grants === undefined || grantTuples === undefined)
|
|
95
|
+
return undefined;
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
grants,
|
|
98
|
+
tuples: Object.freeze(grantTuples.map(({ selectorKind, selector, operation }) => Object.freeze({ selectorKind, selector, operation }))),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Reads this consumer's vault credentials (list, fetch, 401 report), with the
|
|
103
|
+
* enrollment token as authorization. Used by every host of a plugin. There is
|
|
104
|
+
* deliberately no credential cache and no single-flight of credential reads:
|
|
105
|
+
* each physical send is authorized by the vault, so a revoked enrollment or a
|
|
106
|
+
* changed record takes effect on the next send. The enrollment token is
|
|
107
|
+
* re-read per operation so an operator's reissue on disk is picked up.
|
|
108
|
+
*/
|
|
109
|
+
export class ClaustrumScopedCustody {
|
|
110
|
+
#client;
|
|
111
|
+
#readToken;
|
|
112
|
+
#now;
|
|
113
|
+
#family;
|
|
114
|
+
#parseIdentity;
|
|
115
|
+
#requireAssertion;
|
|
116
|
+
#logger;
|
|
117
|
+
#provenance = new WeakMap();
|
|
118
|
+
#reports = new WeakMap();
|
|
119
|
+
#closed = false;
|
|
120
|
+
constructor(options) {
|
|
121
|
+
const tokenPath = options.tokenPath;
|
|
122
|
+
if (options.readToken) {
|
|
123
|
+
this.#readToken = options.readToken;
|
|
124
|
+
}
|
|
125
|
+
else if (tokenPath) {
|
|
126
|
+
this.#readToken = () => readClaustrumEnrollmentToken(tokenPath);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
throw new ClaustrumConsumerError('not-enrolled', 'Claustrum enrollment token path is required');
|
|
130
|
+
}
|
|
131
|
+
this.#client = options.client;
|
|
132
|
+
this.#family = options.family;
|
|
133
|
+
this.#parseIdentity = options.parseIdentity;
|
|
134
|
+
this.#requireAssertion = options.requireAssertion ?? false;
|
|
135
|
+
this.#now = options.now ?? Date.now;
|
|
136
|
+
this.#logger = options.logger ?? defaultLogger;
|
|
137
|
+
}
|
|
138
|
+
#check(signal) {
|
|
139
|
+
if (this.#closed)
|
|
140
|
+
throw new ClaustrumConsumerError('closed', 'Claustrum scoped custody is closed');
|
|
141
|
+
signal?.throwIfAborted();
|
|
142
|
+
}
|
|
143
|
+
async #token(signal) {
|
|
144
|
+
this.#check(signal);
|
|
145
|
+
const value = await this.#readToken();
|
|
146
|
+
this.#check(signal);
|
|
147
|
+
if (!/^[0-9a-f]{64}$/.test(value.token) ||
|
|
148
|
+
!Number.isSafeInteger(value.token_generation) ||
|
|
149
|
+
value.token_generation < 1) {
|
|
150
|
+
throw new ClaustrumConsumerError('invalid-token', 'Invalid Claustrum enrollment token');
|
|
151
|
+
}
|
|
152
|
+
return value.token;
|
|
153
|
+
}
|
|
154
|
+
async #call(operation, signal) {
|
|
155
|
+
this.#check(signal);
|
|
156
|
+
let result;
|
|
157
|
+
let removeAbortListener;
|
|
158
|
+
try {
|
|
159
|
+
const pending = operation();
|
|
160
|
+
result = signal
|
|
161
|
+
? await Promise.race([
|
|
162
|
+
pending,
|
|
163
|
+
new Promise((_resolve, reject) => {
|
|
164
|
+
const abort = () => reject(signal.reason);
|
|
165
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
166
|
+
removeAbortListener = () => signal.removeEventListener('abort', abort);
|
|
167
|
+
if (signal.aborted)
|
|
168
|
+
abort();
|
|
169
|
+
}),
|
|
170
|
+
])
|
|
171
|
+
: await pending;
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
this.#check(signal);
|
|
175
|
+
// Keep the vault's own refusals (ClaustrumCredentialError, carrying
|
|
176
|
+
// code, class and action); replace any other error, whose text may echo
|
|
177
|
+
// request params that include the enrollment token.
|
|
178
|
+
if (error instanceof ClaustrumCredentialError)
|
|
179
|
+
throw error;
|
|
180
|
+
throw new ClaustrumConsumerError('unavailable', 'Claustrum scoped operation unavailable');
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
removeAbortListener?.();
|
|
184
|
+
}
|
|
185
|
+
this.#check(signal);
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* List this consumer's credentials. A record that cannot be used is skipped
|
|
190
|
+
* and warned about rather than failing the whole list, so one bad record
|
|
191
|
+
* never hides every other account. Records outside the family are not
|
|
192
|
+
* skipped records: they simply are not this consumer's.
|
|
193
|
+
*/
|
|
194
|
+
async discover(signal) {
|
|
195
|
+
const token = await this.#token(signal);
|
|
196
|
+
const inventory = await this.#call(() => this.#client.listScoped(token), signal);
|
|
197
|
+
const skipped = [];
|
|
198
|
+
const candidates = [];
|
|
199
|
+
const counts = new Map();
|
|
200
|
+
for (const row of inventory.rows) {
|
|
201
|
+
const type = credentialTypeOf(row, this.#family);
|
|
202
|
+
if (!type)
|
|
203
|
+
continue;
|
|
204
|
+
candidates.push({ row, type });
|
|
205
|
+
counts.set(row.id, (counts.get(row.id) ?? 0) + 1);
|
|
206
|
+
}
|
|
207
|
+
const credentials = [];
|
|
208
|
+
for (const { row, type } of candidates) {
|
|
209
|
+
let reason;
|
|
210
|
+
if (!row.id.trim())
|
|
211
|
+
reason = 'empty credential id';
|
|
212
|
+
else if ((counts.get(row.id) ?? 0) > 1)
|
|
213
|
+
reason = 'duplicate credential id';
|
|
214
|
+
else if (row.accountId !== undefined && !row.accountId.trim())
|
|
215
|
+
reason = 'blank account identity';
|
|
216
|
+
else if (!row.state.trim())
|
|
217
|
+
reason = 'empty state';
|
|
218
|
+
if (reason) {
|
|
219
|
+
skipped.push({ ...(row.id.trim() && { credentialId: row.id }), reason });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
credentials.push(Object.freeze({
|
|
223
|
+
credentialId: row.id,
|
|
224
|
+
credentialType: type,
|
|
225
|
+
...(row.accountId !== undefined && {
|
|
226
|
+
accountIdentity: row.accountId,
|
|
227
|
+
}),
|
|
228
|
+
state: row.state,
|
|
229
|
+
...(row.email !== undefined && { email: row.email }),
|
|
230
|
+
...(row.orgName !== undefined && { orgName: row.orgName }),
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
for (const record of skipped)
|
|
234
|
+
this.#logger.warn('skipped malformed vault record', record);
|
|
235
|
+
const grantReach = grantReachOf(inventory);
|
|
236
|
+
return {
|
|
237
|
+
view: inventory.view,
|
|
238
|
+
credentials: Object.freeze(credentials),
|
|
239
|
+
skipped: Object.freeze(skipped),
|
|
240
|
+
...(grantReach && { grantReach }),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Fetch the credential for one physical send and wrap it in a fresh receipt.
|
|
245
|
+
* The vault's served identity (or, without one, the plugin's parse of the
|
|
246
|
+
* token) must equal the roster's when both are present. Without
|
|
247
|
+
* `requireAssertion`, absence on either side proves nothing and does not
|
|
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.
|
|
251
|
+
*/
|
|
252
|
+
async authorize(identity, signal) {
|
|
253
|
+
if (!identity.credentialId)
|
|
254
|
+
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum dispatch requires a credential id');
|
|
255
|
+
// Capture the caller's fields before yielding so a later mutation cannot move the fence.
|
|
256
|
+
const { credentialId, credentialType, accountIdentity } = identity;
|
|
257
|
+
if (this.#requireAssertion && accountIdentity === undefined)
|
|
258
|
+
throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum dispatch requires a known account identity');
|
|
259
|
+
const token = await this.#token(signal);
|
|
260
|
+
const served = await this.#call(() => this.#client.getScoped({
|
|
261
|
+
credentialId,
|
|
262
|
+
enrollmentToken: token,
|
|
263
|
+
...(credentialType === 'oauth' && { minTtlMs: SERVING_MARGIN_MS }),
|
|
264
|
+
}), signal);
|
|
265
|
+
if (served.credentialId !== undefined &&
|
|
266
|
+
served.credentialId !== credentialId) {
|
|
267
|
+
throw new ClaustrumConsumerError('identity-changed', 'Claustrum served credential identity changed');
|
|
268
|
+
}
|
|
269
|
+
const expiresAtMs = served.expiresAtMs;
|
|
270
|
+
if (!Number.isSafeInteger(served.recordVersion) ||
|
|
271
|
+
served.recordVersion < 0 ||
|
|
272
|
+
(expiresAtMs === null && credentialType === 'oauth') ||
|
|
273
|
+
(expiresAtMs !== null &&
|
|
274
|
+
(!Number.isFinite(expiresAtMs) ||
|
|
275
|
+
expiresAtMs - this.#now() < SERVING_MARGIN_MS))) {
|
|
276
|
+
throw new ClaustrumConsumerError('insufficient-validity', 'Claustrum served credential has insufficient validity');
|
|
277
|
+
}
|
|
278
|
+
const accessToken = accessTokenFromMaterial(served.material);
|
|
279
|
+
const assertedIdentity = served.accountId?.trim()
|
|
280
|
+
? served.accountId
|
|
281
|
+
: undefined;
|
|
282
|
+
if (this.#requireAssertion &&
|
|
283
|
+
(served.credentialId === undefined || assertedIdentity === undefined))
|
|
284
|
+
throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum served credential did not assert its identity');
|
|
285
|
+
const parsedIdentity = assertedIdentity === undefined
|
|
286
|
+
? this.#parseIdentity?.(accessToken)
|
|
287
|
+
: undefined;
|
|
288
|
+
const servedIdentity = assertedIdentity ?? parsedIdentity;
|
|
289
|
+
if (accountIdentity !== undefined &&
|
|
290
|
+
servedIdentity !== undefined &&
|
|
291
|
+
servedIdentity !== accountIdentity) {
|
|
292
|
+
throw new ClaustrumConsumerError('identity-changed', 'Claustrum served credential identity changed');
|
|
293
|
+
}
|
|
294
|
+
const resolvedIdentity = servedIdentity ?? accountIdentity;
|
|
295
|
+
const source = assertedIdentity !== undefined
|
|
296
|
+
? 'asserted'
|
|
297
|
+
: parsedIdentity !== undefined
|
|
298
|
+
? 'parsed'
|
|
299
|
+
: accountIdentity !== undefined
|
|
300
|
+
? 'expected'
|
|
301
|
+
: 'none';
|
|
302
|
+
const attempt = Object.freeze(Object.defineProperty({
|
|
303
|
+
credentialId,
|
|
304
|
+
credentialType,
|
|
305
|
+
...(resolvedIdentity !== undefined && {
|
|
306
|
+
accountIdentity: resolvedIdentity,
|
|
307
|
+
}),
|
|
308
|
+
accountIdentitySource: source,
|
|
309
|
+
...(accountIdentity !== undefined && {
|
|
310
|
+
expectedAccountIdentity: accountIdentity,
|
|
311
|
+
}),
|
|
312
|
+
...(served.credentialId !== undefined && {
|
|
313
|
+
assertedCredentialId: served.credentialId,
|
|
314
|
+
}),
|
|
315
|
+
...(assertedIdentity !== undefined && {
|
|
316
|
+
assertedAccountIdentity: assertedIdentity,
|
|
317
|
+
}),
|
|
318
|
+
...(served.projectId !== undefined && {
|
|
319
|
+
projectId: served.projectId,
|
|
320
|
+
}),
|
|
321
|
+
recordVersion: served.recordVersion,
|
|
322
|
+
expiresAtMs,
|
|
323
|
+
}, 'accessToken', { value: accessToken, enumerable: false }));
|
|
324
|
+
this.#provenance.set(attempt, token);
|
|
325
|
+
return attempt;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Report that a send this consumer actually made was rejected. Only a 401
|
|
329
|
+
* is reported (the vault refuses 429, 402 and 5xx reports), addressed by
|
|
330
|
+
* credential id with the exact record version that send was served, and
|
|
331
|
+
* signed with the enrollment token that authorized it. A receipt this
|
|
332
|
+
* custody did not issue is refused rather than reported.
|
|
333
|
+
*/
|
|
334
|
+
async reportFailure(attempt, status, reporterSource) {
|
|
335
|
+
if (status !== 401)
|
|
336
|
+
return;
|
|
337
|
+
this.#check();
|
|
338
|
+
const token = this.#provenance.get(attempt);
|
|
339
|
+
if (!token)
|
|
340
|
+
throw new ClaustrumConsumerError('no-receipt', 'Claustrum failure report requires an original dispatch receipt');
|
|
341
|
+
const pending = this.#reports.get(attempt);
|
|
342
|
+
if (pending)
|
|
343
|
+
return pending;
|
|
344
|
+
const report = this.#call(() => this.#client.reportAuthFailureScoped({
|
|
345
|
+
credentialId: attempt.credentialId,
|
|
346
|
+
enrollmentToken: token,
|
|
347
|
+
providerStatus: 401,
|
|
348
|
+
recordVersion: attempt.recordVersion,
|
|
349
|
+
reporterSource,
|
|
350
|
+
}));
|
|
351
|
+
this.#reports.set(attempt, report);
|
|
352
|
+
try {
|
|
353
|
+
await report;
|
|
354
|
+
this.#logger.debug('scoped 401 reported', {
|
|
355
|
+
credentialId: attempt.credentialId,
|
|
356
|
+
recordVersion: attempt.recordVersion,
|
|
357
|
+
reporterSource,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
this.#reports.delete(attempt);
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
close() {
|
|
366
|
+
if (this.#closed)
|
|
367
|
+
return;
|
|
368
|
+
this.#closed = true;
|
|
369
|
+
this.#client.close();
|
|
370
|
+
}
|
|
371
|
+
}
|