@pure01fx/dsh-openai-codex-auth 0.7.3 → 0.9.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/lib/index.js CHANGED
@@ -39,6 +39,10 @@ const TOKEN_REF = credentialRef('DSH_OPENAI_CODEX_TOKEN');
39
39
  const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
40
40
  const MAX_ERROR_BODY_LENGTH = 1_024;
41
41
  const MAX_REQUEST_BODY_LENGTH = 8_192;
42
+ const ACCOUNT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
43
+ const TOKEN_REFRESH_REQUEST_TIMEOUT_MS = 30_000;
44
+ const ACCOUNT_REFRESH_LOCK_WAIT_MS = 35_000;
45
+ const CREDENTIAL_REFRESH_PERSIST_WAIT_MS = 500;
42
46
  class DeviceCodeUnavailableError extends Error {
43
47
  code = 'device_code_unavailable';
44
48
  }
@@ -46,12 +50,32 @@ class LoginConflictError extends Error {
46
50
  }
47
51
  class CredentialNotWritableError extends Error {
48
52
  }
53
+ class AccountNotFoundError extends Error {
54
+ }
55
+ class CredentialRefreshUncertainError extends Error {
56
+ constructor(cause) {
57
+ super('OpenAI credential refresh outcome is uncertain; sign in to this account again', { cause });
58
+ }
59
+ }
49
60
  const PERMANENT_REFRESH_CODES = new Set([
50
61
  'refresh_token_expired',
51
62
  'refresh_token_reused',
52
63
  'refresh_token_invalidated',
53
64
  'invalid_grant',
54
65
  ]);
66
+ const DEFINITIVE_OAUTH_REFRESH_CODES = new Set([
67
+ ...PERMANENT_REFRESH_CODES,
68
+ 'access_denied',
69
+ 'invalid_client',
70
+ 'invalid_request',
71
+ 'invalid_scope',
72
+ 'server_error',
73
+ 'slow_down',
74
+ 'temporarily_unavailable',
75
+ 'temporary_failure',
76
+ 'unauthorized_client',
77
+ 'unsupported_grant_type',
78
+ ]);
55
79
  class OAuthEndpointError extends Error {
56
80
  status;
57
81
  oauthCode;
@@ -68,6 +92,33 @@ function base64Url(value) {
68
92
  function messageOf(error) {
69
93
  return error instanceof Error ? error.message : String(error);
70
94
  }
95
+ function isTimeoutAbort(signal) {
96
+ return signal?.aborted === true
97
+ && signal.reason instanceof DOMException
98
+ && signal.reason.name === 'TimeoutError';
99
+ }
100
+ function boundedAccountUsageSignal(signal) {
101
+ const timeout = AbortSignal.timeout(ACCOUNT_USAGE_REQUEST_TIMEOUT_MS);
102
+ return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
103
+ }
104
+ function isFileLockTimeout(error) {
105
+ return error instanceof Error
106
+ && error.message.startsWith('atomic-write: timed out waiting for the writer lock at ');
107
+ }
108
+ function waitForSignal(promise, signal) {
109
+ if (signal === undefined)
110
+ return promise;
111
+ if (signal.aborted)
112
+ return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('Request cancelled'));
113
+ return new Promise((resolveWait, rejectWait) => {
114
+ const onAbort = () => {
115
+ signal.removeEventListener('abort', onAbort);
116
+ rejectWait(signal.reason instanceof Error ? signal.reason : new Error('Request cancelled'));
117
+ };
118
+ signal.addEventListener('abort', onAbort, { once: true });
119
+ promise.then((value) => { signal.removeEventListener('abort', onAbort); resolveWait(value); }, (error) => { signal.removeEventListener('abort', onAbort); rejectWait(error); });
120
+ });
121
+ }
71
122
  function throwIfCancelled(signal) {
72
123
  if (!signal?.aborted)
73
124
  return;
@@ -124,28 +175,76 @@ function parseTokenResponse(value, previous) {
124
175
  : chatGptAccountId(idToken, 'ID token');
125
176
  if (resolvedAccountId === undefined)
126
177
  throw new Error('OpenAI token response has no ID token');
127
- return { access: value.access_token, refresh, expires, accountId: resolvedAccountId };
178
+ const emailClaim = idToken === undefined ? undefined : jwtPayload(idToken, 'ID token').email;
179
+ const email = typeof emailClaim === 'string' && emailClaim.trim() !== ''
180
+ ? emailClaim.trim()
181
+ : previous?.email;
182
+ return {
183
+ access: value.access_token,
184
+ refresh,
185
+ expires,
186
+ accountId: resolvedAccountId,
187
+ ...email === undefined ? {} : { email },
188
+ };
128
189
  }
129
190
  function isPermanentRefreshError(error) {
130
191
  return error instanceof OAuthEndpointError
131
192
  && (error.status === 401
132
193
  || (error.oauthCode !== undefined && PERMANENT_REFRESH_CODES.has(error.oauthCode.toLowerCase())));
133
194
  }
134
- function parseCredential(text, filename) {
135
- const value = JSON.parse(text);
136
- const credential = value.credential;
137
- if (value.version !== 1 || credential === undefined
195
+ function isDefinitiveOAuthRefreshError(error) {
196
+ return error instanceof OAuthEndpointError
197
+ && (error.status === 400 || error.status === 401)
198
+ && error.oauthCode !== undefined
199
+ && DEFINITIVE_OAUTH_REFRESH_CODES.has(error.oauthCode.toLowerCase());
200
+ }
201
+ function validateCredential(value, filename) {
202
+ const credential = value;
203
+ if (credential === null || typeof credential !== 'object'
138
204
  || typeof credential.access !== 'string' || credential.access.length === 0
139
205
  || typeof credential.refresh !== 'string' || credential.refresh.length === 0
140
206
  || typeof credential.expires !== 'number' || !Number.isFinite(credential.expires)
141
- || typeof credential.accountId !== 'string' || credential.accountId.length === 0) {
207
+ || typeof credential.accountId !== 'string' || credential.accountId.length === 0
208
+ || credential.accountId.length > 512
209
+ || (credential.email !== undefined && (typeof credential.email !== 'string' || credential.email.length > 512))) {
142
210
  throw new Error(`openai-codex-auth: invalid credential document ${filename}`);
143
211
  }
144
- return credential;
212
+ return {
213
+ access: credential.access,
214
+ refresh: credential.refresh,
215
+ expires: credential.expires,
216
+ accountId: credential.accountId,
217
+ ...credential.email === undefined ? {} : { email: credential.email },
218
+ };
145
219
  }
146
- async function readCredential(filename) {
220
+ function parseCredentialDocument(text, filename) {
221
+ const value = JSON.parse(text);
222
+ if (value.version === 1) {
223
+ const credential = validateCredential(value.credential, filename);
224
+ return {
225
+ document: { version: 2, currentAccountId: credential.accountId, accounts: [credential] },
226
+ migrated: true,
227
+ };
228
+ }
229
+ if (value.version !== 2 || !Array.isArray(value.accounts)
230
+ || (value.currentAccountId !== null && typeof value.currentAccountId !== 'string')) {
231
+ throw new Error(`openai-codex-auth: invalid credential document ${filename}`);
232
+ }
233
+ const accounts = value.accounts.map(account => validateCredential(account, filename));
234
+ const ids = new Set(accounts.map(account => account.accountId));
235
+ if (ids.size !== accounts.length
236
+ || (accounts.length === 0 && value.currentAccountId !== null)
237
+ || (accounts.length > 0 && (value.currentAccountId === null || !ids.has(value.currentAccountId)))) {
238
+ throw new Error(`openai-codex-auth: invalid credential document ${filename}`);
239
+ }
240
+ return {
241
+ document: { version: 2, currentAccountId: value.currentAccountId, accounts },
242
+ migrated: false,
243
+ };
244
+ }
245
+ async function readCredentialDocument(filename) {
147
246
  try {
148
- return parseCredential(await readFile(filename, 'utf8'), filename);
247
+ return parseCredentialDocument(await readFile(filename, 'utf8'), filename);
149
248
  }
150
249
  catch (error) {
151
250
  if (error.code === 'ENOENT')
@@ -153,6 +252,11 @@ async function readCredential(filename) {
153
252
  throw error;
154
253
  }
155
254
  }
255
+ function currentCredential(document) {
256
+ if (document?.currentAccountId === null || document === undefined)
257
+ return undefined;
258
+ return document.accounts.find(account => account.accountId === document.currentAccountId);
259
+ }
156
260
  async function responseText(response) {
157
261
  return (await response.text().catch(() => '')).slice(0, MAX_ERROR_BODY_LENGTH);
158
262
  }
@@ -209,7 +313,7 @@ async function tokenRequest(init, previous, signal) {
209
313
  }
210
314
  catch (error) {
211
315
  if (signal?.aborted)
212
- throw new Error('OpenAI login cancelled');
316
+ throw new Error(isTimeoutAbort(signal) ? 'OpenAI token request timed out' : 'OpenAI login cancelled');
213
317
  throw error;
214
318
  }
215
319
  if (!response.ok) {
@@ -573,6 +677,8 @@ function resolveCodexRouteStatus(config, llm) {
573
677
  }
574
678
  export const internals = {
575
679
  parseTokenResponse,
680
+ parseCredentialDocument,
681
+ currentCredential,
576
682
  parseAuthority,
577
683
  isLoopbackHostname,
578
684
  isTrustedHost,
@@ -604,6 +710,8 @@ export class OpenAICodexAuth extends Service {
604
710
  usageError;
605
711
  usageRefresh;
606
712
  usageGeneration = 0;
713
+ accountUsageRequestGeneration = 0;
714
+ accountUsageCredentialRefreshes = new Map();
607
715
  directUsageSequence = 0;
608
716
  directUsageAccountId;
609
717
  usageHasDirectDefault = false;
@@ -703,6 +811,9 @@ export class OpenAICodexAuth extends Service {
703
811
  register('/openai-codex/browser/prepare', (req, res) => this.handleBrowserPrepare(req, res));
704
812
  register('/openai-codex/browser/complete', (req, res) => this.handleBrowserComplete(req, res));
705
813
  register('/openai-codex/cancel', (req, res) => this.handleCancel(req, res));
814
+ register('/openai-codex/accounts/current', (req, res) => this.handleSetCurrentAccount(req, res));
815
+ register('/openai-codex/accounts/usage', (req, res) => this.handleAccountUsage(req, res));
816
+ register('/openai-codex/accounts/logout', (req, res) => this.handleAccountLogout(req, res));
706
817
  register('/openai-codex/logout', (req, res) => this.handleLogout(req, res));
707
818
  }
708
819
  catch (error) {
@@ -747,7 +858,7 @@ export class OpenAICodexAuth extends Service {
747
858
  return;
748
859
  this.responseUsage = {
749
860
  accountId: observation.accountId,
750
- amount: observation.metadata.amount,
861
+ metadata: observation.metadata,
751
862
  observedAt: Date.now(),
752
863
  };
753
864
  }
@@ -768,8 +879,7 @@ export class OpenAICodexAuth extends Service {
768
879
  const sameUsageAccount = this.usageAccountId === accountId;
769
880
  if (!sameUsageAccount)
770
881
  this.usageHasDirectDefault = false;
771
- if (hasDefaultQuota)
772
- this.usageGeneration += 1;
882
+ this.usageGeneration += 1;
773
883
  this.usageCache = mergeDirectUsage(sameUsageAccount ? this.usageCache : undefined, accepted);
774
884
  this.usageAccountId = accountId;
775
885
  this.usageError = undefined;
@@ -802,7 +912,7 @@ export class OpenAICodexAuth extends Service {
802
912
  }
803
913
  async performUsageRefresh(generation) {
804
914
  try {
805
- const credential = await readCredential(this.filename);
915
+ const credential = await this.managedCredential();
806
916
  if (credential === undefined) {
807
917
  if (this.usageGeneration === generation) {
808
918
  this.usageCache = undefined;
@@ -900,72 +1010,124 @@ export class OpenAICodexAuth extends Service {
900
1010
  }
901
1011
  throw failure;
902
1012
  }
903
- async commitCredential(credential) {
1013
+ async commitDocument(document, signal) {
1014
+ const nextToken = currentCredential(document)?.access;
904
1015
  const previous = await this.ctx.credentials.resolve(TOKEN_REF);
905
- try {
906
- await this.ctx.credentials.set(TOKEN_REF, credential.access);
907
- }
908
- catch (error) {
909
- let current;
1016
+ throwIfCancelled(signal);
1017
+ if (previous?.value !== nextToken) {
910
1018
  try {
911
- current = await this.ctx.credentials.resolve(TOKEN_REF);
912
- }
913
- catch {
914
- return this.failAfterPublicationRollback(previous, credential.access, error);
1019
+ if (nextToken === undefined)
1020
+ await this.ctx.credentials.unset(TOKEN_REF);
1021
+ else
1022
+ await this.ctx.credentials.set(TOKEN_REF, nextToken);
915
1023
  }
916
- if (current?.value === credential.access) {
917
- return this.failAfterPublicationRollback(previous, credential.access, error);
1024
+ catch (error) {
1025
+ let current;
1026
+ try {
1027
+ current = await this.ctx.credentials.resolve(TOKEN_REF);
1028
+ }
1029
+ catch {
1030
+ return this.failAfterPublicationRollback(previous, nextToken, error);
1031
+ }
1032
+ if (current?.value === nextToken) {
1033
+ return this.failAfterPublicationRollback(previous, nextToken, error);
1034
+ }
1035
+ if (current?.value !== previous?.value)
1036
+ throw this.publicationChangedError(error);
1037
+ throw error;
918
1038
  }
919
- if (current?.value !== previous?.value)
920
- throw this.publicationChangedError(error);
921
- throw error;
922
1039
  }
923
1040
  try {
924
- await this.write(credential);
1041
+ await this.write(document);
925
1042
  }
926
1043
  catch (error) {
927
- return this.failAfterPublicationRollback(previous, credential.access, error);
1044
+ if (previous?.value === nextToken)
1045
+ throw error;
1046
+ return this.failAfterPublicationRollback(previous, nextToken, error);
928
1047
  }
929
1048
  }
930
- async resolveManagedCredentialLocked(signal) {
931
- throwIfCancelled(signal);
932
- const current = await readCredential(this.filename);
1049
+ upsertCurrentCredential(document, credential, replacedAccountId = credential.accountId) {
1050
+ const existingIndex = document.accounts.findIndex(account => account.accountId === replacedAccountId);
1051
+ const accounts = document.accounts.filter(account => account.accountId !== credential.accountId);
1052
+ const filteredIndex = accounts.findIndex(account => account.accountId === replacedAccountId);
1053
+ if (filteredIndex >= 0)
1054
+ accounts.splice(filteredIndex, 1);
1055
+ const insertion = existingIndex < 0 ? accounts.length : Math.min(existingIndex, accounts.length);
1056
+ accounts.splice(insertion, 0, credential);
1057
+ return { version: 2, currentAccountId: credential.accountId, accounts };
1058
+ }
1059
+ async commitCredential(credential) {
1060
+ const parsed = await readCredentialDocument(this.filename);
1061
+ const document = parsed?.document ?? { version: 2, currentAccountId: null, accounts: [] };
1062
+ await this.commitDocument(this.upsertCurrentCredential(document, credential));
1063
+ }
1064
+ /** Return the current managed bearer token, refreshing and migrating it when needed. */
1065
+ async bearerToken(signal) {
933
1066
  throwIfCancelled(signal);
934
- if (current === undefined) {
935
- this.setCredentialAccount(undefined);
936
- return undefined;
937
- }
938
- if (current.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS) {
939
- await this.publishCredentialToken(current.access, signal);
940
- this.setCredentialAccount(current.accountId);
941
- return current;
942
- }
943
- await this.assertCredentialWritable();
944
- let next;
945
- try {
946
- next = await refreshToken(current, signal);
947
- }
948
- catch (error) {
949
- throwIfCancelled(signal);
950
- if (!isPermanentRefreshError(error) && current.expires > Date.now()) {
951
- await this.publishCredentialToken(current.access, signal);
952
- this.setCredentialAccount(current.accountId);
953
- return current;
1067
+ while (true) {
1068
+ const prepared = await withFileLock(this.filename, async () => {
1069
+ throwIfCancelled(signal);
1070
+ const parsed = await readCredentialDocument(this.filename);
1071
+ const document = parsed?.document;
1072
+ const current = currentCredential(document);
1073
+ if (current === undefined) {
1074
+ this.setCredentialAccount(undefined);
1075
+ return undefined;
1076
+ }
1077
+ if (current.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS) {
1078
+ if (parsed?.migrated)
1079
+ await this.commitDocument(document, signal);
1080
+ else
1081
+ await this.publishCredentialToken(current.access, signal);
1082
+ this.setCredentialAccount(current.accountId);
1083
+ return { credential: current, ready: true };
1084
+ }
1085
+ return { credential: current, ready: false };
1086
+ });
1087
+ if (prepared === undefined)
1088
+ return undefined;
1089
+ if (prepared.ready)
1090
+ return prepared.credential.access;
1091
+ let refreshed;
1092
+ try {
1093
+ refreshed = await waitForSignal(this.refreshManagedAccount(prepared.credential, true), signal);
954
1094
  }
955
- throw error;
1095
+ catch (error) {
1096
+ throwIfCancelled(signal);
1097
+ throw error;
1098
+ }
1099
+ if (refreshed === undefined)
1100
+ continue;
1101
+ const access = await withFileLock(this.filename, async () => {
1102
+ throwIfCancelled(signal);
1103
+ const parsed = await readCredentialDocument(this.filename);
1104
+ const document = parsed?.document;
1105
+ const current = currentCredential(document);
1106
+ if (current === undefined || current.accountId !== prepared.credential.accountId)
1107
+ return undefined;
1108
+ if (current.expires <= Date.now())
1109
+ throw new Error('OpenAI Codex access token has expired');
1110
+ if (parsed?.migrated)
1111
+ await this.commitDocument(document, signal);
1112
+ else
1113
+ await this.publishCredentialToken(current.access, signal);
1114
+ this.setCredentialAccount(current.accountId);
1115
+ return current.access;
1116
+ });
1117
+ if (access !== undefined)
1118
+ return access;
956
1119
  }
957
- throwIfCancelled(signal);
958
- await this.commitCredential(next);
959
- this.setCredentialAccount(next.accountId);
960
- return next;
961
1120
  }
962
- /** Return a valid managed bearer token, refreshing and persisting it when near expiry. */
963
- async bearerToken(signal) {
964
- throwIfCancelled(signal);
965
- return withFileLock(this.filename, async () => {
966
- const credential = await this.resolveManagedCredentialLocked(signal);
967
- return credential?.access;
968
- });
1121
+ async managedCredential(signal) {
1122
+ while (true) {
1123
+ const access = await this.bearerToken(signal);
1124
+ if (access === undefined)
1125
+ return undefined;
1126
+ const current = currentCredential((await readCredentialDocument(this.filename))?.document);
1127
+ if (current?.access === access)
1128
+ return current;
1129
+ throwIfCancelled(signal);
1130
+ }
969
1131
  }
970
1132
  externalNativeCredential(accessToken) {
971
1133
  let accountId;
@@ -985,23 +1147,20 @@ export class OpenAICodexAuth extends Service {
985
1147
  async resolveNativeCredential(signal) {
986
1148
  try {
987
1149
  throwIfCancelled(signal);
988
- return await withFileLock(this.filename, async () => {
1150
+ const managed = await this.managedCredential(signal);
1151
+ if (managed !== undefined) {
989
1152
  throwIfCancelled(signal);
990
- const managed = await this.resolveManagedCredentialLocked(signal);
991
- if (managed !== undefined) {
992
- throwIfCancelled(signal);
993
- return { accessToken: managed.access, accountId: managed.accountId };
994
- }
995
- const external = await this.ctx.credentials.resolve(TOKEN_REF);
996
- throwIfCancelled(signal);
997
- if (external === undefined) {
998
- throw new LlmError('native Codex credential is not configured', 'MISSING_CREDENTIAL');
999
- }
1000
- const credential = this.externalNativeCredential(external.value);
1001
- throwIfCancelled(signal);
1002
- this.setCredentialAccount(credential.accountId);
1003
- return credential;
1004
- });
1153
+ return { accessToken: managed.access, accountId: managed.accountId };
1154
+ }
1155
+ const external = await this.ctx.credentials.resolve(TOKEN_REF);
1156
+ throwIfCancelled(signal);
1157
+ if (external === undefined) {
1158
+ throw new LlmError('native Codex credential is not configured', 'MISSING_CREDENTIAL');
1159
+ }
1160
+ const credential = this.externalNativeCredential(external.value);
1161
+ throwIfCancelled(signal);
1162
+ this.setCredentialAccount(credential.accountId);
1163
+ return credential;
1005
1164
  }
1006
1165
  catch (error) {
1007
1166
  if (error instanceof LlmError)
@@ -1013,9 +1172,12 @@ export class OpenAICodexAuth extends Service {
1013
1172
  }
1014
1173
  }
1015
1174
  nativeRecoveryError(error) {
1175
+ const cause = error instanceof CredentialRefreshUncertainError && error.cause !== undefined
1176
+ ? error.cause
1177
+ : error;
1016
1178
  const options = {
1017
- cause: error,
1018
- ...(error instanceof OAuthEndpointError ? { status: error.status } : {}),
1179
+ cause,
1180
+ ...(cause instanceof OAuthEndpointError ? { status: cause.status } : {}),
1019
1181
  };
1020
1182
  if (error instanceof CredentialNotWritableError || isPermanentRefreshError(error)) {
1021
1183
  return new LlmError('native Codex managed credential cannot be refreshed', INVALID_CREDENTIAL_CODE, options);
@@ -1025,35 +1187,46 @@ export class OpenAICodexAuth extends Service {
1025
1187
  async recoverNativeCredential(previous, signal) {
1026
1188
  try {
1027
1189
  throwIfCancelled(signal);
1028
- return await withFileLock(this.filename, async () => {
1029
- throwIfCancelled(signal);
1030
- const current = await readCredential(this.filename);
1190
+ const prepared = await withFileLock(this.filename, async () => {
1031
1191
  throwIfCancelled(signal);
1192
+ const current = currentCredential((await readCredentialDocument(this.filename))?.document);
1032
1193
  if (current === undefined) {
1033
1194
  const external = await this.ctx.credentials.resolve(TOKEN_REF);
1034
1195
  throwIfCancelled(signal);
1035
1196
  if (external === undefined)
1036
- return false;
1197
+ return { result: false };
1037
1198
  const credential = this.externalNativeCredential(external.value);
1038
1199
  throwIfCancelled(signal);
1200
+ if (credential.accountId !== previous.accountId)
1201
+ return { result: false };
1039
1202
  this.setCredentialAccount(credential.accountId);
1040
- return credential.accessToken !== previous.accessToken
1041
- || credential.accountId !== previous.accountId;
1203
+ return { result: credential.accessToken !== previous.accessToken };
1042
1204
  }
1043
- if (current.access !== previous.accessToken || current.accountId !== previous.accountId) {
1205
+ // A request that started under A must never recover by replaying under B.
1206
+ if (current.accountId !== previous.accountId)
1207
+ return { result: false };
1208
+ if (current.access !== previous.accessToken) {
1044
1209
  await this.publishCredentialToken(current.access, signal);
1045
1210
  throwIfCancelled(signal);
1046
1211
  this.setCredentialAccount(current.accountId);
1047
- return true;
1212
+ return { result: true };
1048
1213
  }
1049
- await this.assertCredentialWritable();
1050
- throwIfCancelled(signal);
1051
- const next = await refreshToken(current, signal);
1214
+ return { credential: current };
1215
+ });
1216
+ if (prepared.result !== undefined)
1217
+ return prepared.result;
1218
+ const refreshed = await waitForSignal(this.refreshManagedAccount(prepared.credential, true, true), signal);
1219
+ if (refreshed === undefined)
1220
+ return false;
1221
+ return withFileLock(this.filename, async () => {
1052
1222
  throwIfCancelled(signal);
1053
- await this.commitCredential(next);
1223
+ const current = currentCredential((await readCredentialDocument(this.filename))?.document);
1224
+ if (current === undefined || current.accountId !== previous.accountId)
1225
+ return false;
1226
+ await this.publishCredentialToken(current.access, signal);
1054
1227
  throwIfCancelled(signal);
1055
- this.setCredentialAccount(next.accountId);
1056
- return next.access !== previous.accessToken || next.accountId !== previous.accountId;
1228
+ this.setCredentialAccount(current.accountId);
1229
+ return current.access !== previous.accessToken;
1057
1230
  });
1058
1231
  }
1059
1232
  catch (error) {
@@ -1068,18 +1241,22 @@ export class OpenAICodexAuth extends Service {
1068
1241
  async finishCredential(credential, signal) {
1069
1242
  await this.assertCredentialWritable();
1070
1243
  throwIfCancelled(signal);
1071
- await withFileLock(this.filename, async () => {
1244
+ await this.withAccountRefreshLock(credential.accountId, async () => {
1072
1245
  throwIfCancelled(signal);
1073
- await this.commitCredential(credential);
1074
- this.setCredentialAccount(credential.accountId);
1075
- this.usageGeneration += 1;
1076
- if (this.usageRefresh !== undefined)
1077
- this.usageRefresh.queued = true;
1078
- this.usageCache = undefined;
1079
- this.usageAccountId = undefined;
1080
- this.directUsageAccountId = undefined;
1081
- this.usageHasDirectDefault = false;
1082
- this.usageError = undefined;
1246
+ await withFileLock(this.filename, async () => {
1247
+ throwIfCancelled(signal);
1248
+ await this.commitCredential(credential);
1249
+ this.setCredentialAccount(credential.accountId);
1250
+ this.usageGeneration += 1;
1251
+ if (this.usageRefresh !== undefined)
1252
+ this.usageRefresh.queued = true;
1253
+ this.usageCache = undefined;
1254
+ this.usageAccountId = undefined;
1255
+ this.directUsageAccountId = undefined;
1256
+ this.usageHasDirectDefault = false;
1257
+ this.usageError = undefined;
1258
+ });
1259
+ await this.clearAccountRefreshJournal(credential.accountId);
1083
1260
  });
1084
1261
  this.lastLoginError = undefined;
1085
1262
  }
@@ -1296,53 +1473,116 @@ export class OpenAICodexAuth extends Service {
1296
1473
  if (clearError)
1297
1474
  this.lastLoginError = undefined;
1298
1475
  }
1299
- async logout() {
1300
- await this.cancelLogin(true);
1301
- await withFileLock(this.filename, async () => {
1302
- const previous = await this.ctx.credentials.resolve(TOKEN_REF);
1476
+ resetCurrentAccountState(accountId) {
1477
+ this.setCredentialAccount(accountId);
1478
+ this.usageGeneration += 1;
1479
+ if (this.usageRefresh !== undefined)
1480
+ this.usageRefresh.queued = accountId !== undefined;
1481
+ this.usageCache = undefined;
1482
+ this.usageAccountId = undefined;
1483
+ this.directUsageAccountId = undefined;
1484
+ this.usageHasDirectDefault = false;
1485
+ this.usageError = undefined;
1486
+ this.lastLoginError = undefined;
1487
+ }
1488
+ async setCurrentAccount(accountId) {
1489
+ await this.withAccountRefreshLock(accountId, async () => {
1490
+ await this.reconcileAccountRefreshJournal(accountId);
1491
+ await withFileLock(this.filename, async () => {
1492
+ const document = (await readCredentialDocument(this.filename))?.document;
1493
+ if (document === undefined || !document.accounts.some(account => account.accountId === accountId)) {
1494
+ throw new AccountNotFoundError('OpenAI Codex account was not found');
1495
+ }
1496
+ if (document.currentAccountId !== accountId) {
1497
+ await this.assertCredentialWritable();
1498
+ await this.commitDocument({ ...document, currentAccountId: accountId });
1499
+ }
1500
+ this.resetCurrentAccountState(accountId);
1501
+ });
1502
+ });
1503
+ }
1504
+ async logoutAttempt(accountId, expectedPromotion) {
1505
+ return withFileLock(this.filename, async () => {
1506
+ let parsed;
1303
1507
  try {
1304
- await this.ctx.credentials.unset(TOKEN_REF);
1508
+ parsed = await readCredentialDocument(this.filename);
1305
1509
  }
1306
1510
  catch (error) {
1307
- let current;
1511
+ if (accountId !== undefined)
1512
+ throw error;
1513
+ const previous = await this.ctx.credentials.resolve(TOKEN_REF);
1308
1514
  try {
1309
- current = await this.ctx.credentials.resolve(TOKEN_REF);
1515
+ if (previous !== undefined)
1516
+ await this.ctx.credentials.unset(TOKEN_REF);
1517
+ await unlink(this.filename);
1310
1518
  }
1311
- catch {
1312
- return this.failAfterPublicationRollback(previous, undefined, error);
1519
+ catch (failure) {
1520
+ return this.failAfterPublicationRollback(previous, undefined, failure);
1313
1521
  }
1314
- if (current === undefined) {
1315
- return this.failAfterPublicationRollback(previous, undefined, error);
1316
- }
1317
- if (current.value !== previous?.value)
1318
- throw this.publicationChangedError(error);
1319
- throw error;
1522
+ this.resetCurrentAccountState(undefined);
1523
+ return true;
1320
1524
  }
1321
- try {
1322
- await unlink(this.filename);
1525
+ const document = parsed?.document;
1526
+ const target = accountId ?? document?.currentAccountId ?? undefined;
1527
+ if (document === undefined || target === undefined
1528
+ || !document.accounts.some(account => account.accountId === target)) {
1529
+ if (accountId !== undefined)
1530
+ throw new AccountNotFoundError('OpenAI Codex account was not found');
1531
+ return true;
1323
1532
  }
1324
- catch (error) {
1325
- if (error.code !== 'ENOENT') {
1326
- return this.failAfterPublicationRollback(previous, undefined, error);
1533
+ const removedCurrent = document.currentAccountId === target;
1534
+ const targetIndex = document.accounts.findIndex(account => account.accountId === target);
1535
+ const accounts = document.accounts.filter(account => account.accountId !== target);
1536
+ const currentAccountId = removedCurrent
1537
+ ? accounts[targetIndex]?.accountId ?? accounts[0]?.accountId ?? null
1538
+ : document.currentAccountId;
1539
+ const promotion = removedCurrent ? currentAccountId ?? undefined : undefined;
1540
+ if (promotion !== expectedPromotion)
1541
+ return false;
1542
+ if (removedCurrent)
1543
+ await this.assertCredentialWritable();
1544
+ await this.commitDocument({ version: 2, currentAccountId, accounts });
1545
+ await this.clearAccountRefreshJournal(target);
1546
+ if (removedCurrent)
1547
+ this.resetCurrentAccountState(currentAccountId ?? undefined);
1548
+ return true;
1549
+ });
1550
+ }
1551
+ async logout(accountId) {
1552
+ if (accountId === undefined)
1553
+ await this.cancelLogin(true);
1554
+ while (true) {
1555
+ let target;
1556
+ let expectedPromotion;
1557
+ try {
1558
+ const document = (await readCredentialDocument(this.filename))?.document;
1559
+ target = accountId ?? document?.currentAccountId ?? undefined;
1560
+ if (document !== undefined && target !== undefined && document.currentAccountId === target) {
1561
+ const targetIndex = document.accounts.findIndex(account => account.accountId === target);
1562
+ const accounts = document.accounts.filter(account => account.accountId !== target);
1563
+ expectedPromotion = accounts[targetIndex]?.accountId ?? accounts[0]?.accountId;
1327
1564
  }
1328
1565
  }
1329
- });
1330
- this.setCredentialAccount(undefined);
1331
- this.usageGeneration += 1;
1332
- if (this.usageRefresh !== undefined)
1333
- this.usageRefresh.queued = false;
1334
- this.usageCache = undefined;
1335
- this.usageAccountId = undefined;
1336
- this.directUsageAccountId = undefined;
1337
- this.usageHasDirectDefault = false;
1338
- this.usageError = undefined;
1339
- this.lastLoginError = undefined;
1566
+ catch { }
1567
+ const lockIds = [target, expectedPromotion].filter((value) => value !== undefined);
1568
+ const done = lockIds.length === 0
1569
+ ? await this.logoutAttempt(accountId, undefined)
1570
+ : await this.withAccountRefreshLocks(lockIds, async () => {
1571
+ if (expectedPromotion !== undefined)
1572
+ await this.reconcileAccountRefreshJournal(expectedPromotion);
1573
+ return this.logoutAttempt(accountId, expectedPromotion);
1574
+ });
1575
+ if (done)
1576
+ return;
1577
+ }
1340
1578
  }
1341
1579
  async status(refresh, callbackUrl) {
1580
+ let document;
1342
1581
  let credential;
1343
1582
  let credentialError;
1344
1583
  try {
1345
- credential = await readCredential(this.filename);
1584
+ document = (await readCredentialDocument(this.filename))?.document;
1585
+ credential = currentCredential(document);
1346
1586
  }
1347
1587
  catch (error) {
1348
1588
  credentialError = messageOf(error);
@@ -1350,7 +1590,8 @@ export class OpenAICodexAuth extends Service {
1350
1590
  if (credential !== undefined) {
1351
1591
  try {
1352
1592
  await this.bearerToken();
1353
- credential = await readCredential(this.filename) ?? credential;
1593
+ document = (await readCredentialDocument(this.filename))?.document ?? document;
1594
+ credential = currentCredential(document) ?? credential;
1354
1595
  }
1355
1596
  catch (error) {
1356
1597
  this.usageError = messageOf(error);
@@ -1379,6 +1620,13 @@ export class OpenAICodexAuth extends Service {
1379
1620
  return {
1380
1621
  route: resolveCodexRouteStatus(this.routeConfig, this.ctx.get('llm')),
1381
1622
  loggedIn: credential !== undefined,
1623
+ currentAccountId: document?.currentAccountId ?? null,
1624
+ accounts: (document?.accounts ?? []).map(account => ({
1625
+ accountId: account.accountId,
1626
+ ...account.email === undefined ? {} : { email: account.email },
1627
+ expiresAt: account.expires,
1628
+ current: account.accountId === document?.currentAccountId,
1629
+ })),
1382
1630
  loginPending: startingMethod !== undefined || flow !== undefined,
1383
1631
  ...startingMethod !== undefined ? { loginMethod: startingMethod } : flow === undefined ? {} : { loginMethod: flow.kind },
1384
1632
  ...this.lastLoginError === undefined ? {} : { loginError: this.lastLoginError },
@@ -1388,12 +1636,13 @@ export class OpenAICodexAuth extends Service {
1388
1636
  ...browser === undefined ? {} : { browser },
1389
1637
  ...credential === undefined ? {} : {
1390
1638
  accountId: credential.accountId,
1639
+ ...credential.email === undefined ? {} : { email: credential.email },
1391
1640
  expiresAt: credential.expires,
1392
1641
  ...this.usageCache === undefined || this.usageAccountId !== credential.accountId
1393
1642
  ? {} : { usage: this.usageCache },
1394
1643
  ...this.responseUsage === undefined || this.responseUsage.accountId !== credential.accountId
1395
1644
  ? {} : { responseUsage: {
1396
- amount: this.responseUsage.amount,
1645
+ ...this.responseUsage.metadata,
1397
1646
  observedAt: this.responseUsage.observedAt,
1398
1647
  } },
1399
1648
  ...this.usageError === undefined ? {} : { usageError: this.usageError },
@@ -1401,25 +1650,316 @@ export class OpenAICodexAuth extends Service {
1401
1650
  csrf: this.csrf,
1402
1651
  };
1403
1652
  }
1404
- async fetchUsage(credential) {
1405
- const access = await this.bearerToken();
1406
- if (access === undefined)
1407
- throw new Error('OpenAI login is missing');
1408
- const response = await fetch(USAGE_URL, {
1409
- redirect: 'error',
1410
- headers: {
1411
- accept: 'application/json',
1412
- authorization: `Bearer ${access}`,
1413
- 'chatgpt-account-id': credential.accountId,
1414
- 'user-agent': 'dsh-openai-codex-auth/0.5.0',
1415
- },
1653
+ accountRefreshJournalFilename(accountId) {
1654
+ const key = createHash('sha256').update(accountId).digest('hex').slice(0, 24);
1655
+ return `${this.filename}.account-${key}-refresh.json`;
1656
+ }
1657
+ async readAccountRefreshJournal(accountId) {
1658
+ const filename = this.accountRefreshJournalFilename(accountId);
1659
+ let value;
1660
+ try {
1661
+ value = JSON.parse(await readFile(filename, 'utf8'));
1662
+ }
1663
+ catch (error) {
1664
+ if (error.code === 'ENOENT')
1665
+ return undefined;
1666
+ throw error;
1667
+ }
1668
+ const journal = value;
1669
+ if (journal === null || typeof journal !== 'object' || journal.version !== 1
1670
+ || journal.accountId !== accountId || typeof journal.startedAt !== 'number'
1671
+ || !Number.isFinite(journal.startedAt)
1672
+ || (journal.phase !== 'pending' && journal.phase !== 'uncertain' && journal.phase !== 'refreshed')) {
1673
+ throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
1674
+ }
1675
+ const stored = validateCredential(journal.stored, filename);
1676
+ if (stored.accountId !== accountId)
1677
+ throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
1678
+ if (journal.phase === 'refreshed') {
1679
+ const refreshed = validateCredential(journal.refreshed, filename);
1680
+ if (refreshed.accountId !== accountId)
1681
+ throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
1682
+ return { version: 1, accountId, startedAt: journal.startedAt, phase: 'refreshed', stored, refreshed };
1683
+ }
1684
+ return { version: 1, accountId, startedAt: journal.startedAt, phase: journal.phase, stored };
1685
+ }
1686
+ writeAccountRefreshJournal(journal) {
1687
+ return writeFileAtomic(this.accountRefreshJournalFilename(journal.accountId), JSON.stringify(journal), { mode: 0o600 });
1688
+ }
1689
+ async clearAccountRefreshJournal(accountId) {
1690
+ try {
1691
+ await unlink(this.accountRefreshJournalFilename(accountId));
1692
+ }
1693
+ catch (error) {
1694
+ if (error.code !== 'ENOENT')
1695
+ throw error;
1696
+ }
1697
+ }
1698
+ async removeDeadFileLock(filename) {
1699
+ const lockFilename = `${filename}.lock`;
1700
+ let owner;
1701
+ try {
1702
+ const text = (await readFile(lockFilename, 'utf8')).trim();
1703
+ owner = Number(text);
1704
+ if (!Number.isSafeInteger(owner) || owner <= 0)
1705
+ return false;
1706
+ }
1707
+ catch (error) {
1708
+ return error.code === 'ENOENT';
1709
+ }
1710
+ try {
1711
+ process.kill(owner, 0);
1712
+ return false;
1713
+ }
1714
+ catch (error) {
1715
+ if (error.code !== 'ESRCH')
1716
+ return false;
1717
+ try {
1718
+ await unlink(lockFilename);
1719
+ return true;
1720
+ }
1721
+ catch (unlinkError) {
1722
+ return unlinkError.code === 'ENOENT';
1723
+ }
1724
+ }
1725
+ }
1726
+ async withAccountRefreshLock(accountId, operation) {
1727
+ const filename = this.accountRefreshJournalFilename(accountId);
1728
+ await this.removeDeadFileLock(filename);
1729
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1730
+ try {
1731
+ return await withFileLock(filename, operation, { waitMs: ACCOUNT_REFRESH_LOCK_WAIT_MS });
1732
+ }
1733
+ catch (error) {
1734
+ if (!isFileLockTimeout(error) || attempt > 0 || !await this.removeDeadFileLock(filename))
1735
+ throw error;
1736
+ }
1737
+ }
1738
+ throw new Error('OpenAI credential refresh lock could not be acquired');
1739
+ }
1740
+ withAccountRefreshLocks(accountIds, operation) {
1741
+ const ordered = [...new Set(accountIds)].sort();
1742
+ const acquire = (index) => index === ordered.length
1743
+ ? operation()
1744
+ : this.withAccountRefreshLock(ordered[index], () => acquire(index + 1));
1745
+ return acquire(0);
1746
+ }
1747
+ async reconcileAccountRefreshJournal(accountId) {
1748
+ const journal = await this.readAccountRefreshJournal(accountId);
1749
+ if (journal === undefined)
1750
+ return undefined;
1751
+ const latest = (await readCredentialDocument(this.filename))?.document.accounts
1752
+ .find(account => account.accountId === accountId);
1753
+ if (latest === undefined
1754
+ || latest.access !== journal.stored.access
1755
+ || latest.refresh !== journal.stored.refresh
1756
+ || latest.expires !== journal.stored.expires) {
1757
+ await this.clearAccountRefreshJournal(accountId);
1758
+ return latest;
1759
+ }
1760
+ if (journal.phase !== 'refreshed')
1761
+ throw new CredentialRefreshUncertainError();
1762
+ try {
1763
+ const credential = await this.persistAccountUsageCredential(journal.stored, journal.refreshed);
1764
+ await this.clearAccountRefreshJournal(accountId);
1765
+ return credential;
1766
+ }
1767
+ catch (error) {
1768
+ if (error instanceof AccountNotFoundError)
1769
+ await this.clearAccountRefreshJournal(accountId);
1770
+ throw error;
1771
+ }
1772
+ }
1773
+ async refreshManagedAccount(stored, requireCurrent, force = false) {
1774
+ return this.withAccountRefreshLock(stored.accountId, async () => {
1775
+ const recovered = await this.reconcileAccountRefreshJournal(stored.accountId);
1776
+ if (recovered !== undefined)
1777
+ return recovered;
1778
+ const prepared = await withFileLock(this.filename, async () => {
1779
+ const document = (await readCredentialDocument(this.filename))?.document;
1780
+ const latest = document?.accounts.find(account => account.accountId === stored.accountId);
1781
+ if (document === undefined || latest === undefined)
1782
+ return undefined;
1783
+ if (requireCurrent && document.currentAccountId !== latest.accountId)
1784
+ return undefined;
1785
+ if (latest.access !== stored.access || latest.refresh !== stored.refresh || latest.expires !== stored.expires) {
1786
+ return { credential: latest, changed: true };
1787
+ }
1788
+ if ((force || latest.expires <= Date.now() + TOKEN_REFRESH_PREEMPT_MS)
1789
+ && document.currentAccountId === latest.accountId)
1790
+ await this.assertCredentialWritable();
1791
+ return { credential: latest, changed: false };
1792
+ });
1793
+ if (prepared === undefined || prepared.changed
1794
+ || (!force && prepared.credential.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS))
1795
+ return prepared?.credential;
1796
+ const credential = prepared.credential;
1797
+ const pending = {
1798
+ version: 1,
1799
+ accountId: credential.accountId,
1800
+ startedAt: Date.now(),
1801
+ phase: 'pending',
1802
+ stored: credential,
1803
+ };
1804
+ await this.writeAccountRefreshJournal(pending);
1805
+ let refreshed;
1806
+ try {
1807
+ refreshed = await refreshToken(credential, AbortSignal.timeout(TOKEN_REFRESH_REQUEST_TIMEOUT_MS));
1808
+ }
1809
+ catch (error) {
1810
+ if (isDefinitiveOAuthRefreshError(error)) {
1811
+ await this.clearAccountRefreshJournal(credential.accountId);
1812
+ if (!force && !isPermanentRefreshError(error) && credential.expires > Date.now())
1813
+ return credential;
1814
+ throw error;
1815
+ }
1816
+ await this.writeAccountRefreshJournal({ ...pending, phase: 'uncertain' });
1817
+ throw new CredentialRefreshUncertainError(error);
1818
+ }
1819
+ if (refreshed.accountId !== credential.accountId) {
1820
+ await this.writeAccountRefreshJournal({ ...pending, phase: 'uncertain' });
1821
+ throw new Error('OpenAI refresh changed the ChatGPT account identity');
1822
+ }
1823
+ await this.writeAccountRefreshJournal({ ...pending, phase: 'refreshed', refreshed });
1824
+ try {
1825
+ const persisted = await this.persistAccountUsageCredential(credential, refreshed);
1826
+ await this.clearAccountRefreshJournal(credential.accountId);
1827
+ return persisted;
1828
+ }
1829
+ catch (error) {
1830
+ if (error instanceof AccountNotFoundError)
1831
+ await this.clearAccountRefreshJournal(credential.accountId);
1832
+ throw error;
1833
+ }
1834
+ });
1835
+ }
1836
+ async persistAccountUsageCredential(stored, refreshed) {
1837
+ await this.removeDeadFileLock(this.filename);
1838
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1839
+ try {
1840
+ return await withFileLock(this.filename, async () => {
1841
+ const document = (await readCredentialDocument(this.filename))?.document;
1842
+ const latest = document?.accounts.find(account => account.accountId === stored.accountId);
1843
+ if (document === undefined || latest === undefined) {
1844
+ throw new AccountNotFoundError('OpenAI Codex account was removed while its quota was refreshing');
1845
+ }
1846
+ if (latest.access !== stored.access || latest.refresh !== stored.refresh || latest.expires !== stored.expires) {
1847
+ return latest;
1848
+ }
1849
+ const nextDocument = {
1850
+ ...document,
1851
+ accounts: document.accounts.map(account => account.accountId === stored.accountId ? refreshed : account),
1852
+ };
1853
+ if (document.currentAccountId === stored.accountId) {
1854
+ await this.assertCredentialWritable();
1855
+ await this.commitDocument(nextDocument);
1856
+ }
1857
+ else {
1858
+ await this.write(nextDocument);
1859
+ }
1860
+ return refreshed;
1861
+ }, { waitMs: CREDENTIAL_REFRESH_PERSIST_WAIT_MS });
1862
+ }
1863
+ catch (error) {
1864
+ if (!isFileLockTimeout(error) || attempt > 0 || !await this.removeDeadFileLock(this.filename))
1865
+ throw error;
1866
+ }
1867
+ }
1868
+ throw new Error('OpenAI rotated credential could not be persisted');
1869
+ }
1870
+ refreshAccountUsageCredential(stored) {
1871
+ const active = this.accountUsageCredentialRefreshes.get(stored.accountId);
1872
+ if (active !== undefined)
1873
+ return active;
1874
+ const operation = this.refreshManagedAccount(stored, false).then((credential) => {
1875
+ if (credential === undefined) {
1876
+ throw new AccountNotFoundError('OpenAI Codex account was removed while its quota was refreshing');
1877
+ }
1878
+ return credential;
1416
1879
  });
1880
+ let tracked;
1881
+ tracked = operation.finally(() => {
1882
+ if (this.accountUsageCredentialRefreshes.get(stored.accountId) === tracked) {
1883
+ this.accountUsageCredentialRefreshes.delete(stored.accountId);
1884
+ }
1885
+ });
1886
+ this.accountUsageCredentialRefreshes.set(stored.accountId, tracked);
1887
+ return tracked;
1888
+ }
1889
+ async accountUsageCredential(stored, signal) {
1890
+ if (stored.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS)
1891
+ return stored;
1892
+ return waitForSignal(this.refreshAccountUsageCredential(stored), signal);
1893
+ }
1894
+ async accountUsages(signal) {
1895
+ const prepared = await withFileLock(this.filename, async () => {
1896
+ const document = (await readCredentialDocument(this.filename))?.document;
1897
+ if (document === undefined)
1898
+ return undefined;
1899
+ return {
1900
+ cacheGeneration: this.usageGeneration,
1901
+ requestGeneration: ++this.accountUsageRequestGeneration,
1902
+ tasks: document.accounts.map(stored => ({
1903
+ accountId: stored.accountId,
1904
+ credential: this.accountUsageCredential(stored, signal),
1905
+ })),
1906
+ };
1907
+ });
1908
+ if (prepared === undefined)
1909
+ return [];
1910
+ const results = await Promise.all(prepared.tasks.map(async (task) => {
1911
+ try {
1912
+ const credential = await task.credential;
1913
+ return {
1914
+ accountId: task.accountId,
1915
+ usage: await this.fetchUsage(credential, boundedAccountUsageSignal(signal)),
1916
+ };
1917
+ }
1918
+ catch (error) {
1919
+ return { accountId: task.accountId, error: messageOf(error) };
1920
+ }
1921
+ }));
1922
+ if (!signal?.aborted) {
1923
+ const currentAccountId = (await readCredentialDocument(this.filename))?.document.currentAccountId;
1924
+ const current = results.find(row => row.accountId === currentAccountId);
1925
+ if (current?.usage !== undefined
1926
+ && this.usageGeneration === prepared.cacheGeneration
1927
+ && this.accountUsageRequestGeneration === prepared.requestGeneration) {
1928
+ this.usageGeneration += 1;
1929
+ this.usageCache = current.usage;
1930
+ this.usageAccountId = currentAccountId ?? undefined;
1931
+ this.usageHasDirectDefault = false;
1932
+ this.usageError = undefined;
1933
+ }
1934
+ }
1935
+ return results;
1936
+ }
1937
+ async fetchUsage(credential, signal) {
1938
+ let response;
1939
+ try {
1940
+ response = await fetch(USAGE_URL, {
1941
+ redirect: 'error',
1942
+ ...signal === undefined ? {} : { signal },
1943
+ headers: {
1944
+ accept: 'application/json',
1945
+ authorization: `Bearer ${credential.access}`,
1946
+ 'chatgpt-account-id': credential.accountId,
1947
+ 'user-agent': 'dsh-openai-codex-auth/0.5.0',
1948
+ },
1949
+ });
1950
+ }
1951
+ catch (error) {
1952
+ if (signal?.aborted) {
1953
+ throw new Error(isTimeoutAbort(signal) ? 'Codex usage request timed out' : 'Codex usage request cancelled');
1954
+ }
1955
+ throw error;
1956
+ }
1417
1957
  if (!response.ok)
1418
1958
  throw new Error(`Codex usage request failed (HTTP ${response.status})`);
1419
1959
  return normalizeUsage(await response.json());
1420
1960
  }
1421
- write(credential) {
1422
- return writeFileAtomic(this.filename, `${JSON.stringify({ version: 1, credential }, null, 2)}\n`, {
1961
+ write(document) {
1962
+ return writeFileAtomic(this.filename, `${JSON.stringify(document, null, 2)}\n`, {
1423
1963
  mode: 0o600, dirMode: 0o700,
1424
1964
  });
1425
1965
  }
@@ -1466,6 +2006,31 @@ export class OpenAICodexAuth extends Service {
1466
2006
  this.sendJson(res, 500, { error: messageOf(error) });
1467
2007
  }
1468
2008
  }
2009
+ async handleAccountUsage(req, res) {
2010
+ if (!this.trustedManagementRequest(req, res))
2011
+ return;
2012
+ if (req.method !== 'POST') {
2013
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
2014
+ return;
2015
+ }
2016
+ if (!this.requireCsrf(req, res))
2017
+ return;
2018
+ const abort = new AbortController();
2019
+ const onAborted = () => { abort.abort(new Error('Account quota request cancelled')); };
2020
+ const observesAbort = typeof req.once === 'function' && typeof req.off === 'function';
2021
+ if (observesAbort)
2022
+ req.once('aborted', onAborted);
2023
+ try {
2024
+ this.sendJson(res, 200, { accounts: await this.accountUsages(abort.signal) });
2025
+ }
2026
+ catch (error) {
2027
+ this.sendJson(res, error instanceof CredentialNotWritableError ? 409 : 500, { error: messageOf(error) });
2028
+ }
2029
+ finally {
2030
+ if (observesAbort)
2031
+ req.off('aborted', onAborted);
2032
+ }
2033
+ }
1469
2034
  async handleDeviceStart(req, res) {
1470
2035
  if (!this.trustedManagementRequest(req, res))
1471
2036
  return;
@@ -1642,6 +2207,54 @@ export class OpenAICodexAuth extends Service {
1642
2207
  this.sendJson(res, 500, { error: messageOf(error) });
1643
2208
  }
1644
2209
  }
2210
+ async accountIdBody(req) {
2211
+ const body = await readJsonBody(req);
2212
+ if (Object.keys(body).length !== 1 || typeof body.accountId !== 'string'
2213
+ || body.accountId.length === 0 || body.accountId.length > 512) {
2214
+ throw new Error('The accountId field must identify one OpenAI Codex account.');
2215
+ }
2216
+ return body.accountId;
2217
+ }
2218
+ async handleSetCurrentAccount(req, res) {
2219
+ if (!this.trustedManagementRequest(req, res))
2220
+ return;
2221
+ if (req.method !== 'POST') {
2222
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
2223
+ return;
2224
+ }
2225
+ if (!this.requireCsrf(req, res))
2226
+ return;
2227
+ try {
2228
+ await this.setCurrentAccount(await this.accountIdBody(req));
2229
+ this.sendJson(res, 200, { ok: true });
2230
+ }
2231
+ catch (error) {
2232
+ const status = error instanceof AccountNotFoundError
2233
+ ? 404
2234
+ : error instanceof CredentialNotWritableError ? 409 : 400;
2235
+ this.sendJson(res, status, { error: messageOf(error) });
2236
+ }
2237
+ }
2238
+ async handleAccountLogout(req, res) {
2239
+ if (!this.trustedManagementRequest(req, res))
2240
+ return;
2241
+ if (req.method !== 'POST') {
2242
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
2243
+ return;
2244
+ }
2245
+ if (!this.requireCsrf(req, res))
2246
+ return;
2247
+ try {
2248
+ await this.logout(await this.accountIdBody(req));
2249
+ this.sendJson(res, 200, { ok: true });
2250
+ }
2251
+ catch (error) {
2252
+ const status = error instanceof AccountNotFoundError
2253
+ ? 404
2254
+ : error instanceof CredentialNotWritableError ? 409 : 400;
2255
+ this.sendJson(res, status, { error: messageOf(error) });
2256
+ }
2257
+ }
1645
2258
  async handleLogout(req, res) {
1646
2259
  if (!this.trustedManagementRequest(req, res))
1647
2260
  return;
@@ -1656,7 +2269,7 @@ export class OpenAICodexAuth extends Service {
1656
2269
  this.sendJson(res, 200, { ok: true });
1657
2270
  }
1658
2271
  catch (error) {
1659
- this.sendJson(res, 500, { error: messageOf(error) });
2272
+ this.sendJson(res, error instanceof CredentialNotWritableError ? 409 : 500, { error: messageOf(error) });
1660
2273
  }
1661
2274
  }
1662
2275
  }