opencode-auth-load-balancer 0.5.1 → 0.7.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 +152 -12
- package/dist/accounts.d.ts +22 -2
- package/dist/index.d.ts +7 -12
- package/dist/index.js +46026 -14563
- package/dist/legacy.d.ts +25 -0
- package/dist/login-health.d.ts +12 -0
- package/dist/notify.d.ts +6 -0
- package/dist/pairing.d.ts +22 -0
- package/dist/pending/coordinator.d.ts +5 -1
- package/dist/pending/v2.d.ts +3 -0
- package/dist/pool/paths.d.ts +8 -0
- package/dist/providers/anthropic/constants.d.ts +21 -0
- package/dist/providers/anthropic/oauth.d.ts +9 -0
- package/dist/providers/anthropic/setup-token.d.ts +14 -0
- package/dist/providers/anthropic/usage.d.ts +13 -0
- package/dist/providers/types.d.ts +27 -0
- package/dist/refresh.d.ts +12 -0
- package/dist/status.d.ts +6 -0
- package/dist/sync/apply.d.ts +66 -0
- package/dist/sync/crypto.d.ts +19 -0
- package/dist/sync/cycle.d.ts +19 -0
- package/dist/sync/engine.d.ts +44 -0
- package/dist/sync/errors.d.ts +9 -0
- package/dist/sync/gist.d.ts +33 -0
- package/dist/sync/github-auth.d.ts +15 -0
- package/dist/sync/intent.d.ts +29 -0
- package/dist/sync/loop.d.ts +40 -0
- package/dist/sync/merge.d.ts +49 -0
- package/dist/sync/origin.d.ts +2 -0
- package/dist/sync/own.d.ts +31 -0
- package/dist/sync/payload.d.ts +50 -0
- package/dist/sync/start.d.ts +5 -0
- package/dist/sync/state.d.ts +79 -0
- package/dist/sync/timing.d.ts +4 -0
- package/dist/token-mint.d.ts +25 -0
- package/dist/types.d.ts +75 -0
- package/dist/util.d.ts +7 -0
- package/dist/v1.d.ts +1 -0
- package/dist/v1.js +18393 -0
- package/dist/v2-rpc.d.ts +62 -0
- package/dist/v2.d.ts +2 -0
- package/index.js +2 -0
- package/package.json +46 -8
- package/tui/auth-load-balancer-rpc.ts +60 -0
- package/tui/auth-load-balancer-tui.logic.ts +363 -7
- package/tui/auth-load-balancer-tui.view.tsx +299 -107
- package/tui/auth-load-balancer-v2.ts +10 -0
- package/tui/auth-load-balancer-v2.view.tsx +242 -0
- package/tui.ts +2 -0
package/dist/legacy.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AuthHook, Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
import { type ToastClient } from './notify';
|
|
3
|
+
import { PendingCoordinator } from './pending/coordinator';
|
|
4
|
+
import type { ProviderAdapter } from './providers/types';
|
|
5
|
+
/**
|
|
6
|
+
* Build an opencode auth hook for one provider:
|
|
7
|
+
* - `loader`: seeds the pool from any existing opencode credential, then returns
|
|
8
|
+
* the load-balanced fetch so every request flows through the scheduler.
|
|
9
|
+
* - `methods`: logins that APPEND each account to the pool (instead of
|
|
10
|
+
* overwriting opencode's single auth slot) — a pasted OAuth code for
|
|
11
|
+
* Claude/Codex, then for Claude a pasted `claude setup-token` token; for
|
|
12
|
+
* Kimi Code a device-code OAuth login, then a pasted key.
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildAuthHook(adapter: ProviderAdapter, client: ToastClient, pending: PendingCoordinator): AuthHook;
|
|
15
|
+
export declare const AnthropicLoadBalancerPlugin: Plugin;
|
|
16
|
+
export declare const OpenAILoadBalancerPlugin: Plugin;
|
|
17
|
+
export declare const KimiCodeLoadBalancerPlugin: Plugin;
|
|
18
|
+
export declare const KimiCodeGlobalLoadBalancerPlugin: Plugin;
|
|
19
|
+
/**
|
|
20
|
+
* A standalone plugin that registers the `auth_lb_status` tool — an on-demand
|
|
21
|
+
* dashboard (in-use account per provider, each account's weekly/5h usage,
|
|
22
|
+
* cooldowns, and the ranked next candidates) across ALL providers. Registered once
|
|
23
|
+
* (not per provider) so the tool name doesn't collide.
|
|
24
|
+
*/
|
|
25
|
+
export declare const AuthLoadBalancerStatusPlugin: (input: Pick<Parameters<Plugin>[0], 'directory' | 'worktree'>) => ReturnType<Plugin>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A row's login health for the dashboards: what it holds, and what needs a re-login. */
|
|
2
|
+
import type { PoolAccount } from './types';
|
|
3
|
+
/** A setup-token row's credentials, `token` or `token+oauth`; '' for any other row. */
|
|
4
|
+
export declare function credentialTag(account: PoolAccount): string;
|
|
5
|
+
/**
|
|
6
|
+
* Login trouble on a row still in service: a lost half of a paired row,
|
|
7
|
+
* or an OAuth login about to expire. A parked row shows none — its state
|
|
8
|
+
* already reads `re-login` / `disabled`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function loginWarnings(account: PoolAccount, now: number): string[];
|
|
11
|
+
/** Why each lost login stopped working, and when: one dashboard line apiece. */
|
|
12
|
+
export declare function lostLoginNotes(account: PoolAccount, now: number): string[];
|
package/dist/notify.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface ToastClient {
|
|
|
19
19
|
* Best-effort: a failed toast never affects the request.
|
|
20
20
|
*/
|
|
21
21
|
export declare function notifyOnSwitch(client: ToastClient, providerID: string, account: PoolAccount): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Confirm a login that left a row holding both credentials, either half
|
|
24
|
+
* having arrived second: organization auto-pairing is a guess, so its result
|
|
25
|
+
* is shown, never silent.
|
|
26
|
+
*/
|
|
27
|
+
export declare function notifyPaired(client: ToastClient, providerID: string, account: PoolAccount): Promise<void>;
|
|
22
28
|
/**
|
|
23
29
|
* Toast (once per account + source-model + exhaustion window) when a request's
|
|
24
30
|
* model was auto-downgraded to a fallback because its MODEL TIER's weekly cap
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Which pool row a fresh login belongs to: the rules `addAccount` lands it by. */
|
|
2
|
+
import { type PoolAccount, type TokenSet } from './types';
|
|
3
|
+
export declare function holdsCredential(row: PoolAccount, tokens: TokenSet): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Auto-pairing: the one row of the credential's organization, among those
|
|
6
|
+
* its usage readings do not rule out, that can take this kind of credential.
|
|
7
|
+
* A setup-token carries no account identity, so the organization narrows it
|
|
8
|
+
* — exact for personal Pro/Max accounts (one organization each), shared by
|
|
9
|
+
* Team/Enterprise seats — and the reset times tell seats apart. A working
|
|
10
|
+
* credential of the same kind is replaced only when the readings confirm
|
|
11
|
+
* the same account (a renewed token); an OAuth login never replaces one,
|
|
12
|
+
* since its account id already failed to match.
|
|
13
|
+
*/
|
|
14
|
+
export declare function orgPartner(rows: readonly PoolAccount[], tokens: TokenSet): PoolAccount | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* The one row of the token's organization, among those its usage readings do
|
|
17
|
+
* not rule out, that already serves the account with a working token of its
|
|
18
|
+
* own. Gist sync uses it to leave such an account alone: a second token for it
|
|
19
|
+
* adds nothing, and replacing one machine's token with another's would have
|
|
20
|
+
* two machines trading tokens for ever.
|
|
21
|
+
*/
|
|
22
|
+
export declare function tokenSharer(rows: readonly PoolAccount[], tokens: TokenSet): PoolAccount | undefined;
|
|
@@ -76,6 +76,10 @@ export interface PendingRestoreClient {
|
|
|
76
76
|
prompt(options: RestorePromptOptions): Promise<RestoreResult<unknown>>;
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
|
+
/** Hosts with durable inboxes resume the original prompt by its stable ID. */
|
|
80
|
+
export interface PendingResumeClient {
|
|
81
|
+
resumePending(turn: PendingTurn): Promise<'resumed' | 'obsolete'>;
|
|
82
|
+
}
|
|
79
83
|
export interface PendingCoordinatorDependencies {
|
|
80
84
|
now(): number;
|
|
81
85
|
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
@@ -126,7 +130,7 @@ export declare class PendingCoordinator {
|
|
|
126
130
|
private restoreOnce;
|
|
127
131
|
private restoreWithRetry;
|
|
128
132
|
/** Restore this workspace/provider's durable references independently. */
|
|
129
|
-
restore(client: PendingRestoreClient): Promise<void>;
|
|
133
|
+
restore(client: PendingRestoreClient | PendingResumeClient): Promise<void>;
|
|
130
134
|
/** Remove all pending providers for a deleted session in this workspace. */
|
|
131
135
|
removeSession(sessionID: string): Promise<number>;
|
|
132
136
|
/** Mark shutdown before aborting sleepers so their abort path preserves rows. */
|
package/dist/pool/paths.d.ts
CHANGED
|
@@ -24,4 +24,12 @@ export declare function pendingFilePath(): string;
|
|
|
24
24
|
* same env-keyed memo as the two files above.
|
|
25
25
|
*/
|
|
26
26
|
export declare function versionCacheFilePath(): string;
|
|
27
|
+
/** Path to the gist-sync state (role, gist id, encryption key, imported ids). Holds the key: owner-only. */
|
|
28
|
+
export declare function syncStateFilePath(): string;
|
|
29
|
+
/** Path to this machine's sync origin (random, not secret; outlives "stop syncing"). */
|
|
30
|
+
export declare function syncOriginFilePath(): string;
|
|
31
|
+
/** Path to the one-shot TUI → server sync request (consumed and deleted on read). */
|
|
32
|
+
export declare function syncIntentFilePath(): string;
|
|
33
|
+
/** Path to the latest sync outcome shown by the TUI (no secrets). */
|
|
34
|
+
export declare function syncStatusFilePath(): string;
|
|
27
35
|
export {};
|
|
@@ -6,7 +6,28 @@ export declare const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
|
6
6
|
export { OAUTH_HTTP_TIMEOUT_MS, USAGE_HTTP_TIMEOUT_MS } from '../http-timeouts';
|
|
7
7
|
/** Dedicated usage endpoint — returns 5h + 7d utilization without consuming quota. */
|
|
8
8
|
export declare const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
9
|
+
/** Default host; `rewriteUrl` applies `ANTHROPIC_BASE_URL` and `?beta=true` like on any request. */
|
|
10
|
+
export declare const MESSAGES_URL = "https://api.anthropic.com/v1/messages";
|
|
11
|
+
export declare const ANTHROPIC_VERSION = "2023-06-01";
|
|
12
|
+
/**
|
|
13
|
+
* Model for the usage probe that stands in for `USAGE_URL` on a
|
|
14
|
+
* `claude setup-token` row (see `fetchUsage` in usage.ts): Claude Code's
|
|
15
|
+
* small, fast model, so the one-token probe costs as little quota as any
|
|
16
|
+
* request can. An alias, not a dated snapshot, so it follows the model
|
|
17
|
+
* forward.
|
|
18
|
+
*/
|
|
19
|
+
export declare const USAGE_PROBE_MODEL = "claude-haiku-4-5";
|
|
20
|
+
/** Claude Code's docs for `claude setup-token`, shown with the token login's prompt. */
|
|
21
|
+
export declare const SETUP_TOKEN_DOCS_URL = "https://code.claude.com/docs/en/authentication#generate-a-long-lived-token";
|
|
22
|
+
/** Response header naming the organization a request was served for. */
|
|
23
|
+
export declare const ORGANIZATION_ID_HEADER = "anthropic-organization-id";
|
|
9
24
|
export declare const OAUTH_SCOPES: string[];
|
|
25
|
+
/**
|
|
26
|
+
* What `claude setup-token` asks for: an inference-only token valid for a
|
|
27
|
+
* year. `mintInferenceToken` (oauth.ts) asks a refresh grant for the same.
|
|
28
|
+
*/
|
|
29
|
+
export declare const INFERENCE_SCOPE = "user:inference";
|
|
30
|
+
export declare const INFERENCE_TOKEN_LIFETIME_S: number;
|
|
10
31
|
export declare const TOOL_PREFIX = "mcp_";
|
|
11
32
|
/**
|
|
12
33
|
* LAST-RESORT downgrade target when a MODEL-TIER weekly cap is exhausted and
|
|
@@ -6,3 +6,12 @@ export declare function authorize(): Promise<AuthorizeRequest>;
|
|
|
6
6
|
export declare function exchange(input: string, verifier: string, redirectUri: string, expectedState?: string): Promise<TokenSet | null>;
|
|
7
7
|
/** Refresh an access token. Throws on failure; message includes the HTTP status. */
|
|
8
8
|
export declare function refresh(refreshToken: string): Promise<TokenSet>;
|
|
9
|
+
/**
|
|
10
|
+
* Mint the token `claude setup-token` prints from an OAuth login, with no
|
|
11
|
+
* Claude Code and no second browser approval: a refresh grant naming the
|
|
12
|
+
* scope and lifetime it wants, as Claude Code's own `claude auth login`
|
|
13
|
+
* mints its one-year token from a refresh token. The grant spends the
|
|
14
|
+
* refresh token like `refresh`, so the result carries the rotated one;
|
|
15
|
+
* `access`/`expires` are the minted token. Throws like `refresh`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function mintInferenceToken(refreshToken: string): Promise<TokenSet>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type TokenSet } from '../../types';
|
|
2
|
+
import type { TokenLogin } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* The pasted token, checked before it can join the pool. All whitespace is
|
|
5
|
+
* dropped first: a terminal wraps the long token, so a copied one routinely
|
|
6
|
+
* carries line breaks. One probe then decides — only a 401/403 means the
|
|
7
|
+
* token does not authenticate (a 429 is a valid but exhausted one). Its
|
|
8
|
+
* response names the organization the token acts for and the account's
|
|
9
|
+
* usage windows: with no profile scope, that is all that ties the token to
|
|
10
|
+
* its account's OAuth login, and the usage seeds the row without a second
|
|
11
|
+
* probe.
|
|
12
|
+
*/
|
|
13
|
+
export declare function exchangeSetupToken(input: string): Promise<TokenSet | null>;
|
|
14
|
+
export declare const setupTokenLogin: TokenLogin;
|
|
@@ -6,9 +6,22 @@ import type { PoolAccount, UsageSnapshot } from '../../types';
|
|
|
6
6
|
* and the fraction scale are Anthropic-specific.
|
|
7
7
|
*/
|
|
8
8
|
export declare function parseUsageHeaders(headers: Headers): Partial<UsageSnapshot> | null;
|
|
9
|
+
/**
|
|
10
|
+
* Claude Code's own quota check: one output token on the small model, shaped
|
|
11
|
+
* like every other request this plugin sends (identity + billing header,
|
|
12
|
+
* betas, UA). Any response to it — a 429 included — carries the unified
|
|
13
|
+
* 5h/7d headers. Rejects only on a network failure; the caller owns the body.
|
|
14
|
+
*/
|
|
15
|
+
export declare function sendUsageProbe(access: string): Promise<Response>;
|
|
16
|
+
/** The usage a probe's response reports, or null when it carries none. */
|
|
17
|
+
export declare function probeSnapshot(headers: Headers, now: number): UsageSnapshot | null;
|
|
9
18
|
/**
|
|
10
19
|
* Poll the dedicated usage endpoint for authoritative 5h + 7d utilization without
|
|
11
20
|
* consuming inference quota. Returns null on any failure — including a 200 whose
|
|
12
21
|
* body is not the usage shape — so the caller keeps the last-known snapshot.
|
|
22
|
+
*
|
|
23
|
+
* A row without a refresh token is a `claude setup-token` bearer, scoped to
|
|
24
|
+
* `user:inference` alone; the endpoint demands `user:profile` and answers it
|
|
25
|
+
* 403, so such a row is measured with the one-token probe above instead.
|
|
13
26
|
*/
|
|
14
27
|
export declare function fetchUsage(account: PoolAccount, now: number): Promise<UsageSnapshot | null>;
|
|
@@ -31,6 +31,19 @@ export interface AuthorizeRequest {
|
|
|
31
31
|
/** Login prompt text; absent = the OAuth default ("Paste the authorization code here:"). */
|
|
32
32
|
instructions?: string;
|
|
33
33
|
}
|
|
34
|
+
/** A paste login for a long-lived token the provider's own CLI mints (see `ProviderAdapter.tokenLogin`). */
|
|
35
|
+
export interface TokenLogin {
|
|
36
|
+
/**
|
|
37
|
+
* What the token is called, spliced into the method label. The TUI
|
|
38
|
+
* re-logs a static row through the method whose label carries it.
|
|
39
|
+
*/
|
|
40
|
+
readonly label: string;
|
|
41
|
+
/** Page explaining how to mint the token. */
|
|
42
|
+
readonly url: string;
|
|
43
|
+
readonly instructions: string;
|
|
44
|
+
/** The pasted token, verified; null when malformed or refused. */
|
|
45
|
+
exchange(input: string): Promise<TokenSet | null>;
|
|
46
|
+
}
|
|
34
47
|
/** A device-code login (RFC 8628) in flight: where to approve it, and the wait for approval. */
|
|
35
48
|
export interface DeviceLogin {
|
|
36
49
|
/** Verification URL to open (the user code rides in it). */
|
|
@@ -108,6 +121,20 @@ export interface ProviderAdapter {
|
|
|
108
121
|
* instead of cooling it down — no refresh can repair a revoked key.
|
|
109
122
|
*/
|
|
110
123
|
tokensFromApiKey?(key: string): TokenSet;
|
|
124
|
+
/**
|
|
125
|
+
* Present on providers whose own CLI mints a long-lived bearer (Claude's
|
|
126
|
+
* `claude setup-token`: one year, inference-only). Registered as a paste
|
|
127
|
+
* login after the OAuth one; the token joins the pool as a static row —
|
|
128
|
+
* no refresh token, so it never rotates and a 401 parks it for a re-login.
|
|
129
|
+
*/
|
|
130
|
+
tokenLogin?: TokenLogin;
|
|
131
|
+
/**
|
|
132
|
+
* Present on providers whose OAuth login can mint the bearer `tokenLogin`
|
|
133
|
+
* takes (Claude): spends `refreshToken` like `refresh`, returning the
|
|
134
|
+
* minted token as `access`/`expires` beside the rotated refresh token.
|
|
135
|
+
* Throws on failure.
|
|
136
|
+
*/
|
|
137
|
+
mintInferenceToken?(refreshToken: string): Promise<TokenSet>;
|
|
111
138
|
/** Set auth + provider headers on an outgoing request for the chosen account. */
|
|
112
139
|
applyAuth(headers: Headers, account: PoolAccount): void;
|
|
113
140
|
/** Optionally rewrite the request URL (e.g. add ?beta=true, swap base URL). */
|
package/dist/refresh.d.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import type { ProviderAdapter } from './providers/types';
|
|
2
2
|
import { type PoolAccount } from './types';
|
|
3
|
+
/** The single-use refresh token + the generation it was read at (the CAS key). */
|
|
4
|
+
export interface RefreshAttempt {
|
|
5
|
+
readonly refresh: string;
|
|
6
|
+
readonly gen: number;
|
|
7
|
+
}
|
|
3
8
|
export declare function needsRefresh(account: PoolAccount, now: number): boolean;
|
|
9
|
+
export declare function genOf(account: PoolAccount): number;
|
|
10
|
+
export declare function sameGeneration(account: PoolAccount, attempt: RefreshAttempt): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Run `fn` holding the account's refresh lock — taken by everything that
|
|
13
|
+
* spends its single-use refresh token (a refresh, a token mint).
|
|
14
|
+
*/
|
|
15
|
+
export declare function withRefreshLock<T>(providerID: string, accountId: string, fn: () => Promise<T>): Promise<T>;
|
|
4
16
|
/**
|
|
5
17
|
* Ensure the account has a fresh access token, refreshing if needed. The rotated
|
|
6
18
|
* refresh token is persisted immediately. On a genuine invalid_grant (the current
|
package/dist/status.d.ts
CHANGED
|
@@ -12,6 +12,12 @@ interface AccountStatus {
|
|
|
12
12
|
/** tier name → epoch ms until that model tier's cap resets (empty = none). */
|
|
13
13
|
modelCooldownsUntil: Record<string, number>;
|
|
14
14
|
disabledReason: string | null;
|
|
15
|
+
/** A setup-token row's credentials: `token`, `token+oauth`, or '' for any other row. */
|
|
16
|
+
login: string;
|
|
17
|
+
/** `oauth re-login` / `token re-login` / `oauth expires Nd` on a row still in service. */
|
|
18
|
+
warnings: string[];
|
|
19
|
+
/** Why each lost login stopped working, printed under the table. */
|
|
20
|
+
lostNotes: string[];
|
|
15
21
|
/** The account that most recently served a request for this provider. */
|
|
16
22
|
current: boolean;
|
|
17
23
|
/** 1 = next candidate the scheduler would pick. */
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ProviderAdapter } from '../providers/types';
|
|
2
|
+
import type { PoolAccount, PoolFile, TokenSet } from '../types';
|
|
3
|
+
import { type ImportJob, type MergeMemory, type MergePlan } from './merge';
|
|
4
|
+
import { type ParsedPayload } from './payload';
|
|
5
|
+
import { type ImportedRef, type SkippedRef } from './state';
|
|
6
|
+
export interface ApplyResult {
|
|
7
|
+
/** The imported map after this plan. */
|
|
8
|
+
imported: Record<string, ImportedRef>;
|
|
9
|
+
/** The skipped map after this plan. */
|
|
10
|
+
skipped: Record<string, SkippedRef>;
|
|
11
|
+
added: number;
|
|
12
|
+
updated: number;
|
|
13
|
+
removed: number;
|
|
14
|
+
/** Entries that could not be landed yet (the provider could not be reached, or refused the secret). */
|
|
15
|
+
deferred: number;
|
|
16
|
+
}
|
|
17
|
+
/** What became of one import: the row it landed on, or left alone because the account already has a credential. */
|
|
18
|
+
export type Landing = {
|
|
19
|
+
kind: 'placed';
|
|
20
|
+
row: PoolAccount;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'skipped';
|
|
23
|
+
blocker: string;
|
|
24
|
+
holds: string;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Land one verified credential, deciding everything from the pool as it is
|
|
28
|
+
* under the lock (callers run this inside `mutatePool`):
|
|
29
|
+
* - a row of this provider that already holds the secret (or, for a key, the
|
|
30
|
+
* same account) is the user's own, whatever the plan saw earlier;
|
|
31
|
+
* - for a Claude token, a row that already serves the same account with a
|
|
32
|
+
* working token of its own, minted here, pasted, or imported from another
|
|
33
|
+
* entry, is never replaced: two machines that each hold a token for one
|
|
34
|
+
* account would otherwise trade them for ever. The entry is skipped;
|
|
35
|
+
* - in both cases the old imported secret (if the entry is a rotation) is
|
|
36
|
+
* taken back, and the row in the way is reported, so the entry is not
|
|
37
|
+
* probed again until that row changes;
|
|
38
|
+
* - an earlier import still held by its row is replaced in place, but only by
|
|
39
|
+
* a credential of the same provider; if the entry changed provider the old
|
|
40
|
+
* one is taken back and the new one placed on its own;
|
|
41
|
+
* - anything else is placed by the shared pairing (`placeTokens`), with the
|
|
42
|
+
* provider-confirmed identity of the probe only.
|
|
43
|
+
*/
|
|
44
|
+
export declare function land(pool: PoolFile, { entry, previous }: ImportJob, tokens: TokenSet): Landing;
|
|
45
|
+
/**
|
|
46
|
+
* Carry out a merge plan. Each entry is landed independently: one failing
|
|
47
|
+
* (a probe that cannot be reached, a pool write that throws) is deferred and
|
|
48
|
+
* never blocks the rest, and what was already landed stays recorded.
|
|
49
|
+
*/
|
|
50
|
+
export declare function applyPlan(plan: MergePlan, memory: Pick<MergeMemory, 'imported' | 'skipped'>, adapters?: readonly ProviderAdapter[]): Promise<ApplyResult>;
|
|
51
|
+
/** What `applySnapshot` takes besides the snapshot and the memory. */
|
|
52
|
+
export interface SnapshotOptions {
|
|
53
|
+
adapters?: readonly ProviderAdapter[];
|
|
54
|
+
/**
|
|
55
|
+
* Called with the running result after every pass, before the next one: the
|
|
56
|
+
* pool has changed by then, so the record of what was imported has to be
|
|
57
|
+
* saved by then too, or a later failure would leave rows nobody tracks.
|
|
58
|
+
*/
|
|
59
|
+
afterPass?: (soFar: ApplyResult) => Promise<unknown>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Bring the pool in line with `snapshot`. After a pass that removed something
|
|
63
|
+
* the plan is made again, so an entry that was kept out by the credential just
|
|
64
|
+
* removed is imported in the same cycle instead of the next one.
|
|
65
|
+
*/
|
|
66
|
+
export declare function applySnapshot(snapshot: Pick<ParsedPayload, 'entries' | 'listed'>, memory: MergeMemory, options?: SnapshotOptions): Promise<ApplyResult>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Blob format version; a reader refuses any other. */
|
|
2
|
+
export declare const BLOB_VERSION = 1;
|
|
3
|
+
/** Largest blob (and gist file) a reader accepts. */
|
|
4
|
+
export declare const MAX_BLOB_BYTES: number;
|
|
5
|
+
/** A fresh random AES-256 key. */
|
|
6
|
+
export declare function generateKey(): Buffer;
|
|
7
|
+
/** The key as it rides in the link's fragment. */
|
|
8
|
+
export declare function encodeKey(key: Buffer): string;
|
|
9
|
+
/** The key of a link fragment, or null when it is not a 32-byte base64url key. */
|
|
10
|
+
export declare function decodeKey(text: string): Buffer | null;
|
|
11
|
+
/** A short stable digest, used to recognize a credential without keeping it. */
|
|
12
|
+
export declare function fingerprint(secret: string): string;
|
|
13
|
+
/** Encrypt `plaintext` into the blob stored in the gist: a fresh nonce on every call. */
|
|
14
|
+
export declare function seal(plaintext: string, key: Buffer): string;
|
|
15
|
+
/**
|
|
16
|
+
* Decrypt a blob. Every failure is a typed `SyncError` that says nothing
|
|
17
|
+
* about the content: a wrong key and a tampered blob look the same by design.
|
|
18
|
+
*/
|
|
19
|
+
export declare function open(text: string, key: Buffer): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ProviderAdapter } from '../providers/types';
|
|
2
|
+
import { type GhRunner } from './github-auth';
|
|
3
|
+
export interface CycleDeps {
|
|
4
|
+
now: () => number;
|
|
5
|
+
runGh?: GhRunner;
|
|
6
|
+
adapters?: readonly ProviderAdapter[];
|
|
7
|
+
}
|
|
8
|
+
/** What the cycle tells the scheduler: how soon to try again if it fails. */
|
|
9
|
+
export interface CycleReport {
|
|
10
|
+
retryMs: number;
|
|
11
|
+
}
|
|
12
|
+
/** After GitHub refuses the account's write, nothing is attempted for this long (downloads go on). */
|
|
13
|
+
export declare const WRITE_BACKOFF_MS = 3600000;
|
|
14
|
+
/**
|
|
15
|
+
* Run one cycle. `force` is "Upload now": it needs a GitHub token (and says so
|
|
16
|
+
* if there is none), reads the gist afresh, and tries to write even inside the
|
|
17
|
+
* back-off after a refusal. Never writes without the lock the caller holds.
|
|
18
|
+
*/
|
|
19
|
+
export declare function runCycle(deps: CycleDeps, force: boolean, report: CycleReport): Promise<string>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ProviderAdapter } from '../providers/types';
|
|
2
|
+
import { type GhRunner } from './github-auth';
|
|
3
|
+
export interface EngineDeps {
|
|
4
|
+
now: () => number;
|
|
5
|
+
runGh?: GhRunner;
|
|
6
|
+
adapters?: readonly ProviderAdapter[];
|
|
7
|
+
}
|
|
8
|
+
/** What an operation did; `backoffMs` asks the caller to leave GitHub alone that long. */
|
|
9
|
+
export interface Outcome {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
message: string;
|
|
12
|
+
backoffMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface SyncEngine {
|
|
15
|
+
/** One full cycle: download and apply, then upload this machine's own changes when it may. */
|
|
16
|
+
sync(reqAt?: number): Promise<Outcome>;
|
|
17
|
+
/**
|
|
18
|
+
* "Upload now": a cycle that insists on writing (it needs a GitHub token),
|
|
19
|
+
* or with `fresh` (or when this machine follows nothing) a new gist.
|
|
20
|
+
*/
|
|
21
|
+
upload(fresh: boolean, reqAt?: number): Promise<Outcome>;
|
|
22
|
+
/** Start following a gist link, then sync it once. */
|
|
23
|
+
subscribe(link: string, reqAt?: number): Promise<Outcome>;
|
|
24
|
+
/** Stop syncing; imported accounts stay. */
|
|
25
|
+
forget(reqAt?: number): Promise<Outcome>;
|
|
26
|
+
/**
|
|
27
|
+
* The digest of this machine's own credentials when the gist may be out of
|
|
28
|
+
* date and this machine may write, else null.
|
|
29
|
+
*/
|
|
30
|
+
unpublishedDigest(): Promise<string | null>;
|
|
31
|
+
/**
|
|
32
|
+
* The scheduled cycle. Call it with the cycle lock held: it looks at the
|
|
33
|
+
* persisted state again and returns null (does nothing) unless this machine
|
|
34
|
+
* still follows gist gistId and a cycle is due.
|
|
35
|
+
*/
|
|
36
|
+
backgroundPoll(gistId: string): Promise<Outcome | null>;
|
|
37
|
+
/**
|
|
38
|
+
* The automatic upload, decided again under the lock: null unless this
|
|
39
|
+
* machine still follows gist gistId, no back-off is running, and its
|
|
40
|
+
* credentials still differ from the upload by digest.
|
|
41
|
+
*/
|
|
42
|
+
backgroundPublish(gistId: string, digest: string): Promise<Outcome | null>;
|
|
43
|
+
}
|
|
44
|
+
export declare function createEngine(deps: EngineDeps): SyncEngine;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type SyncErrorCode = 'bad-link' | 'not-set-up' | 'bad-blob' | 'bad-version' | 'decrypt' | 'rolled-back' | 'too-large' | 'rate-limited' | 'not-found' | 'network' | 'no-auth' | 'http' | 'write-denied';
|
|
2
|
+
/** A sync failure with a fixed, non-leaky message; `retryAfterMs` accompanies `rate-limited`. */
|
|
3
|
+
export declare class SyncError extends Error {
|
|
4
|
+
readonly code: SyncErrorCode;
|
|
5
|
+
readonly retryAfterMs?: number | undefined;
|
|
6
|
+
constructor(code: SyncErrorCode, retryAfterMs?: number | undefined);
|
|
7
|
+
}
|
|
8
|
+
/** The user-facing line for any thrown value: a sync error's own text, else a generic one. */
|
|
9
|
+
export declare function describeSyncError(error: unknown): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The one file a sync gist holds. */
|
|
2
|
+
export declare const GIST_FILE = "auth-load-balancer-sync.json";
|
|
3
|
+
export declare const GIST_ID_RE: RegExp;
|
|
4
|
+
export interface GistLink {
|
|
5
|
+
id: string;
|
|
6
|
+
key: Buffer;
|
|
7
|
+
}
|
|
8
|
+
/** The id and key of a `https://gist.github.com/<user>/<id>#<key>` link, or null for anything else. */
|
|
9
|
+
export declare function parseGistLink(link: string): GistLink | null;
|
|
10
|
+
/** The link to share: the gist's address plus the key as its fragment. */
|
|
11
|
+
export declare function formatGistLink(owner: string | undefined, id: string, key: Buffer): string;
|
|
12
|
+
export interface CreatedGist {
|
|
13
|
+
id: string;
|
|
14
|
+
owner?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Create a secret gist holding `content`. */
|
|
17
|
+
export declare function createGist(token: string, content: string, now?: number): Promise<CreatedGist>;
|
|
18
|
+
/** Replace the sync file of gist `id` with `content`. */
|
|
19
|
+
export declare function updateGist(token: string, id: string, content: string, now?: number): Promise<void>;
|
|
20
|
+
export type GistRead = {
|
|
21
|
+
changed: false;
|
|
22
|
+
} | {
|
|
23
|
+
changed: true;
|
|
24
|
+
content: string;
|
|
25
|
+
etag?: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Read the sync file of gist `id`; a matching `etag` answers `changed: false`
|
|
29
|
+
* cheaply. With a `token` the read is authenticated (GitHub's own limit is far
|
|
30
|
+
* higher than the 60 an hour an anonymous address gets); a token GitHub
|
|
31
|
+
* refuses is not sent again, and the gist is read without credentials.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readGist(id: string, etag?: string, now?: number, token?: string): Promise<GistRead>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Runs `gh auth token`; resolves its stdout, rejects when gh is missing, logged out, or slow. */
|
|
2
|
+
export type GhRunner = () => Promise<string>;
|
|
3
|
+
/** The slice of `execFile` used here, so tests never spawn the real gh. */
|
|
4
|
+
export type ExecFn = (file: string, args: string[], options: {
|
|
5
|
+
timeout: number;
|
|
6
|
+
windowsHide: boolean;
|
|
7
|
+
maxBuffer: number;
|
|
8
|
+
}, callback: (error: Error | null, stdout: string) => void) => unknown;
|
|
9
|
+
/** `execFile` with no shell: nothing to inject into, and the token never touches a command line. */
|
|
10
|
+
export declare function ghAuthToken(exec?: ExecFn): Promise<string>;
|
|
11
|
+
/**
|
|
12
|
+
* The GitHub token to upload with: `GITHUB_TOKEN`, then `GH_TOKEN`, then the
|
|
13
|
+
* gh CLI's. Null when none exists. Only uploading needs it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function discoverGithubToken(run?: GhRunner, env?: NodeJS.ProcessEnv): Promise<string | null>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type SyncAction = 'upload' | 'upload-new' | 'subscribe' | 'sync' | 'forget';
|
|
2
|
+
/** A TUI request is dead after this long, so an abandoned one cannot fire later. */
|
|
3
|
+
export declare const INTENT_TTL_MS: number;
|
|
4
|
+
/** A request stamped further ahead than this is not from a sane clock; it would otherwise outlive the TTL. */
|
|
5
|
+
export declare const INTENT_SKEW_MS = 60000;
|
|
6
|
+
/**
|
|
7
|
+
* A request the TUI hands the server through a file beside the pool (the TUI
|
|
8
|
+
* cannot import the plugin). `link` rides along for `subscribe` and holds the
|
|
9
|
+
* key, so the file is claimed and deleted the moment the server reads it.
|
|
10
|
+
*/
|
|
11
|
+
export interface SyncIntent {
|
|
12
|
+
action: SyncAction;
|
|
13
|
+
at: number;
|
|
14
|
+
link?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Whether a request is waiting (without claiming it). */
|
|
17
|
+
export declare function intentPending(): Promise<boolean>;
|
|
18
|
+
/**
|
|
19
|
+
* Claim the pending request, if any. Under a lock the file is renamed to a
|
|
20
|
+
* name of our own and THAT file is read and deleted, so of several opencode
|
|
21
|
+
* processes exactly one acts on it, and a newer request the TUI writes while
|
|
22
|
+
* we read is a different file that is left alone. (A bare rename is not an
|
|
23
|
+
* exclusive claim on Windows: two concurrent renames of one file can both
|
|
24
|
+
* succeed, which is why it runs under the lock.) The claimed file is deleted
|
|
25
|
+
* even when it does not parse. fterClaim is a test seam: it runs once the
|
|
26
|
+
* request is ours and before it is read, which is where the TUI can write a
|
|
27
|
+
* newer one.
|
|
28
|
+
*/
|
|
29
|
+
export declare function takeIntent(now: number, waitMs?: number, afterClaim?: () => Promise<void>): Promise<SyncIntent | null>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { SyncEngine } from './engine';
|
|
2
|
+
import { type SyncIntent } from './intent';
|
|
3
|
+
export { POLL_MS, PUBLISH_RETRY_MS } from './timing';
|
|
4
|
+
export declare const TICK_MS = 1000;
|
|
5
|
+
export declare const CHANGE_CHECK_MS = 5000;
|
|
6
|
+
export declare const DEBOUNCE_MS = 10000;
|
|
7
|
+
export interface LoopTimers {
|
|
8
|
+
set(run: () => void, ms: number): unknown;
|
|
9
|
+
clear(handle: unknown): void;
|
|
10
|
+
}
|
|
11
|
+
/** Timers that never keep the process alive. */
|
|
12
|
+
export declare const realTimers: LoopTimers;
|
|
13
|
+
/**
|
|
14
|
+
* Run `fn` unless another opencode process is mid-run; resolves undefined then.
|
|
15
|
+
* `staleMs` is four heartbeats: a live holder touches the lock every 5 s, so it
|
|
16
|
+
* is never stale, while a holder that died (a process killed mid-run) stops
|
|
17
|
+
* blocking the machine after 20 s instead of a minute.
|
|
18
|
+
*/
|
|
19
|
+
export declare function withCycleLock<T>(fn: () => Promise<T>, waitMs: number): Promise<T | undefined>;
|
|
20
|
+
/** Where the TUI's requests come from; the file handshake unless a test replaces it. */
|
|
21
|
+
export interface IntentSource {
|
|
22
|
+
pending(): Promise<boolean>;
|
|
23
|
+
take(now: number): Promise<SyncIntent | null>;
|
|
24
|
+
}
|
|
25
|
+
export interface LoopDeps {
|
|
26
|
+
engine: SyncEngine;
|
|
27
|
+
now: () => number;
|
|
28
|
+
timers?: LoopTimers;
|
|
29
|
+
intents?: IntentSource;
|
|
30
|
+
lockWaitMs?: number;
|
|
31
|
+
disposeWaitMs?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface SyncLoop {
|
|
34
|
+
start(): void;
|
|
35
|
+
/** Stop ticking and wait (bounded) for the step in flight, so its lock is released. */
|
|
36
|
+
dispose(): Promise<void>;
|
|
37
|
+
/** One scheduler step; exposed so tests drive time themselves. */
|
|
38
|
+
tick(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
export declare function createSyncLoop(deps: LoopDeps): SyncLoop;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { PoolAccount } from '../types';
|
|
2
|
+
import { type ParsedPayload, type SyncEntry } from './payload';
|
|
3
|
+
import type { ImportedRef, SkippedRef } from './state';
|
|
4
|
+
/** An entry to land locally; `previous` is the row an earlier value of it was imported into. */
|
|
5
|
+
export interface ImportJob {
|
|
6
|
+
entry: SyncEntry;
|
|
7
|
+
previous?: ImportedRef;
|
|
8
|
+
}
|
|
9
|
+
/** What sync remembers about earlier cycles, and which machine this is. */
|
|
10
|
+
export interface MergeMemory {
|
|
11
|
+
origin: string;
|
|
12
|
+
imported: Readonly<Record<string, ImportedRef>>;
|
|
13
|
+
skipped: Readonly<Record<string, SkippedRef>>;
|
|
14
|
+
}
|
|
15
|
+
export interface MergePlan {
|
|
16
|
+
imports: ImportJob[];
|
|
17
|
+
/** Imported entries the gist no longer lists: entry key and where it landed. */
|
|
18
|
+
drops: [string, ImportedRef][];
|
|
19
|
+
/** Skip records of entries the gist no longer lists. */
|
|
20
|
+
forgets: string[];
|
|
21
|
+
}
|
|
22
|
+
/** Whether `row` carries the credential with this fingerprint as its static one. */
|
|
23
|
+
export declare function holdsFingerprint(row: PoolAccount, digest: string): boolean;
|
|
24
|
+
/** The digest of the static credential a row carries, or '' when it has none. */
|
|
25
|
+
export declare function staticFingerprint(row: PoolAccount): string;
|
|
26
|
+
/** Whether the row that kept an entry out is still in the way. */
|
|
27
|
+
export declare function blockerStands(accounts: readonly PoolAccount[], skip: SkippedRef): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Whether a skipped entry should be looked at again because the row that kept
|
|
30
|
+
* it out is gone, or no longer holds what it held.
|
|
31
|
+
*/
|
|
32
|
+
export declare function hasFreedSkips(accounts: readonly PoolAccount[], skipped: Readonly<Record<string, SkippedRef>>): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* What to do with the gist's entries, given what was imported before. Pure:
|
|
35
|
+
* - this machine's own entries (its origin) are never imported back;
|
|
36
|
+
* - a new entry is imported, unless a local row already holds its secret
|
|
37
|
+
* (that row is the user's own and sync never tracks, so never removes, it);
|
|
38
|
+
* - an entry that was skipped because the account already had a credential is
|
|
39
|
+
* not probed again while that credential is still in the way;
|
|
40
|
+
* - an entry whose secret changed is imported again, onto the row it was in,
|
|
41
|
+
* unless a local row already holds the new secret, which stays the user's
|
|
42
|
+
* own: the old imported secret is taken back and the entry is forgotten;
|
|
43
|
+
* - an entry whose secret is unchanged is left alone, so a row the user
|
|
44
|
+
* deleted or re-pointed stays that way until the gist changes it;
|
|
45
|
+
* - an imported entry the gist no longer lists is dropped. An entry that is
|
|
46
|
+
* listed but failed validation is NOT "no longer listed": it is left as is.
|
|
47
|
+
* Rows that were never imported are not reachable from a plan.
|
|
48
|
+
*/
|
|
49
|
+
export declare function planMerge(accounts: readonly PoolAccount[], snapshot: Pick<ParsedPayload, 'entries' | 'listed'>, memory: MergeMemory): MergePlan;
|