@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.
Files changed (104) hide show
  1. package/dist/bin.js +9 -8
  2. package/dist/{pool-lifecycle-C_6YRmsh.d.ts → codex-request-CjXETpTA.d.ts} +190 -27
  3. package/dist/index-03em8hv1.js +69 -0
  4. package/dist/index-6n4pnfkg.js +2 -0
  5. package/dist/{index-pg305s90.js → index-bc89akak.js} +1 -1
  6. package/dist/{index-y4gxyxqd.js → index-ehjj89rx.js} +2 -2
  7. package/dist/index-f21addzm.js +90 -0
  8. package/dist/{index-fsky1rsh.js → index-ffg0sgg5.js} +2 -2
  9. package/dist/{index-nx2qh047.js → index-ftkhhr6d.js} +1 -1
  10. package/dist/{index-826s1yxa.js → index-htvcmtcx.js} +2 -2
  11. package/dist/{index-fsxbncpw.js → index-qp5hdpw8.js} +1 -1
  12. package/dist/index-qxbchh2d.js +8 -0
  13. package/dist/{index-116q849r.js → index-r2s51ac1.js} +2 -2
  14. package/dist/index-rwbrhkse.js +65 -0
  15. package/dist/{index-66asrje2.js → index-xwvze96g.js} +1 -1
  16. package/dist/index.d.ts +18 -8
  17. package/dist/index.js +1 -1
  18. package/dist/{protocol-DctqemRI.d.ts → model-CQcw10oW.d.ts} +67 -25
  19. package/dist/{port-file-ZPlxXB4G.d.ts → port-file-BDJnzgHH.d.ts} +1 -1
  20. package/dist/protocol-C-kagE6O.d.ts +29 -0
  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-z_M7Trnd.d.ts → sidebar-state-BfQ4mpZO.d.ts} +109 -10
  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-bzhkgc29.js → token-z6qq7a1d.js} +1 -1
  33. package/dist/tui-preferences.js +1 -1
  34. package/dist/tui.d.ts +20 -2
  35. package/dist/v2/server.d.ts +180 -1
  36. package/dist/v2/server.js +3 -3
  37. package/package.json +3 -3
  38. package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +148 -26
  39. package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +371 -0
  40. package/src/tui-compiled/raw/shared/134b008900698fe0-backoff.js +92 -0
  41. package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +430 -0
  42. package/src/tui-compiled/raw/shared/2577ec69f9973ae9-quota-normalize.js +154 -0
  43. package/src/tui-compiled/raw/shared/2dce6e9613993329-quota-manager.js +473 -0
  44. package/src/tui-compiled/raw/shared/30f5bc480f31f51c-roster.js +566 -0
  45. package/src/tui-compiled/raw/shared/43fc9e6b994519c7-refresh-lock.js +0 -1
  46. package/src/tui-compiled/raw/shared/4593a4d71edf69fd-tombstone.js +25 -0
  47. package/src/tui-compiled/raw/shared/4d05c2d7a531aece-oauth.js +500 -0
  48. package/src/tui-compiled/raw/shared/6371b24a0573d79e-any-signal.js +42 -0
  49. package/src/tui-compiled/raw/shared/7c140680fc81be92-interlock.js +29 -0
  50. package/src/tui-compiled/raw/shared/85512cc5263ba259-provider.js +87 -0
  51. package/src/tui-compiled/raw/shared/abd062d633e29fd2-pool-routing.js +192 -0
  52. package/src/tui-compiled/raw/shared/b9588ad4725c1d10-enrollment.js +806 -0
  53. package/src/tui-compiled/raw/shared/bfbdd0f9aae56b47-pool-quota.js +85 -0
  54. package/src/tui-compiled/raw/shared/c852a15cfe489d12-pool.js +0 -1
  55. package/src/tui-compiled/raw/shared/ca0fccaba1d87af4-errors.js +8 -0
  56. package/src/tui-compiled/raw/shared/d0f0b6be200311af-insula.js +152 -0
  57. package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +1 -1
  58. package/src/tui-compiled/raw/shared/d9d2892a4c371301-host-slot.js +72 -0
  59. package/src/tui-compiled/raw/shared/dbc61cd405941828-accounts.js +1470 -0
  60. package/src/tui-compiled/raw/shared/e8cd4929507508d8-refresh-file-lock.js +26 -12
  61. package/src/tui-compiled/raw/shared/e8d3c8da776ef9cd-index.js +24 -0
  62. package/src/tui-compiled/raw/sidebar-state.js +103 -14
  63. package/src/tui-compiled/raw/tui/command-dialogs.tsx +3 -34
  64. package/src/tui-compiled/raw/tui/section-text.js +21 -0
  65. package/src/tui-compiled/raw/tui/v2-dialog.js +137 -0
  66. package/src/tui-compiled/raw/tui/v2.tsx +133 -0
  67. package/src/tui-compiled/raw/tui.tsx +228 -70
  68. package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +148 -26
  69. package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +371 -0
  70. package/src/tui-compiled/runtime/shared/134b008900698fe0-backoff.js +92 -0
  71. package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +430 -0
  72. package/src/tui-compiled/runtime/shared/2577ec69f9973ae9-quota-normalize.js +154 -0
  73. package/src/tui-compiled/runtime/shared/2dce6e9613993329-quota-manager.js +473 -0
  74. package/src/tui-compiled/runtime/shared/30f5bc480f31f51c-roster.js +566 -0
  75. package/src/tui-compiled/runtime/shared/43fc9e6b994519c7-refresh-lock.js +0 -1
  76. package/src/tui-compiled/runtime/shared/4593a4d71edf69fd-tombstone.js +25 -0
  77. package/src/tui-compiled/runtime/shared/4d05c2d7a531aece-oauth.js +500 -0
  78. package/src/tui-compiled/runtime/shared/6371b24a0573d79e-any-signal.js +42 -0
  79. package/src/tui-compiled/runtime/shared/7c140680fc81be92-interlock.js +29 -0
  80. package/src/tui-compiled/runtime/shared/85512cc5263ba259-provider.js +87 -0
  81. package/src/tui-compiled/runtime/shared/abd062d633e29fd2-pool-routing.js +192 -0
  82. package/src/tui-compiled/runtime/shared/b9588ad4725c1d10-enrollment.js +806 -0
  83. package/src/tui-compiled/runtime/shared/bfbdd0f9aae56b47-pool-quota.js +85 -0
  84. package/src/tui-compiled/runtime/shared/c852a15cfe489d12-pool.js +0 -1
  85. package/src/tui-compiled/runtime/shared/ca0fccaba1d87af4-errors.js +8 -0
  86. package/src/tui-compiled/runtime/shared/d0f0b6be200311af-insula.js +152 -0
  87. package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +1 -1
  88. package/src/tui-compiled/runtime/shared/d9d2892a4c371301-host-slot.js +72 -0
  89. package/src/tui-compiled/runtime/shared/dbc61cd405941828-accounts.js +1470 -0
  90. package/src/tui-compiled/runtime/shared/e8cd4929507508d8-refresh-file-lock.js +26 -12
  91. package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +24 -0
  92. package/src/tui-compiled/runtime/sidebar-state.js +103 -14
  93. package/src/tui-compiled/runtime/tui/command-dialogs.js +3 -15
  94. package/src/tui-compiled/runtime/tui/section-text.js +21 -0
  95. package/src/tui-compiled/runtime/tui/v2-dialog.js +137 -0
  96. package/src/tui-compiled/runtime/tui/v2.js +95 -0
  97. package/src/tui-compiled/runtime/tui.js +351 -222
  98. package/dist/index-4fnghj9e.js +0 -65
  99. package/dist/index-a69gr76b.js +0 -2
  100. package/dist/index-h263aftt.js +0 -157
  101. package/dist/index-ssj7m2rd.js +0 -8
  102. package/dist/index-ydz3hbhc.js +0 -2
  103. package/dist/map-m6f5a_IA.d.ts +0 -56
  104. 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 length or a time span in its largest whole-ish unit: `45m`, `5h`, `3d`.
26
- * Spans under an hour are minutes, under a day hours, otherwise days, each
27
- * rounded to the nearest unit and never shown as zero.
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.round(ms / 60_000));
31
+ const minutes = Math.max(1, Math.floor(ms / 60_000));
31
32
  if (minutes < 60)
32
33
  return `${minutes}m`;
33
- const hours = Math.round(minutes / 60);
34
- if (hours < 24)
35
- return `${hours}h`;
36
- return `${Math.max(1, Math.round(minutes / 1440))}d`;
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 `${Math.round(clampPercent(remaining))}% left`;
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
- kind: 'window',
106
- name: quotaWindowName(limit),
107
- figure: percentLeft(limit.remainingPercent ?? 0),
108
- remainingPercent,
109
- checkedAt: limit.checkedAt,
110
- ...resetDetails(limit.resetsAt, limit.checkedAt, now),
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
- kind: 'budget',
123
- name: 'credits',
124
- figure,
125
- ...(remainingPercent === undefined
126
- ? {}
127
- : { remainingPercent: clampPercent(remainingPercent) }),
128
- checkedAt: budget.checkedAt,
129
- ...resetDetails(budget.resetsAt, budget.checkedAt, now),
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. With nothing
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
+ }