opencode-auth-load-balancer 0.8.0 → 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/README.md +44 -23
- package/dist/index.js +505 -51
- package/dist/login-rev.d.ts +14 -0
- package/dist/refresh-hooks.d.ts +18 -0
- package/dist/sync/apply.d.ts +2 -0
- package/dist/sync/cycle.d.ts +2 -0
- package/dist/sync/enabled.d.ts +2 -0
- package/dist/sync/login-hooks.d.ts +14 -0
- package/dist/sync/login-payload.d.ts +40 -0
- package/dist/sync/logins.d.ts +63 -0
- package/dist/sync/payload.d.ts +13 -7
- package/dist/sync/start.d.ts +21 -3
- package/dist/sync/state.d.ts +6 -0
- package/dist/sync/text.d.ts +5 -0
- package/dist/token-mint.d.ts +10 -1
- package/dist/types.d.ts +10 -0
- package/dist/v1.js +505 -51
- package/dist/v2-entry.js +505 -51
- package/package.json +3 -3
- package/tui/auth-load-balancer-tui.logic.ts +1 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared version of a row's OAuth login. Gist sync orders the copies of one
|
|
3
|
+
* login that different machines hold by it, so every change of the access +
|
|
4
|
+
* refresh pair (login, refresh, minted rotation) bumps it in the same pool
|
|
5
|
+
* transaction as the credential.
|
|
6
|
+
*/
|
|
7
|
+
import type { PoolAccount } from './types';
|
|
8
|
+
/** Record that `row`'s login pair just changed. */
|
|
9
|
+
export declare function bumpLoginRev(row: PoolAccount, now?: number): void;
|
|
10
|
+
/** The version of a row's login; a row that never recorded one is revision 1, at time 0. */
|
|
11
|
+
export declare function loginVersion(row: PoolAccount): {
|
|
12
|
+
rev: number;
|
|
13
|
+
at: number;
|
|
14
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks gist sync plugs into the refresh path. A leaf module on purpose:
|
|
3
|
+
* `refresh.ts` stays free of GitHub and crypto code, and sync can register
|
|
4
|
+
* without an import cycle. No hooks are set unless sync registers some.
|
|
5
|
+
*/
|
|
6
|
+
import type { PoolAccount } from './types';
|
|
7
|
+
export interface RefreshHooks {
|
|
8
|
+
/**
|
|
9
|
+
* Called inside the account's refresh lock, before the provider is asked:
|
|
10
|
+
* resolves true when it put a newer shared login on the row (re-read it).
|
|
11
|
+
* `recovery` is set once the provider refused the refresh token.
|
|
12
|
+
*/
|
|
13
|
+
pull?: (account: PoolAccount, recovery: boolean) => Promise<boolean>;
|
|
14
|
+
/** Called outside the lock after a refresh this call performed. */
|
|
15
|
+
afterRefresh?: () => void;
|
|
16
|
+
}
|
|
17
|
+
export declare function setRefreshHooks(hooks: RefreshHooks | null): void;
|
|
18
|
+
export declare function getRefreshHooks(): RefreshHooks | null;
|
package/dist/sync/apply.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type Landing = {
|
|
|
23
23
|
blocker: string;
|
|
24
24
|
holds: string;
|
|
25
25
|
};
|
|
26
|
+
/** `label`, or `label (n)` when another row already carries it (labels are unique pool-wide). */
|
|
27
|
+
export declare function uniqueLabel(pool: PoolFile, label: string): string;
|
|
26
28
|
/**
|
|
27
29
|
* Land one verified credential, deciding everything from the pool as it is
|
|
28
30
|
* under the lock (callers run this inside `mutatePool`):
|
package/dist/sync/cycle.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface CycleReport {
|
|
|
11
11
|
}
|
|
12
12
|
/** After GitHub refuses the account's write, nothing is attempted for this long (downloads go on). */
|
|
13
13
|
export declare const WRITE_BACKOFF_MS = 3600000;
|
|
14
|
+
/** "Uploaded 2 credentials and 3 logins." (the logins only when there are some). */
|
|
15
|
+
export declare function uploadedMessage(entries: number, logins: number): string;
|
|
14
16
|
/**
|
|
15
17
|
* Run one cycle. `force` is "Upload now": it needs a GitHub token (and says so
|
|
16
18
|
* if there is none), reads the gist afresh, and tries to write even inside the
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sync side of the refresh hooks: before a machine spends a login's
|
|
3
|
+
* single-use refresh token it looks at the gist, because another machine may
|
|
4
|
+
* have refreshed that login already.
|
|
5
|
+
*
|
|
6
|
+
* Runs inside the caller's refresh lock, so it never takes that lock again and
|
|
7
|
+
* never takes the cycle lock or writes the sync state (the cycle takes refresh
|
|
8
|
+
* locks while holding the cycle lock; the other order could deadlock).
|
|
9
|
+
*/
|
|
10
|
+
import type { PoolAccount } from '../types';
|
|
11
|
+
import { type GhRunner } from './github-auth';
|
|
12
|
+
/** The hook `refresh.ts` calls; `runGh` finds the GitHub token (a test replaces it). */
|
|
13
|
+
export declare function createPullLogin(runGh?: GhRunner): (account: PoolAccount, _recovery: boolean) => Promise<boolean>;
|
|
14
|
+
export declare const pullLogin: (account: PoolAccount, _recovery: boolean) => Promise<boolean>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export declare const MAX_LOGINS = 64;
|
|
2
|
+
/** A set, not an object: a provider id from a gist must never resolve to an inherited member such as `constructor`. */
|
|
3
|
+
export declare const LOGIN_PROVIDERS: ReadonlySet<string>;
|
|
4
|
+
/** One shared login: the newest `(rev, at, refresh fingerprint)` of a key wins on every machine. */
|
|
5
|
+
export interface LoginRecord {
|
|
6
|
+
/** `${providerID}:${accountId}`: the identity of the login across machines. */
|
|
7
|
+
key: string;
|
|
8
|
+
providerID: string;
|
|
9
|
+
label: string;
|
|
10
|
+
access: string;
|
|
11
|
+
refresh: string;
|
|
12
|
+
/** epoch ms the access token lapses. */
|
|
13
|
+
expires: number;
|
|
14
|
+
accountId: string;
|
|
15
|
+
orgId?: string;
|
|
16
|
+
/** epoch ms the login itself lapses, when known. */
|
|
17
|
+
refreshExpires?: number;
|
|
18
|
+
rev: number;
|
|
19
|
+
at: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function loginKey(providerID: string, accountId: string): string;
|
|
22
|
+
export declare function parseLogin(raw: unknown): LoginRecord | null;
|
|
23
|
+
/** The logins in a stable order (by key), so equal lists produce equal payloads. */
|
|
24
|
+
export declare function sortLogins(logins: readonly LoginRecord[]): LoginRecord[];
|
|
25
|
+
/** A digest of the logins: when it changes, the gist is out of date. */
|
|
26
|
+
export declare function loginsDigest(logins: readonly LoginRecord[]): string;
|
|
27
|
+
/**
|
|
28
|
+
* The total order two machines share to pick the winner among copies of one
|
|
29
|
+
* login: revision, then time, then the refresh token's fingerprint (so even a
|
|
30
|
+
* tie of both is settled the same way everywhere). Positive when `a` is newer.
|
|
31
|
+
*/
|
|
32
|
+
export declare function compareLogins(a: Pick<LoginRecord, 'at' | 'refresh' | 'rev'>, b: Pick<LoginRecord, 'at' | 'refresh' | 'rev'>): number;
|
|
33
|
+
/** What a payload's login list yields: the valid logins, every key listed, and how many could not be read. */
|
|
34
|
+
export interface ParsedLogins {
|
|
35
|
+
logins: LoginRecord[];
|
|
36
|
+
loginsListed: Set<string>;
|
|
37
|
+
loginsUnreadable: number;
|
|
38
|
+
}
|
|
39
|
+
/** Parse a raw login list. A duplicate key keeps the first valid login; an unreadable one is counted, never imported. */
|
|
40
|
+
export declare function parseLoginList(rawList: readonly unknown[]): ParsedLogins;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type PoolAccount, type PoolFile } from '../types';
|
|
2
|
+
import { type LoginRecord } from './login-payload';
|
|
3
|
+
import { type SyncEntry } from './payload';
|
|
4
|
+
/** On unless `OPENCODE_AUTH_LB_SYNC_LOGINS` is `0` / `false` / `no` / `off`. */
|
|
5
|
+
export declare function loginsSyncEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
6
|
+
/** The login keys the pool's rows hold. */
|
|
7
|
+
export declare function heldMarks(accounts: readonly PoolAccount[]): Record<string, true>;
|
|
8
|
+
/**
|
|
9
|
+
* This machine's shareable logins, one per key: a real OAuth login (a refresh
|
|
10
|
+
* token, not a Kimi API key or a setup-token), of a known account, that still
|
|
11
|
+
* works. Usage, cooldowns, sessions, `tokenGen`, the inference token and local
|
|
12
|
+
* ids never go in. Every login passes the receiver's own validation.
|
|
13
|
+
*/
|
|
14
|
+
export declare function ownLogins(pool: PoolFile): LoginRecord[];
|
|
15
|
+
/**
|
|
16
|
+
* The logins to write: per key the winner of the gist's and this machine's
|
|
17
|
+
* copy. A gist login this machine has no copy of is carried over untouched; a
|
|
18
|
+
* login of this machine the gist lacks is added while there is room.
|
|
19
|
+
*/
|
|
20
|
+
export declare function mergeLogins(gist: readonly LoginRecord[], own: readonly LoginRecord[]): LoginRecord[];
|
|
21
|
+
/** How many of this machine's logins are, as they are, what `next` lists. */
|
|
22
|
+
export declare function ownCopies(next: readonly LoginRecord[], own: readonly LoginRecord[]): number;
|
|
23
|
+
/**
|
|
24
|
+
* The digest of what this machine would publish: when it differs from the one
|
|
25
|
+
* stored at the last upload, the gist may be out of date. Without logins it is
|
|
26
|
+
* the entries' digest alone, so a machine that shares none keeps its old one.
|
|
27
|
+
*/
|
|
28
|
+
export declare function publishDigest(entries: readonly SyncEntry[], logins: readonly LoginRecord[]): string;
|
|
29
|
+
/** Whether `row` is this machine's copy of `login`: the same key, and the same organization when both know it. */
|
|
30
|
+
export declare function sameLogin(row: PoolAccount, login: LoginRecord): boolean;
|
|
31
|
+
/** The version a row holds, comparable with a login's. */
|
|
32
|
+
export declare function versionOf(row: PoolAccount): Pick<LoginRecord, 'at' | 'refresh' | 'rev'>;
|
|
33
|
+
/**
|
|
34
|
+
* Put the gist's login onto the row that holds the same login, in one pool
|
|
35
|
+
* transaction that re-checks the row's (refresh, tokenGen) as
|
|
36
|
+
ow was read:
|
|
37
|
+
* a row that moved on in the meantime is left to the next cycle. The bumped
|
|
38
|
+
* okenGen makes a refresh that is in flight lose its CAS. The row keeps its
|
|
39
|
+
* inference token, usage, cooldowns, sessions and label. The caller holds the
|
|
40
|
+
* row's refresh lock (the lock is not re-entrant).
|
|
41
|
+
*/
|
|
42
|
+
export declare function overwriteLocked(row: PoolAccount, login: LoginRecord): Promise<boolean>;
|
|
43
|
+
/** What sync remembers about deleted rows, in and out of `applyLogins`. */
|
|
44
|
+
export interface LoginMarks {
|
|
45
|
+
known: Readonly<Record<string, true>> | undefined;
|
|
46
|
+
suppressed: Readonly<Record<string, true>> | undefined;
|
|
47
|
+
}
|
|
48
|
+
export interface LoginsApplied {
|
|
49
|
+
/** Rows the gist's logins were put onto, or created. */
|
|
50
|
+
adopted: number;
|
|
51
|
+
known: Record<string, true>;
|
|
52
|
+
suppressed: Record<string, true>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Bring the pool's logins in line with the gist's. First the deletion guard: a
|
|
56
|
+
* key that was held and has no row any more was deleted by the user, so it is
|
|
57
|
+
* suppressed and never imported; a suppressed key with a row again was logged
|
|
58
|
+
* in to again, so it is released (and outranks the gist's copy). Then every
|
|
59
|
+
* gist login that wins over this machine's copy is put onto the pool. A login
|
|
60
|
+
* that is not newer is ignored: this machine's copy is then the winner, and
|
|
61
|
+
* is written back.
|
|
62
|
+
*/
|
|
63
|
+
export declare function applyLogins(gist: readonly LoginRecord[], marks: LoginMarks, now: number): Promise<LoginsApplied>;
|
package/dist/sync/payload.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import { type LoginRecord } from './login-payload';
|
|
2
|
+
import { MAX_LABEL, printable } from './text';
|
|
3
|
+
export { MAX_LABEL, printable };
|
|
4
|
+
export declare const PAYLOAD_VERSION = 3;
|
|
2
5
|
export declare const MAX_ENTRIES = 64;
|
|
3
|
-
export declare const MAX_LABEL = 80;
|
|
4
6
|
/** A machine's origin: 128 random bits as hex. Not a secret, and never derived from a host name. */
|
|
5
7
|
export declare const ORIGIN_RE: RegExp;
|
|
6
8
|
/** The providers a credential may sync for, and the shape its secret must have. */
|
|
@@ -21,16 +23,14 @@ export interface SyncEntry {
|
|
|
21
23
|
}
|
|
22
24
|
/** The identity of an entry across machines. */
|
|
23
25
|
export declare function entryKey(entry: Pick<SyncEntry, 'origin' | 'id'>): string;
|
|
24
|
-
/** `text` with those characters turned into spaces, trimmed. */
|
|
25
|
-
export declare function printable(text: string): string;
|
|
26
26
|
export declare function parseEntry(raw: unknown): SyncEntry | null;
|
|
27
27
|
/** The entries in a stable order (by key), so equal lists produce equal payloads. */
|
|
28
28
|
export declare function sortEntries(entries: readonly SyncEntry[]): SyncEntry[];
|
|
29
29
|
/** A digest of the entries: when it changes, the gist is out of date. */
|
|
30
30
|
export declare function entriesDigest(entries: readonly SyncEntry[]): string;
|
|
31
|
-
/** The plaintext to encrypt. */
|
|
32
|
-
export declare function buildPayload(entries: readonly SyncEntry[], at: number): string;
|
|
33
|
-
/** A snapshot read back: when it was written, its valid entries, and every key it still lists. */
|
|
31
|
+
/** The plaintext to encrypt: always the current version, entries and logins each in a stable order. */
|
|
32
|
+
export declare function buildPayload(entries: readonly SyncEntry[], at: number, logins?: readonly LoginRecord[]): string;
|
|
33
|
+
/** A snapshot read back: when it was written, its valid entries and logins, and every key it still lists. */
|
|
34
34
|
export interface ParsedPayload {
|
|
35
35
|
at: number;
|
|
36
36
|
entries: SyncEntry[];
|
|
@@ -41,6 +41,12 @@ export interface ParsedPayload {
|
|
|
41
41
|
listed: ReadonlySet<string>;
|
|
42
42
|
/** How many entries could not be read at all: a writer must not rewrite what it cannot parse. */
|
|
43
43
|
unreadable: number;
|
|
44
|
+
/** The valid logins (none in a version 2 snapshot). */
|
|
45
|
+
logins: LoginRecord[];
|
|
46
|
+
/** Keys of every listed login, valid or not. */
|
|
47
|
+
loginsListed: ReadonlySet<string>;
|
|
48
|
+
/** How many logins could not be read at all: as for entries, a writer must not rewrite them. */
|
|
49
|
+
loginsUnreadable: number;
|
|
44
50
|
}
|
|
45
51
|
/**
|
|
46
52
|
* The snapshot in a decrypted payload. Unknown fields, unknown providers,
|
package/dist/sync/start.d.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
|
+
import { syncEnabled } from './enabled';
|
|
2
|
+
import { type SyncEngine } from './engine';
|
|
1
3
|
import { type SyncLoop } from './loop';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
export { syncEnabled };
|
|
5
|
+
/**
|
|
6
|
+
* Start the gist-sync schedule for this process; null when sync is switched
|
|
7
|
+
* off. With login sharing on it also plugs into the refresh path: a refresh is
|
|
8
|
+
* published at once, and a refresh about to happen first looks at the gist.
|
|
9
|
+
*/
|
|
5
10
|
export declare function startSync(): SyncLoop | null;
|
|
11
|
+
export interface LoginPublishOptions {
|
|
12
|
+
engine?: SyncEngine;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Publish a login that just landed, in the same breath: one sync cycle, so the
|
|
17
|
+
* machines that follow the gist get it without waiting for the schedule (the
|
|
18
|
+
* process may be a login command that exits right after). A no-op unless sync
|
|
19
|
+
* and login sharing are on and this machine follows a gist. Bounded, and it
|
|
20
|
+
* swallows every failure: a login never waits long for, or fails because of,
|
|
21
|
+
* its upload.
|
|
22
|
+
*/
|
|
23
|
+
export declare function publishAfterLogin(options?: LoginPublishOptions): Promise<void>;
|
package/dist/sync/state.d.ts
CHANGED
|
@@ -61,6 +61,10 @@ export interface SyncState {
|
|
|
61
61
|
imported: Record<string, ImportedRef>;
|
|
62
62
|
/** Entry key to why it was not imported. */
|
|
63
63
|
skipped: Record<string, SkippedRef>;
|
|
64
|
+
/** Login keys this machine held a row for at the last pass: a key that has no row any more was deleted by the user. */
|
|
65
|
+
loginKnown?: Record<string, true>;
|
|
66
|
+
/** Login keys the user deleted here: never imported again until a row for the key exists once more. */
|
|
67
|
+
loginSuppressed?: Record<string, true>;
|
|
64
68
|
}
|
|
65
69
|
/**
|
|
66
70
|
* A key-indexed map. Keys come from another machine, so the record has no
|
|
@@ -69,6 +73,8 @@ export interface SyncState {
|
|
|
69
73
|
*/
|
|
70
74
|
export declare function newMap<T>(from?: Readonly<Record<string, T>>): Record<string, T>;
|
|
71
75
|
export declare function newRefs(from?: Readonly<Record<string, ImportedRef>>): Record<string, ImportedRef>;
|
|
76
|
+
/** A map of marks, or undefined when it holds none: a state without logins stays as compact as it was. */
|
|
77
|
+
export declare function marks(from: Readonly<Record<string, true>> | undefined): Record<string, true> | undefined;
|
|
72
78
|
export declare function readSyncState(): Promise<SyncState | null>;
|
|
73
79
|
/**
|
|
74
80
|
* Read-modify-write the state under a cross-process lock. `fn` returns the
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** `text` with those characters turned into spaces, trimmed. */
|
|
2
|
+
export declare function printable(text: string): string;
|
|
3
|
+
export declare function boundedString(value: unknown, max: number): string | undefined;
|
|
4
|
+
/** The longest label a synced credential or login may carry. */
|
|
5
|
+
export declare const MAX_LABEL = 80;
|
package/dist/token-mint.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { ProviderAdapter } from './providers/types';
|
|
2
2
|
import type { PoolAccount, PoolFile } from './types';
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Off unless `OPENCODE_AUTH_LB_ANTHROPIC_AUTO_TOKEN` is `1` / `true` / `yes` /
|
|
5
|
+
* `on`. Verified against Anthropic's token endpoint: a refresh grant for
|
|
6
|
+
* `scope=user:inference&expires_in=31536000` is answered with an 8-hour token
|
|
7
|
+
* (the lifetime is ignored), and the refresh token it rotates to is narrowed
|
|
8
|
+
* to `user:inference` for good, so the login can no longer read its usage
|
|
9
|
+
* (`403 … user:profile`) and a full-scope refresh is refused (`invalid_scope`).
|
|
10
|
+
* The token is then too short to pool, so the grant only does harm. A
|
|
11
|
+
* long-lived token comes from a pasted `claude setup-token`.
|
|
12
|
+
*/
|
|
4
13
|
export declare function autoTokenEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
5
14
|
/** A row whose OAuth login should mint it a token: it has none, or its minted one nears its end. */
|
|
6
15
|
export declare function tokenDue(account: PoolAccount, now: number): boolean;
|
package/dist/types.d.ts
CHANGED
|
@@ -124,6 +124,16 @@ export interface PoolAccount {
|
|
|
124
124
|
* days, as Claude Code does.
|
|
125
125
|
*/
|
|
126
126
|
refreshExpires?: number;
|
|
127
|
+
/**
|
|
128
|
+
* The SHARED version of the row's OAuth login (gist sync orders machines'
|
|
129
|
+
* copies of one login by it): 1 when the login was created, plus one for
|
|
130
|
+
* every re-login, committed refresh rotation and minted rotation (see
|
|
131
|
+
* `src/login-rev.ts`). Absent on rows without an OAuth login and on legacy
|
|
132
|
+
* rows (read as 1).
|
|
133
|
+
*/
|
|
134
|
+
loginRev?: number;
|
|
135
|
+
/** epoch ms `loginRev` was last bumped (a tie-break between equal revisions). Absent reads as 0. */
|
|
136
|
+
loginAt?: number;
|
|
127
137
|
/**
|
|
128
138
|
* Organization the row's credentials act for (Anthropic), when known. A
|
|
129
139
|
* setup-token carries no account identity — only the organization its
|