@askalf/dario 6.20.3 → 6.22.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/README.md +1 -1
- package/dist/account-sync.d.ts +92 -18
- package/dist/account-sync.js +87 -17
- package/dist/admin-api.d.ts +8 -0
- package/dist/admin-api.js +179 -24
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +47 -27
- package/dist/codex-account-sync.d.ts +28 -0
- package/dist/codex-account-sync.js +54 -0
- package/dist/codex-accounts.d.ts +32 -0
- package/dist/codex-accounts.js +140 -17
- package/dist/proxy.js +31 -4
- package/docs/admin-api.md +21 -1
- package/docs/multi-instance.md +6 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -213,7 +213,7 @@ dario is the routing layer of **[Own Your Stack](https://github.com/askalf)**, o
|
|
|
213
213
|
|
|
214
214
|
dario is part of **Own Your Stack**, the open toolkit behind **[Sprayberry Labs](https://sprayberrylabs.com)**, the software studio with one human on staff, run by [askalf](https://askalf.org), the AI operation these tools are part of.
|
|
215
215
|
|
|
216
|
-
Built in the open, scars included. Follow the build: **[@ask_alf](https://x.com/ask_alf)**
|
|
216
|
+
Built in the open, scars included. Follow the build: **[@ask_alf](https://x.com/ask_alf)**
|
|
217
217
|
|
|
218
218
|
### Sponsors
|
|
219
219
|
|
package/dist/account-sync.d.ts
CHANGED
|
@@ -19,32 +19,81 @@
|
|
|
19
19
|
* made after it, so the instances' clocks must agree (NTP) to well under the
|
|
20
20
|
* time between a removal and a later login.
|
|
21
21
|
*
|
|
22
|
+
* Admin API logins that are started but not yet completed ride the same
|
|
23
|
+
* service (`/logins/<claude|codex>/...`, see SharedPendingLogins below), so
|
|
24
|
+
* a login started on one replica completes on another.
|
|
25
|
+
*
|
|
22
26
|
* Every call fails open: with the service unreachable an instance serves the
|
|
23
27
|
* accounts on its own disk, logging once per outage. The registry holds
|
|
24
28
|
* every seat's full credentials, refresh tokens included, with no expiry, so
|
|
25
29
|
* DARIO_REFRESH_LOCK_TOKEN reads every seat; that is why this is an opt-in
|
|
26
30
|
* separate from `--pool-shared-state`.
|
|
31
|
+
*
|
|
32
|
+
* ChatGPT seats ride the same switch and the same rules in a registry of their
|
|
33
|
+
* own (`/codex-accounts/...`, codex-account-sync.ts), since aliases are a
|
|
34
|
+
* namespace per engine: a second AccountSync, with an AccountRegistry that
|
|
35
|
+
* says where its records live and how to read them.
|
|
27
36
|
*/
|
|
28
37
|
import { type AccountCredentials } from './accounts.js';
|
|
38
|
+
/** What the sync needs of a record, whichever engine it belongs to. */
|
|
39
|
+
export interface SyncedCredentials {
|
|
40
|
+
alias: string;
|
|
41
|
+
accessToken: string;
|
|
42
|
+
refreshToken: string;
|
|
43
|
+
expiresAt: number;
|
|
44
|
+
grantedAt?: number;
|
|
45
|
+
}
|
|
29
46
|
/** Where the sync reads and writes this instance's accounts. accounts.ts in production; a test passes its own. */
|
|
30
|
-
export interface AccountSyncStore {
|
|
31
|
-
loadAll(): Promise<
|
|
32
|
-
save(creds:
|
|
47
|
+
export interface AccountSyncStore<R extends SyncedCredentials = AccountCredentials> {
|
|
48
|
+
loadAll(): Promise<R[]>;
|
|
49
|
+
save(creds: R, opts: {
|
|
33
50
|
fetchedAt: number;
|
|
34
51
|
removedAt?: number;
|
|
35
52
|
}): Promise<{
|
|
36
53
|
saved: boolean;
|
|
37
|
-
replaced?:
|
|
54
|
+
replaced?: R;
|
|
38
55
|
}>;
|
|
39
56
|
remove(alias: string, removedAt: number): Promise<boolean>;
|
|
40
57
|
/** Removals this process made, alias to epoch ms. */
|
|
41
58
|
localRemovals(): ReadonlyMap<string, number>;
|
|
42
59
|
}
|
|
43
60
|
export declare const accountFileStore: AccountSyncStore;
|
|
61
|
+
/** A started, not yet completed admin API login: the PKCE secrets and when they lapse. */
|
|
62
|
+
export interface SharedLogin {
|
|
63
|
+
codeVerifier: string;
|
|
64
|
+
state: string;
|
|
65
|
+
expiresAt: number;
|
|
66
|
+
}
|
|
67
|
+
/** Which engine's pending logins: they are separate namespaces, as the seats are. */
|
|
68
|
+
export type LoginEngine = 'claude' | 'codex';
|
|
69
|
+
/**
|
|
70
|
+
* Pending admin API logins held on the shared service, so any instance can
|
|
71
|
+
* complete one that another started. The verifier and state are secrets and
|
|
72
|
+
* are stored as the registry stores credentials. 'failed' is the service
|
|
73
|
+
* not answering or not knowing the endpoint (an older service); the caller
|
|
74
|
+
* then keeps the login on this instance only.
|
|
75
|
+
*/
|
|
76
|
+
export interface SharedPendingLogins {
|
|
77
|
+
/**
|
|
78
|
+
* Publish a login for `alias`, replacing any other. 'full' when the service already holds its cap of other aliases.
|
|
79
|
+
* With `create`, a live login under the alias is left alone and the answer is 'taken'.
|
|
80
|
+
*/
|
|
81
|
+
put(engine: LoginEngine, alias: string, login: SharedLogin, ttlMs: number, create?: boolean): Promise<'stored' | 'full' | 'taken' | 'failed'>;
|
|
82
|
+
/** The login for `alias`, null when the service holds none (or it lapsed). Does not consume it. */
|
|
83
|
+
get(engine: LoginEngine, alias: string): Promise<SharedLogin | null | 'failed'>;
|
|
84
|
+
/**
|
|
85
|
+
* Delete the login for `alias` whose state is `state`, and only that one. True when this call removed
|
|
86
|
+
* it live; false when none was there, it lapsed, or another login has replaced it (another instance
|
|
87
|
+
* completed or restarted it first), which the caller must treat as lost.
|
|
88
|
+
*/
|
|
89
|
+
remove(engine: LoginEngine, alias: string, state: string): Promise<boolean | 'failed'>;
|
|
90
|
+
/** The aliases with a pending login on any instance. */
|
|
91
|
+
aliases(engine: LoginEngine): Promise<string[] | 'failed'>;
|
|
92
|
+
}
|
|
44
93
|
/** What one sync changed on this instance, handed to the running proxy. */
|
|
45
|
-
export interface AccountSyncChange {
|
|
94
|
+
export interface AccountSyncChange<R extends SyncedCredentials = AccountCredentials> {
|
|
46
95
|
/** Records the pool lacks or holds older credentials for. */
|
|
47
|
-
records:
|
|
96
|
+
records: R[];
|
|
48
97
|
/** Seats the pool still holds whose account was removed. */
|
|
49
98
|
removed: Array<{
|
|
50
99
|
alias: string;
|
|
@@ -72,19 +121,35 @@ export interface AccountSyncStatus {
|
|
|
72
121
|
errors: number;
|
|
73
122
|
lastError: string | null;
|
|
74
123
|
}
|
|
75
|
-
|
|
124
|
+
/** One engine's registry on the service: where its records live and how to read them. */
|
|
125
|
+
export interface AccountRegistry<R extends SyncedCredentials> {
|
|
126
|
+
/** The service path it answers under: `<path>/list`, `<path>/<alias>/put`, `<path>/<alias>/remove`. */
|
|
127
|
+
path: string;
|
|
128
|
+
/** What the log calls one record. */
|
|
129
|
+
noun: string;
|
|
130
|
+
store: AccountSyncStore<R>;
|
|
131
|
+
/** A registry record complete enough to serve from, stored under its own alias. */
|
|
132
|
+
isRecord(alias: string, rec: unknown): rec is R;
|
|
133
|
+
/** Names a record in a log line, never with its credentials. */
|
|
134
|
+
describe(creds: R): string;
|
|
135
|
+
/** Whether two records under one alias are different subscriptions. */
|
|
136
|
+
differ(a: R, b: R): boolean;
|
|
137
|
+
}
|
|
138
|
+
export interface AccountSyncOptions<R extends SyncedCredentials = AccountCredentials> {
|
|
76
139
|
baseUrl: string;
|
|
77
140
|
token: string;
|
|
78
141
|
/** Sync interval, ms. Default 15000. */
|
|
79
142
|
intervalMs?: number;
|
|
80
|
-
store?: AccountSyncStore
|
|
143
|
+
store?: AccountSyncStore<R>;
|
|
81
144
|
log?: (line: string) => void;
|
|
145
|
+
/** Default: the Claude accounts. */
|
|
146
|
+
registry?: AccountRegistry<R>;
|
|
82
147
|
}
|
|
83
148
|
export declare const DEFAULT_ACCOUNT_SYNC_INTERVAL_MS = 15000;
|
|
84
|
-
export interface AccountSyncPlan {
|
|
149
|
+
export interface AccountSyncPlan<R extends SyncedCredentials = AccountCredentials> {
|
|
85
150
|
/** Registry records to write here, each with the removal time it must outlive. */
|
|
86
151
|
save: Array<{
|
|
87
|
-
creds:
|
|
152
|
+
creds: R;
|
|
88
153
|
removedAt?: number;
|
|
89
154
|
}>;
|
|
90
155
|
/** Local files a removal covers. */
|
|
@@ -93,7 +158,7 @@ export interface AccountSyncPlan {
|
|
|
93
158
|
removedAt: number;
|
|
94
159
|
}>;
|
|
95
160
|
/** Local records newer than the registry's. */
|
|
96
|
-
push:
|
|
161
|
+
push: R[];
|
|
97
162
|
}
|
|
98
163
|
/** The latest removal of `alias` either side knows of, or undefined. */
|
|
99
164
|
export declare function removalTime(alias: string, markers: ReadonlyMap<string, number>, localRemovals: ReadonlyMap<string, number>): number | undefined;
|
|
@@ -104,7 +169,7 @@ export declare function removalTime(alias: string, markers: ReadonlyMap<string,
|
|
|
104
169
|
* newer wins. The registry's goes to disk, ours goes to the registry, and a
|
|
105
170
|
* local file no record outlives is removed.
|
|
106
171
|
*/
|
|
107
|
-
export declare function planAccountSync(local: readonly
|
|
172
|
+
export declare function planAccountSync<R extends SyncedCredentials>(local: readonly R[], registry: ReadonlyMap<string, R>, markers: ReadonlyMap<string, number>, localRemovals: ReadonlyMap<string, number>): AccountSyncPlan<R>;
|
|
108
173
|
/** A registry record complete enough to serve from, stored under its own alias. */
|
|
109
174
|
export declare function isRegistryRecord(alias: string, rec: unknown): rec is AccountCredentials;
|
|
110
175
|
/** How `dario accounts remove` ended with the shared account registry on. */
|
|
@@ -124,16 +189,19 @@ export type SharedRemovalResult =
|
|
|
124
189
|
* goes only once the registry took the removal, and only when it holds no
|
|
125
190
|
* login granted after it. Throws when the service does not answer.
|
|
126
191
|
*/
|
|
127
|
-
export declare function removeAccountEverywhere(baseUrl: string, token: string, alias: string, opts?: {
|
|
128
|
-
store?: AccountSyncStore
|
|
192
|
+
export declare function removeAccountEverywhere<R extends SyncedCredentials = AccountCredentials>(baseUrl: string, token: string, alias: string, opts?: {
|
|
193
|
+
store?: AccountSyncStore<R>;
|
|
129
194
|
removedAt?: number;
|
|
195
|
+
registry?: AccountRegistry<R>;
|
|
130
196
|
}): Promise<SharedRemovalResult>;
|
|
131
|
-
export declare class AccountSync {
|
|
197
|
+
export declare class AccountSync<R extends SyncedCredentials = AccountCredentials> implements SharedPendingLogins {
|
|
132
198
|
readonly intervalMs: number;
|
|
133
199
|
private readonly baseUrl;
|
|
134
200
|
private readonly token;
|
|
201
|
+
private readonly registry;
|
|
135
202
|
private readonly store;
|
|
136
203
|
private readonly log;
|
|
204
|
+
private readonly tag;
|
|
137
205
|
private onChange;
|
|
138
206
|
private live;
|
|
139
207
|
private timer;
|
|
@@ -143,14 +211,14 @@ export declare class AccountSync {
|
|
|
143
211
|
private markers;
|
|
144
212
|
private down;
|
|
145
213
|
private stats;
|
|
146
|
-
constructor(opts: AccountSyncOptions);
|
|
214
|
+
constructor(opts: AccountSyncOptions<R>);
|
|
147
215
|
/**
|
|
148
216
|
* Connect a running pool: `live` reads a seat's credentials, `onChange`
|
|
149
217
|
* gets what each later sync changed. Until then a sync updates the
|
|
150
218
|
* accounts directory only, which is all the startup sync needs, since the
|
|
151
219
|
* pool loads from that directory after it.
|
|
152
220
|
*/
|
|
153
|
-
attach(live: (alias: string) => LiveSeat | undefined, onChange: (change: AccountSyncChange) => Promise<void> | void): void;
|
|
221
|
+
attach(live: (alias: string) => LiveSeat | undefined, onChange: (change: AccountSyncChange<R>) => Promise<void> | void): void;
|
|
154
222
|
/** Sync on the interval. The timer never keeps the process alive. */
|
|
155
223
|
start(): void;
|
|
156
224
|
stop(): void;
|
|
@@ -162,8 +230,14 @@ export declare class AccountSync {
|
|
|
162
230
|
* login the sync just removed.
|
|
163
231
|
*/
|
|
164
232
|
removalOf(alias: string): number | undefined;
|
|
233
|
+
private loginPath;
|
|
234
|
+
private loginFailed;
|
|
235
|
+
put(engine: LoginEngine, alias: string, login: SharedLogin, ttlMs: number, create?: boolean): Promise<'stored' | 'full' | 'taken' | 'failed'>;
|
|
236
|
+
get(engine: LoginEngine, alias: string): Promise<SharedLogin | null | 'failed'>;
|
|
237
|
+
remove(engine: LoginEngine, alias: string, state: string): Promise<boolean | 'failed'>;
|
|
238
|
+
aliases(engine: LoginEngine): Promise<string[] | 'failed'>;
|
|
165
239
|
/** One sync. Null when the registry could not be read; never throws. */
|
|
166
|
-
syncOnce(): Promise<AccountSyncChange | null>;
|
|
240
|
+
syncOnce(): Promise<AccountSyncChange<R> | null>;
|
|
167
241
|
private run;
|
|
168
242
|
/**
|
|
169
243
|
* One POST to the service. Null on any failure: the caller carries on with
|
package/dist/account-sync.js
CHANGED
|
@@ -19,11 +19,20 @@
|
|
|
19
19
|
* made after it, so the instances' clocks must agree (NTP) to well under the
|
|
20
20
|
* time between a removal and a later login.
|
|
21
21
|
*
|
|
22
|
+
* Admin API logins that are started but not yet completed ride the same
|
|
23
|
+
* service (`/logins/<claude|codex>/...`, see SharedPendingLogins below), so
|
|
24
|
+
* a login started on one replica completes on another.
|
|
25
|
+
*
|
|
22
26
|
* Every call fails open: with the service unreachable an instance serves the
|
|
23
27
|
* accounts on its own disk, logging once per outage. The registry holds
|
|
24
28
|
* every seat's full credentials, refresh tokens included, with no expiry, so
|
|
25
29
|
* DARIO_REFRESH_LOCK_TOKEN reads every seat; that is why this is an opt-in
|
|
26
30
|
* separate from `--pool-shared-state`.
|
|
31
|
+
*
|
|
32
|
+
* ChatGPT seats ride the same switch and the same rules in a registry of their
|
|
33
|
+
* own (`/codex-accounts/...`, codex-account-sync.ts), since aliases are a
|
|
34
|
+
* namespace per engine: a second AccountSync, with an AccountRegistry that
|
|
35
|
+
* says where its records live and how to read them.
|
|
27
36
|
*/
|
|
28
37
|
import { loadAllAccounts, saveSharedAccount, removeSharedAccount, localAccountRemovals, credentialsAreNewer, grantedAfter, isValidAccountAlias, } from './accounts.js';
|
|
29
38
|
import { maskEmail } from './pool.js';
|
|
@@ -34,6 +43,18 @@ export const accountFileStore = {
|
|
|
34
43
|
remove: removeSharedAccount,
|
|
35
44
|
localRemovals: localAccountRemovals,
|
|
36
45
|
};
|
|
46
|
+
function parseSharedLogin(raw) {
|
|
47
|
+
const r = raw;
|
|
48
|
+
if (!r || typeof r !== 'object')
|
|
49
|
+
return null;
|
|
50
|
+
if (typeof r.codeVerifier !== 'string' || r.codeVerifier.length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
if (typeof r.state !== 'string' || r.state.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
if (typeof r.expiresAt !== 'number' || !Number.isFinite(r.expiresAt))
|
|
55
|
+
return null;
|
|
56
|
+
return { codeVerifier: r.codeVerifier, state: r.state, expiresAt: r.expiresAt };
|
|
57
|
+
}
|
|
37
58
|
export const DEFAULT_ACCOUNT_SYNC_INTERVAL_MS = 15_000;
|
|
38
59
|
const CALL_TIMEOUT_MS = 3_000;
|
|
39
60
|
/** The latest removal of `alias` either side knows of, or undefined. */
|
|
@@ -83,12 +104,12 @@ export function isRegistryRecord(alias, rec) {
|
|
|
83
104
|
&& typeof r.expiresAt === 'number' && Number.isFinite(r.expiresAt)
|
|
84
105
|
&& Array.isArray(r.scopes) && typeof r.deviceId === 'string' && typeof r.accountUuid === 'string';
|
|
85
106
|
}
|
|
86
|
-
function parseRegistry(raw) {
|
|
107
|
+
function parseRegistry(raw, isRecord) {
|
|
87
108
|
const out = new Map();
|
|
88
109
|
if (!raw || typeof raw !== 'object')
|
|
89
110
|
return out;
|
|
90
111
|
for (const [alias, rec] of Object.entries(raw))
|
|
91
|
-
if (
|
|
112
|
+
if (isRecord(alias, rec))
|
|
92
113
|
out.set(alias, rec);
|
|
93
114
|
return out;
|
|
94
115
|
}
|
|
@@ -121,9 +142,10 @@ async function registryCall(baseUrl, token, path, body) {
|
|
|
121
142
|
* login granted after it. Throws when the service does not answer.
|
|
122
143
|
*/
|
|
123
144
|
export async function removeAccountEverywhere(baseUrl, token, alias, opts = {}) {
|
|
124
|
-
const
|
|
145
|
+
const registry = opts.registry ?? CLAUDE_REGISTRY;
|
|
146
|
+
const store = opts.store ?? registry.store;
|
|
125
147
|
const removedAt = opts.removedAt ?? Date.now();
|
|
126
|
-
const res = await registryCall(baseUrl, token,
|
|
148
|
+
const res = await registryCall(baseUrl, token, `${registry.path}/${encodeURIComponent(alias)}/remove`, { removedAt });
|
|
127
149
|
if (res.removed !== true)
|
|
128
150
|
return 'registry-newer';
|
|
129
151
|
if (await store.remove(alias, removedAt))
|
|
@@ -139,12 +161,22 @@ export async function removeAccountEverywhere(baseUrl, token, alias, opts = {})
|
|
|
139
161
|
function describeAccount(c) {
|
|
140
162
|
return maskEmail(c.accountEmail) ?? c.accountId ?? 'unknown account';
|
|
141
163
|
}
|
|
164
|
+
const CLAUDE_REGISTRY = {
|
|
165
|
+
path: '/accounts',
|
|
166
|
+
noun: 'account',
|
|
167
|
+
store: accountFileStore,
|
|
168
|
+
isRecord: isRegistryRecord,
|
|
169
|
+
describe: describeAccount,
|
|
170
|
+
differ: (a, b) => !!a.accountId && !!b.accountId && a.accountId !== b.accountId,
|
|
171
|
+
};
|
|
142
172
|
export class AccountSync {
|
|
143
173
|
intervalMs;
|
|
144
174
|
baseUrl;
|
|
145
175
|
token;
|
|
176
|
+
registry;
|
|
146
177
|
store;
|
|
147
178
|
log;
|
|
179
|
+
tag;
|
|
148
180
|
onChange = null;
|
|
149
181
|
live = null;
|
|
150
182
|
timer = null;
|
|
@@ -166,8 +198,10 @@ export class AccountSync {
|
|
|
166
198
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
167
199
|
this.token = opts.token;
|
|
168
200
|
this.intervalMs = clampTimerMs(Math.max(1_000, opts.intervalMs ?? DEFAULT_ACCOUNT_SYNC_INTERVAL_MS));
|
|
169
|
-
this.
|
|
201
|
+
this.registry = opts.registry ?? CLAUDE_REGISTRY;
|
|
202
|
+
this.store = opts.store ?? this.registry.store;
|
|
170
203
|
this.log = opts.log ?? ((line) => console.error(line));
|
|
204
|
+
this.tag = `[dario] shared ${this.registry.noun}s:`;
|
|
171
205
|
}
|
|
172
206
|
/**
|
|
173
207
|
* Connect a running pool: `live` reads a seat's credentials, `onChange`
|
|
@@ -203,6 +237,42 @@ export class AccountSync {
|
|
|
203
237
|
removalOf(alias) {
|
|
204
238
|
return removalTime(alias, this.markers, this.store.localRemovals());
|
|
205
239
|
}
|
|
240
|
+
loginPath(engine, alias, action) {
|
|
241
|
+
return `/logins/${engine}/${encodeURIComponent(alias)}/${action}`;
|
|
242
|
+
}
|
|
243
|
+
// Never logs the login: the line carries the alias and the transport error only.
|
|
244
|
+
loginFailed(what, alias) {
|
|
245
|
+
this.log(`${this.tag} could not ${what} the pending login for "${alias}" on the service (${this.stats.lastError ?? 'no answer'}); using this instance's copy`);
|
|
246
|
+
return 'failed';
|
|
247
|
+
}
|
|
248
|
+
async put(engine, alias, login, ttlMs, create = false) {
|
|
249
|
+
const out = await this.call(this.loginPath(engine, alias, 'put'), create ? { login, ttlMs, create: true } : { login, ttlMs });
|
|
250
|
+
if (!out)
|
|
251
|
+
return this.loginFailed('publish', alias);
|
|
252
|
+
if (out.stored === true)
|
|
253
|
+
return 'stored';
|
|
254
|
+
if (out.full === true)
|
|
255
|
+
return 'full';
|
|
256
|
+
return out.taken === true ? 'taken' : this.loginFailed('publish', alias);
|
|
257
|
+
}
|
|
258
|
+
async get(engine, alias) {
|
|
259
|
+
const out = await this.call(this.loginPath(engine, alias, 'get'), {});
|
|
260
|
+
if (!out)
|
|
261
|
+
return this.loginFailed('read', alias);
|
|
262
|
+
return parseSharedLogin(out.login);
|
|
263
|
+
}
|
|
264
|
+
async remove(engine, alias, state) {
|
|
265
|
+
const out = await this.call(this.loginPath(engine, alias, 'remove'), { state });
|
|
266
|
+
if (!out)
|
|
267
|
+
return this.loginFailed('remove', alias);
|
|
268
|
+
return out.removed === true;
|
|
269
|
+
}
|
|
270
|
+
async aliases(engine) {
|
|
271
|
+
const out = await this.call(`/logins/${engine}/list`, {});
|
|
272
|
+
if (!out)
|
|
273
|
+
return 'failed';
|
|
274
|
+
return Array.isArray(out.aliases) ? out.aliases.filter((a) => typeof a === 'string') : [];
|
|
275
|
+
}
|
|
206
276
|
/** One sync. Null when the registry could not be read; never throws. */
|
|
207
277
|
syncOnce() {
|
|
208
278
|
if (this.queued)
|
|
@@ -212,7 +282,7 @@ export class AccountSync {
|
|
|
212
282
|
return this.run().catch((err) => {
|
|
213
283
|
this.stats.errors++;
|
|
214
284
|
this.stats.lastError = err instanceof Error ? err.message : String(err);
|
|
215
|
-
this.log(
|
|
285
|
+
this.log(`${this.tag} sync failed (${this.stats.lastError}); retrying on the next sync`);
|
|
216
286
|
return null;
|
|
217
287
|
});
|
|
218
288
|
});
|
|
@@ -227,17 +297,17 @@ export class AccountSync {
|
|
|
227
297
|
for (const [alias, at] of this.store.localRemovals()) {
|
|
228
298
|
if ((this.published.get(alias) ?? -Infinity) >= at)
|
|
229
299
|
continue;
|
|
230
|
-
const res = await this.call(
|
|
300
|
+
const res = await this.call(`${this.registry.path}/${encodeURIComponent(alias)}/remove`, { removedAt: at });
|
|
231
301
|
if (!res)
|
|
232
302
|
break;
|
|
233
303
|
this.published.set(alias, at);
|
|
234
304
|
this.stats.removalsPublished++;
|
|
235
305
|
}
|
|
236
306
|
const fetchedAt = Date.now();
|
|
237
|
-
const res = await this.call(
|
|
307
|
+
const res = await this.call(`${this.registry.path}/list`, {});
|
|
238
308
|
if (!res)
|
|
239
309
|
return null;
|
|
240
|
-
const registry = parseRegistry(res.accounts);
|
|
310
|
+
const registry = parseRegistry(res.accounts, this.registry.isRecord);
|
|
241
311
|
const markers = parseMarkers(res.removed);
|
|
242
312
|
this.markers = markers;
|
|
243
313
|
this.stats.registryAccounts = registry.size;
|
|
@@ -260,7 +330,7 @@ export class AccountSync {
|
|
|
260
330
|
kept.delete(alias);
|
|
261
331
|
removed.push({ alias, removedAt });
|
|
262
332
|
this.stats.removed++;
|
|
263
|
-
this.log(
|
|
333
|
+
this.log(`${this.tag} removed ${this.registry.noun} "${alias}"; it was removed on another instance`);
|
|
264
334
|
}
|
|
265
335
|
for (const { creds, removedAt } of plan.save) {
|
|
266
336
|
const out = await this.store.save(creds, removedAt === undefined ? { fetchedAt } : { fetchedAt, removedAt });
|
|
@@ -270,13 +340,13 @@ export class AccountSync {
|
|
|
270
340
|
this.stats.saved++;
|
|
271
341
|
const was = out.replaced;
|
|
272
342
|
if (!was) {
|
|
273
|
-
this.log(
|
|
343
|
+
this.log(`${this.tag} added ${this.registry.noun} "${creds.alias}" (${this.registry.describe(creds)}) from the registry`);
|
|
274
344
|
}
|
|
275
|
-
else if (
|
|
345
|
+
else if (this.registry.differ(was, creds)) {
|
|
276
346
|
// Aliases are one namespace across every instance. Two instances
|
|
277
347
|
// that each logged a different subscription in under one alias
|
|
278
348
|
// (`login`, most often) keep only the newer grant.
|
|
279
|
-
this.log(
|
|
349
|
+
this.log(`${this.tag} ${this.registry.noun} "${creds.alias}" here was ${this.registry.describe(was)} and is now ${this.registry.describe(creds)}, a newer login under the same alias on another instance; aliases are shared across instances, so give each subscription its own alias`);
|
|
280
350
|
}
|
|
281
351
|
}
|
|
282
352
|
for (const creds of plan.push) {
|
|
@@ -285,14 +355,14 @@ export class AccountSync {
|
|
|
285
355
|
const removedSince = this.store.localRemovals().get(creds.alias);
|
|
286
356
|
if (!grantedAfter(creds, removedSince))
|
|
287
357
|
continue;
|
|
288
|
-
const out = await this.call(
|
|
358
|
+
const out = await this.call(`${this.registry.path}/${encodeURIComponent(creds.alias)}/put`, { credentials: creds });
|
|
289
359
|
if (!out)
|
|
290
360
|
break;
|
|
291
361
|
if (!out.stored)
|
|
292
362
|
continue;
|
|
293
363
|
this.stats.pushed++;
|
|
294
364
|
if (!registry.has(creds.alias))
|
|
295
|
-
this.log(
|
|
365
|
+
this.log(`${this.tag} published ${this.registry.noun} "${creds.alias}" to the registry`);
|
|
296
366
|
}
|
|
297
367
|
const change = { records: [], removed };
|
|
298
368
|
if (this.live) {
|
|
@@ -334,7 +404,7 @@ export class AccountSync {
|
|
|
334
404
|
this.stats.lastOkAt = Date.now();
|
|
335
405
|
if (this.down) {
|
|
336
406
|
this.down = false;
|
|
337
|
-
this.log(
|
|
407
|
+
this.log(`${this.tag} service reachable again (${this.baseUrl})`);
|
|
338
408
|
}
|
|
339
409
|
return out;
|
|
340
410
|
}
|
|
@@ -343,7 +413,7 @@ export class AccountSync {
|
|
|
343
413
|
this.stats.lastError = err instanceof Error ? err.message : String(err);
|
|
344
414
|
if (!this.down) {
|
|
345
415
|
this.down = true;
|
|
346
|
-
this.log(
|
|
416
|
+
this.log(`${this.tag} ${this.baseUrl} unreachable (${this.stats.lastError}); serving the accounts on this instance's disk until it answers again`);
|
|
347
417
|
}
|
|
348
418
|
return null;
|
|
349
419
|
}
|
package/dist/admin-api.d.ts
CHANGED
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
78
78
|
import type { CodexSeatState } from './codex-accounts.js';
|
|
79
79
|
import { type CodexUsageView } from './codex-usage.js';
|
|
80
|
+
import type { SharedPendingLogins } from './account-sync.js';
|
|
80
81
|
import { type KeyStore } from './keys.js';
|
|
81
82
|
/** Persisted account metadata surfaced by `GET /admin/accounts`. */
|
|
82
83
|
export interface AdminAccountRecord {
|
|
@@ -226,6 +227,13 @@ export interface AdminDeps {
|
|
|
226
227
|
* off on this proxy; the routes answer 404.
|
|
227
228
|
*/
|
|
228
229
|
keys?: KeyStore | null;
|
|
230
|
+
/**
|
|
231
|
+
* The shared account registry's pending-login store (account-sync.ts), set
|
|
232
|
+
* when `--pool-shared-accounts` is on. A login started here is also
|
|
233
|
+
* published there, so any instance can complete it. Absent: pending logins
|
|
234
|
+
* live in this process only.
|
|
235
|
+
*/
|
|
236
|
+
sharedLogins?: SharedPendingLogins | null;
|
|
229
237
|
}
|
|
230
238
|
/**
|
|
231
239
|
* Handle an `/admin/*` request. Returns `true` if it owned the request (matched
|