@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.
Files changed (66) hide show
  1. package/dist/bin.js +8 -8
  2. package/dist/{codex-request-BXDXToyt.d.ts → codex-request-B9D0e97Q.d.ts} +177 -28
  3. package/dist/{index-7c6sxtd6.js → index-1mbgsxsr.js} +2 -2
  4. package/dist/{index-4c8yw54k.js → index-2pgkrebq.js} +2 -2
  5. package/dist/index-4v2fs42x.js +65 -0
  6. package/dist/index-8x1aj7q3.js +2 -0
  7. package/dist/{index-f2fmb596.js → index-bc89akak.js} +1 -1
  8. package/dist/index-ex6nx3mq.js +2 -0
  9. package/dist/{index-zpm95mt5.js → index-ftkhhr6d.js} +1 -1
  10. package/dist/{index-msx2m78k.js → index-gn2b097f.js} +2 -2
  11. package/dist/{index-j8v2xwq6.js → index-m3ctrabv.js} +1 -1
  12. package/dist/index-psjyexj3.js +69 -0
  13. package/dist/{index-k6cydkgt.js → index-tddr876f.js} +2 -2
  14. package/dist/index-tn8x5tz8.js +90 -0
  15. package/dist/{index-csky2xpg.js → index-xwvze96g.js} +1 -1
  16. package/dist/index.d.ts +5 -3
  17. package/dist/index.js +1 -1
  18. package/dist/{model-YRg7Uowo.d.ts → model-BdITTmeb.d.ts} +1 -1
  19. package/dist/{port-file-CxEpgSHY.d.ts → port-file-DbHIVC9a.d.ts} +1 -1
  20. package/dist/{protocol-CDV1Pc6h.d.ts → protocol-MysYPAho.d.ts} +1 -1
  21. package/dist/rpc/port-file.d.ts +1 -1
  22. package/dist/rpc/port-file.js +1 -1
  23. package/dist/rpc/protocol.d.ts +1 -1
  24. package/dist/rpc/protocol.js +1 -1
  25. package/dist/rpc/rpc-client.d.ts +3 -3
  26. package/dist/rpc/rpc-client.js +1 -1
  27. package/dist/rpc/rpc-dir.js +1 -1
  28. package/dist/{sidebar-state-DjTWUbmf.d.ts → sidebar-state-CVSok7zv.d.ts} +114 -5
  29. package/dist/sidebar-state.d.ts +2 -2
  30. package/dist/sidebar-state.js +1 -1
  31. package/dist/token-util-cf0nyh7n.js +1 -0
  32. package/dist/{token-rz4t3ay6.js → token-z6qq7a1d.js} +1 -1
  33. package/dist/tui-preferences.js +1 -1
  34. package/dist/tui.d.ts +2 -2
  35. package/dist/v2/server.d.ts +109 -2
  36. package/dist/v2/server.js +3 -3
  37. package/package.json +4 -4
  38. package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +203 -18
  39. package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +242 -16
  40. package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +168 -6
  41. package/src/tui-compiled/raw/shared/375d9cca2def037d-private-files.js +10 -2
  42. package/src/tui-compiled/raw/shared/94f53cfd572ba4bb-availability.js +106 -0
  43. package/src/tui-compiled/raw/shared/d0f0b6be200311af-insula.js +286 -21
  44. package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +1 -1
  45. package/src/tui-compiled/raw/shared/dc9290c2a7552ba6-watch.js +253 -0
  46. package/src/tui-compiled/raw/shared/e8d3c8da776ef9cd-index.js +4 -1
  47. package/src/tui-compiled/raw/sidebar-state.js +63 -1
  48. package/src/tui-compiled/raw/tui.tsx +82 -30
  49. package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +203 -18
  50. package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +242 -16
  51. package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +168 -6
  52. package/src/tui-compiled/runtime/shared/375d9cca2def037d-private-files.js +10 -2
  53. package/src/tui-compiled/runtime/shared/94f53cfd572ba4bb-availability.js +106 -0
  54. package/src/tui-compiled/runtime/shared/d0f0b6be200311af-insula.js +286 -21
  55. package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +1 -1
  56. package/src/tui-compiled/runtime/shared/dc9290c2a7552ba6-watch.js +253 -0
  57. package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +4 -1
  58. package/src/tui-compiled/runtime/sidebar-state.js +63 -1
  59. package/src/tui-compiled/runtime/tui.js +245 -208
  60. package/dist/index-0yh85bwb.js +0 -69
  61. package/dist/index-7eb3dw37.js +0 -90
  62. package/dist/index-8re8rprk.js +0 -2
  63. package/dist/index-edbdscy7.js +0 -65
  64. package/dist/index-m5qmvr6c.js +0 -2
  65. package/dist/map-Buq-GW7l.d.ts +0 -56
  66. 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 `${Math.round(clampPercent(remaining))}% left`;
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
- kind: 'window',
111
- name: quotaWindowName(limit),
112
- figure: percentLeft(limit.remainingPercent ?? 0),
113
- remainingPercent,
114
- checkedAt: limit.checkedAt,
115
- ...resetDetails(limit.resetsAt, limit.checkedAt, now),
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
- kind: 'budget',
128
- name: 'credits',
129
- figure,
130
- ...(remainingPercent === undefined
131
- ? {}
132
- : { remainingPercent: clampPercent(remainingPercent) }),
133
- checkedAt: budget.checkedAt,
134
- ...resetDetails(budget.resetsAt, budget.checkedAt, now),
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. With nothing
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. There is
89
- * deliberately no credential cache and no single-flight of credential reads:
90
- * each physical send is authorized by the vault, so a revoked enrollment or a
91
- * changed record takes effect on the next send. The enrollment token is
92
- * re-read per operation so an operator's reissue on disk is picked up.
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
- const token = await this.#token(signal);
181
- const inventory = await this.#call(() => this.#client.listScoped(token), signal);
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
- * Fetch the credential for one physical send and wrap it in a fresh receipt.
228
- * The vault's served identity (or, without one, the plugin's parse of the
229
- * token) must equal the roster's when both are present. Without
230
- * `requireAssertion`, absence on either side proves nothing and does not
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
- async authorize(identity, signal) {
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
  }