@cortexkit/opencode-openai-auth 1.0.5 → 1.1.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-BXDXToyt.d.ts} +158 -27
- package/dist/index-0yh85bwb.js +69 -0
- package/dist/{index-826s1yxa.js → index-4c8yw54k.js} +2 -2
- package/dist/{index-y4gxyxqd.js → index-7c6sxtd6.js} +2 -2
- package/dist/index-7eb3dw37.js +90 -0
- package/dist/index-8re8rprk.js +2 -0
- package/dist/{index-66asrje2.js → index-csky2xpg.js} +1 -1
- package/dist/index-edbdscy7.js +65 -0
- package/dist/{index-pg305s90.js → index-f2fmb596.js} +1 -1
- package/dist/{index-fsxbncpw.js → index-j8v2xwq6.js} +1 -1
- package/dist/{index-fsky1rsh.js → index-k6cydkgt.js} +2 -2
- package/dist/{index-116q849r.js → index-m5qmvr6c.js} +2 -2
- package/dist/index-msx2m78k.js +8 -0
- package/dist/{index-nx2qh047.js → index-zpm95mt5.js} +1 -1
- package/dist/index.d.ts +15 -7
- package/dist/index.js +1 -1
- package/dist/{map-m6f5a_IA.d.ts → map-Buq-GW7l.d.ts} +1 -1
- package/dist/{protocol-DctqemRI.d.ts → model-YRg7Uowo.d.ts} +67 -25
- package/dist/{port-file-ZPlxXB4G.d.ts → port-file-CxEpgSHY.d.ts} +1 -1
- package/dist/protocol-CDV1Pc6h.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-DjTWUbmf.d.ts} +15 -9
- package/dist/sidebar-state.d.ts +1 -1
- package/dist/sidebar-state.js +1 -1
- package/dist/{token-bzhkgc29.js → token-rz4t3ay6.js} +1 -1
- package/dist/token-util-tpy2c7h6.js +1 -0
- package/dist/tui-preferences.js +1 -1
- package/dist/tui.d.ts +20 -2
- package/dist/v2/server.d.ts +73 -1
- package/dist/v2/server.js +3 -3
- package/package.json +2 -2
- package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +13 -8
- package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +354 -0
- package/src/tui-compiled/raw/shared/134b008900698fe0-backoff.js +92 -0
- package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +393 -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/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 +41 -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 +146 -40
- package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +13 -8
- package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +354 -0
- package/src/tui-compiled/runtime/shared/134b008900698fe0-backoff.js +92 -0
- package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +393 -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/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 +41 -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 +255 -163
- 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/token-util-4a2xxnpg.js +0 -1
|
@@ -0,0 +1,354 @@
|
|
|
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
|
+
* 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.
|
|
93
|
+
*/
|
|
94
|
+
export class ClaustrumScopedCustody {
|
|
95
|
+
#client;
|
|
96
|
+
#readToken;
|
|
97
|
+
#now;
|
|
98
|
+
#family;
|
|
99
|
+
#parseIdentity;
|
|
100
|
+
#requireAssertion;
|
|
101
|
+
#logger;
|
|
102
|
+
#provenance = new WeakMap();
|
|
103
|
+
#reports = new WeakMap();
|
|
104
|
+
#closed = false;
|
|
105
|
+
constructor(options) {
|
|
106
|
+
const tokenPath = options.tokenPath;
|
|
107
|
+
if (options.readToken) {
|
|
108
|
+
this.#readToken = options.readToken;
|
|
109
|
+
}
|
|
110
|
+
else if (tokenPath) {
|
|
111
|
+
this.#readToken = () => readClaustrumEnrollmentToken(tokenPath);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
throw new ClaustrumConsumerError('not-enrolled', 'Claustrum enrollment token path is required');
|
|
115
|
+
}
|
|
116
|
+
this.#client = options.client;
|
|
117
|
+
this.#family = options.family;
|
|
118
|
+
this.#parseIdentity = options.parseIdentity;
|
|
119
|
+
this.#requireAssertion = options.requireAssertion ?? false;
|
|
120
|
+
this.#now = options.now ?? Date.now;
|
|
121
|
+
this.#logger = options.logger ?? defaultLogger;
|
|
122
|
+
}
|
|
123
|
+
#check(signal) {
|
|
124
|
+
if (this.#closed)
|
|
125
|
+
throw new ClaustrumConsumerError('closed', 'Claustrum scoped custody is closed');
|
|
126
|
+
signal?.throwIfAborted();
|
|
127
|
+
}
|
|
128
|
+
async #token(signal) {
|
|
129
|
+
this.#check(signal);
|
|
130
|
+
const value = await this.#readToken();
|
|
131
|
+
this.#check(signal);
|
|
132
|
+
if (!/^[0-9a-f]{64}$/.test(value.token) ||
|
|
133
|
+
!Number.isSafeInteger(value.token_generation) ||
|
|
134
|
+
value.token_generation < 1) {
|
|
135
|
+
throw new ClaustrumConsumerError('invalid-token', 'Invalid Claustrum enrollment token');
|
|
136
|
+
}
|
|
137
|
+
return value.token;
|
|
138
|
+
}
|
|
139
|
+
async #call(operation, signal) {
|
|
140
|
+
this.#check(signal);
|
|
141
|
+
let result;
|
|
142
|
+
let removeAbortListener;
|
|
143
|
+
try {
|
|
144
|
+
const pending = operation();
|
|
145
|
+
result = signal
|
|
146
|
+
? await Promise.race([
|
|
147
|
+
pending,
|
|
148
|
+
new Promise((_resolve, reject) => {
|
|
149
|
+
const abort = () => reject(signal.reason);
|
|
150
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
151
|
+
removeAbortListener = () => signal.removeEventListener('abort', abort);
|
|
152
|
+
if (signal.aborted)
|
|
153
|
+
abort();
|
|
154
|
+
}),
|
|
155
|
+
])
|
|
156
|
+
: await pending;
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
this.#check(signal);
|
|
160
|
+
// Keep the vault's own refusals (ClaustrumCredentialError, carrying
|
|
161
|
+
// code, class and action); replace any other error, whose text may echo
|
|
162
|
+
// request params that include the enrollment token.
|
|
163
|
+
if (error instanceof ClaustrumCredentialError)
|
|
164
|
+
throw error;
|
|
165
|
+
throw new ClaustrumConsumerError('unavailable', 'Claustrum scoped operation unavailable');
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
removeAbortListener?.();
|
|
169
|
+
}
|
|
170
|
+
this.#check(signal);
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* List this consumer's credentials. A record that cannot be used is skipped
|
|
175
|
+
* and warned about rather than failing the whole list, so one bad record
|
|
176
|
+
* never hides every other account. Records outside the family are not
|
|
177
|
+
* skipped records: they simply are not this consumer's.
|
|
178
|
+
*/
|
|
179
|
+
async discover(signal) {
|
|
180
|
+
const token = await this.#token(signal);
|
|
181
|
+
const inventory = await this.#call(() => this.#client.listScoped(token), signal);
|
|
182
|
+
const skipped = [];
|
|
183
|
+
const candidates = [];
|
|
184
|
+
const counts = new Map();
|
|
185
|
+
for (const row of inventory.rows) {
|
|
186
|
+
const type = credentialTypeOf(row, this.#family);
|
|
187
|
+
if (!type)
|
|
188
|
+
continue;
|
|
189
|
+
candidates.push({ row, type });
|
|
190
|
+
counts.set(row.id, (counts.get(row.id) ?? 0) + 1);
|
|
191
|
+
}
|
|
192
|
+
const credentials = [];
|
|
193
|
+
for (const { row, type } of candidates) {
|
|
194
|
+
let reason;
|
|
195
|
+
if (!row.id.trim())
|
|
196
|
+
reason = 'empty credential id';
|
|
197
|
+
else if ((counts.get(row.id) ?? 0) > 1)
|
|
198
|
+
reason = 'duplicate credential id';
|
|
199
|
+
else if (row.accountId !== undefined && !row.accountId.trim())
|
|
200
|
+
reason = 'blank account identity';
|
|
201
|
+
else if (!row.state.trim())
|
|
202
|
+
reason = 'empty state';
|
|
203
|
+
if (reason) {
|
|
204
|
+
skipped.push({ ...(row.id.trim() && { credentialId: row.id }), reason });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
credentials.push(Object.freeze({
|
|
208
|
+
credentialId: row.id,
|
|
209
|
+
credentialType: type,
|
|
210
|
+
...(row.accountId !== undefined && {
|
|
211
|
+
accountIdentity: row.accountId,
|
|
212
|
+
}),
|
|
213
|
+
state: row.state,
|
|
214
|
+
...(row.email !== undefined && { email: row.email }),
|
|
215
|
+
...(row.orgName !== undefined && { orgName: row.orgName }),
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
for (const record of skipped)
|
|
219
|
+
this.#logger.warn('skipped malformed vault record', record);
|
|
220
|
+
return {
|
|
221
|
+
view: inventory.view,
|
|
222
|
+
credentials: Object.freeze(credentials),
|
|
223
|
+
skipped: Object.freeze(skipped),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
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.
|
|
234
|
+
*/
|
|
235
|
+
async authorize(identity, signal) {
|
|
236
|
+
if (!identity.credentialId)
|
|
237
|
+
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum dispatch requires a credential id');
|
|
238
|
+
// Capture the caller's fields before yielding so a later mutation cannot move the fence.
|
|
239
|
+
const { credentialId, credentialType, accountIdentity } = identity;
|
|
240
|
+
if (this.#requireAssertion && accountIdentity === undefined)
|
|
241
|
+
throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum dispatch requires a known account identity');
|
|
242
|
+
const token = await this.#token(signal);
|
|
243
|
+
const served = await this.#call(() => this.#client.getScoped({
|
|
244
|
+
credentialId,
|
|
245
|
+
enrollmentToken: token,
|
|
246
|
+
...(credentialType === 'oauth' && { minTtlMs: SERVING_MARGIN_MS }),
|
|
247
|
+
}), signal);
|
|
248
|
+
if (served.credentialId !== undefined &&
|
|
249
|
+
served.credentialId !== credentialId) {
|
|
250
|
+
throw new ClaustrumConsumerError('identity-changed', 'Claustrum served credential identity changed');
|
|
251
|
+
}
|
|
252
|
+
const expiresAtMs = served.expiresAtMs;
|
|
253
|
+
if (!Number.isSafeInteger(served.recordVersion) ||
|
|
254
|
+
served.recordVersion < 0 ||
|
|
255
|
+
(expiresAtMs === null && credentialType === 'oauth') ||
|
|
256
|
+
(expiresAtMs !== null &&
|
|
257
|
+
(!Number.isFinite(expiresAtMs) ||
|
|
258
|
+
expiresAtMs - this.#now() < SERVING_MARGIN_MS))) {
|
|
259
|
+
throw new ClaustrumConsumerError('insufficient-validity', 'Claustrum served credential has insufficient validity');
|
|
260
|
+
}
|
|
261
|
+
const accessToken = accessTokenFromMaterial(served.material);
|
|
262
|
+
const assertedIdentity = served.accountId?.trim()
|
|
263
|
+
? served.accountId
|
|
264
|
+
: undefined;
|
|
265
|
+
if (this.#requireAssertion &&
|
|
266
|
+
(served.credentialId === undefined || assertedIdentity === undefined))
|
|
267
|
+
throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum served credential did not assert its identity');
|
|
268
|
+
const parsedIdentity = assertedIdentity === undefined
|
|
269
|
+
? this.#parseIdentity?.(accessToken)
|
|
270
|
+
: undefined;
|
|
271
|
+
const servedIdentity = assertedIdentity ?? parsedIdentity;
|
|
272
|
+
if (accountIdentity !== undefined &&
|
|
273
|
+
servedIdentity !== undefined &&
|
|
274
|
+
servedIdentity !== accountIdentity) {
|
|
275
|
+
throw new ClaustrumConsumerError('identity-changed', 'Claustrum served credential identity changed');
|
|
276
|
+
}
|
|
277
|
+
const resolvedIdentity = servedIdentity ?? accountIdentity;
|
|
278
|
+
const source = assertedIdentity !== undefined
|
|
279
|
+
? 'asserted'
|
|
280
|
+
: parsedIdentity !== undefined
|
|
281
|
+
? 'parsed'
|
|
282
|
+
: accountIdentity !== undefined
|
|
283
|
+
? 'expected'
|
|
284
|
+
: 'none';
|
|
285
|
+
const attempt = Object.freeze(Object.defineProperty({
|
|
286
|
+
credentialId,
|
|
287
|
+
credentialType,
|
|
288
|
+
...(resolvedIdentity !== undefined && {
|
|
289
|
+
accountIdentity: resolvedIdentity,
|
|
290
|
+
}),
|
|
291
|
+
accountIdentitySource: source,
|
|
292
|
+
...(accountIdentity !== undefined && {
|
|
293
|
+
expectedAccountIdentity: accountIdentity,
|
|
294
|
+
}),
|
|
295
|
+
...(served.credentialId !== undefined && {
|
|
296
|
+
assertedCredentialId: served.credentialId,
|
|
297
|
+
}),
|
|
298
|
+
...(assertedIdentity !== undefined && {
|
|
299
|
+
assertedAccountIdentity: assertedIdentity,
|
|
300
|
+
}),
|
|
301
|
+
...(served.projectId !== undefined && {
|
|
302
|
+
projectId: served.projectId,
|
|
303
|
+
}),
|
|
304
|
+
recordVersion: served.recordVersion,
|
|
305
|
+
expiresAtMs,
|
|
306
|
+
}, 'accessToken', { value: accessToken, enumerable: false }));
|
|
307
|
+
this.#provenance.set(attempt, token);
|
|
308
|
+
return attempt;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Report that a send this consumer actually made was rejected. Only a 401
|
|
312
|
+
* is reported (the vault refuses 429, 402 and 5xx reports), addressed by
|
|
313
|
+
* credential id with the exact record version that send was served, and
|
|
314
|
+
* signed with the enrollment token that authorized it. A receipt this
|
|
315
|
+
* custody did not issue is refused rather than reported.
|
|
316
|
+
*/
|
|
317
|
+
async reportFailure(attempt, status, reporterSource) {
|
|
318
|
+
if (status !== 401)
|
|
319
|
+
return;
|
|
320
|
+
this.#check();
|
|
321
|
+
const token = this.#provenance.get(attempt);
|
|
322
|
+
if (!token)
|
|
323
|
+
throw new ClaustrumConsumerError('no-receipt', 'Claustrum failure report requires an original dispatch receipt');
|
|
324
|
+
const pending = this.#reports.get(attempt);
|
|
325
|
+
if (pending)
|
|
326
|
+
return pending;
|
|
327
|
+
const report = this.#call(() => this.#client.reportAuthFailureScoped({
|
|
328
|
+
credentialId: attempt.credentialId,
|
|
329
|
+
enrollmentToken: token,
|
|
330
|
+
providerStatus: 401,
|
|
331
|
+
recordVersion: attempt.recordVersion,
|
|
332
|
+
reporterSource,
|
|
333
|
+
}));
|
|
334
|
+
this.#reports.set(attempt, report);
|
|
335
|
+
try {
|
|
336
|
+
await report;
|
|
337
|
+
this.#logger.debug('scoped 401 reported', {
|
|
338
|
+
credentialId: attempt.credentialId,
|
|
339
|
+
recordVersion: attempt.recordVersion,
|
|
340
|
+
reporterSource,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
this.#reports.delete(attempt);
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
close() {
|
|
349
|
+
if (this.#closed)
|
|
350
|
+
return;
|
|
351
|
+
this.#closed = true;
|
|
352
|
+
this.#client.close();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const NON_TRANSIENT_REFRESH_RETRY_DELAY_MS = 86400000;
|
|
3
|
+
const MIN_REFRESH_RETRY_DELAY_MS = 300000, MAX_REFRESH_RETRY_DELAY_MS = 3600000, MIN_QUOTA_RETRY_DELAY_MS = 60000, MAX_QUOTA_RETRY_DELAY_MS = 900000, NON_TRANSIENT_QUOTA_RETRY_DELAY_MS = 300000;
|
|
4
|
+
function formatErrorMessage(error) {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
export function isTransientRefreshError(error) {
|
|
8
|
+
const status = error?.status;
|
|
9
|
+
if (typeof status === "number")
|
|
10
|
+
return status === 429 || status >= 500;
|
|
11
|
+
if (!(error instanceof Error))
|
|
12
|
+
return !1;
|
|
13
|
+
return error.name === "AbortError" || error.name === "TimeoutError" || error.message.includes("fetch failed") || "code" in error && (error.code === "ECONNRESET" || error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT" || error.code === "UND_ERR_CONNECT_TIMEOUT");
|
|
14
|
+
}
|
|
15
|
+
export function isTransientQuotaError(error) {
|
|
16
|
+
const message = formatErrorMessage(error), status = error?.status;
|
|
17
|
+
if (typeof status === "number")
|
|
18
|
+
return status === 429 || status >= 500;
|
|
19
|
+
if (!(error instanceof Error))
|
|
20
|
+
return !1;
|
|
21
|
+
const code = error.code;
|
|
22
|
+
return error.name === "AbortError" || error.name === "TimeoutError" || message.includes("fetch failed") || code === "ECONNRESET" || code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT";
|
|
23
|
+
}
|
|
24
|
+
export function hashRefreshToken(refreshToken) {
|
|
25
|
+
return createHash("sha256").update(refreshToken).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
export function buildRefreshOperationError(input) {
|
|
28
|
+
const tokenHash = hashRefreshToken(input.refreshToken), retryCount = (input.previous?.tokenHash === tokenHash ? input.previous.retryCount ?? 0 : 0) + 1;
|
|
29
|
+
let delay;
|
|
30
|
+
const retryAfter = input.error?.retryAfter;
|
|
31
|
+
if (typeof retryAfter === "number" && retryAfter > 0)
|
|
32
|
+
delay = retryAfter * 1000;
|
|
33
|
+
else if (isTransientRefreshError(input.error))
|
|
34
|
+
delay = Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6));
|
|
35
|
+
else
|
|
36
|
+
delay = NON_TRANSIENT_REFRESH_RETRY_DELAY_MS;
|
|
37
|
+
return {
|
|
38
|
+
message: formatErrorMessage(input.error),
|
|
39
|
+
checkedAt: input.now,
|
|
40
|
+
nextRetryAt: input.now + delay,
|
|
41
|
+
retryCount,
|
|
42
|
+
tokenHash
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function refreshBackoffActive(error, refreshToken, now) {
|
|
46
|
+
if (!error?.nextRetryAt || error.nextRetryAt <= now)
|
|
47
|
+
return !1;
|
|
48
|
+
if (!refreshToken)
|
|
49
|
+
return !0;
|
|
50
|
+
return error.tokenHash === hashRefreshToken(refreshToken);
|
|
51
|
+
}
|
|
52
|
+
export function formatRefreshBackoffMessage(error, now) {
|
|
53
|
+
return `Codex OAuth refresh is backed off for ${Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000))}s after: ${error.message}`;
|
|
54
|
+
}
|
|
55
|
+
export function buildQuotaOperationError(input) {
|
|
56
|
+
const retryCount = (input.previous?.retryCount ?? 0) + 1;
|
|
57
|
+
let delay;
|
|
58
|
+
const retryAfter = input.error?.retryAfter;
|
|
59
|
+
if (typeof retryAfter === "number" && retryAfter > 0)
|
|
60
|
+
delay = retryAfter * 1000;
|
|
61
|
+
else if (isTransientQuotaError(input.error))
|
|
62
|
+
delay = Math.min(MAX_QUOTA_RETRY_DELAY_MS, MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6));
|
|
63
|
+
else
|
|
64
|
+
delay = NON_TRANSIENT_QUOTA_RETRY_DELAY_MS;
|
|
65
|
+
return {
|
|
66
|
+
message: formatErrorMessage(input.error),
|
|
67
|
+
checkedAt: input.now,
|
|
68
|
+
nextRetryAt: input.now + delay,
|
|
69
|
+
retryCount
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function quotaBackoffActive(error, now) {
|
|
73
|
+
if (!error?.nextRetryAt || error.nextRetryAt <= now)
|
|
74
|
+
return !1;
|
|
75
|
+
return !0;
|
|
76
|
+
}
|
|
77
|
+
export function formatQuotaBackoffMessage(error, now) {
|
|
78
|
+
return `Quota API backed off for ${Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000))}s after: ${error.message}`;
|
|
79
|
+
}
|
|
80
|
+
export function parseRetryAfter(value) {
|
|
81
|
+
if (!value)
|
|
82
|
+
return;
|
|
83
|
+
const seconds = Number(value);
|
|
84
|
+
if (Number.isFinite(seconds) && seconds > 0)
|
|
85
|
+
return Math.ceil(seconds);
|
|
86
|
+
const date = Date.parse(value);
|
|
87
|
+
if (Number.isFinite(date)) {
|
|
88
|
+
const delta = Math.ceil((date - Date.now()) / 1000);
|
|
89
|
+
return delta > 0 ? delta : void 0;
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|