@hienlh/ppm 0.7.8 → 0.7.10
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/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +46 -0
- package/LICENSE +21 -0
- package/README.md +34 -1
- package/bun.lock +1 -0
- package/dist/web/assets/ai-settings-section-BxCMGg-I.js +1 -0
- package/dist/web/assets/chat-tab-R_8ZfOG8.js +7 -0
- package/dist/web/assets/{code-editor-1FNaZKfA.js → code-editor-BbhIHbts.js} +1 -1
- package/dist/web/assets/{database-viewer-Hso-EwQH.js → database-viewer-BJYmlnr2.js} +1 -1
- package/dist/web/assets/{diff-viewer-BG2UNjTZ.js → diff-viewer-CS-wesGq.js} +1 -1
- package/dist/web/assets/{git-graph-DK_yDfWe.js → git-graph-B9eaNltz.js} +1 -1
- package/dist/web/assets/index-qElHXk-7.js +28 -0
- package/dist/web/assets/index-sMxUHxFZ.css +2 -0
- package/dist/web/assets/input-CVIzrYsH.js +41 -0
- package/dist/web/assets/keybindings-store-DrBQMVKg.js +1 -0
- package/dist/web/assets/{markdown-renderer-Xe_wjdJH.js → markdown-renderer-DpIu7iOT.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CguN1z3q.js → postgres-viewer-B5-tRXE2.js} +1 -1
- package/dist/web/assets/settings-tab-3-ewawy0.js +1 -0
- package/dist/web/assets/{sqlite-viewer-VrZiiegZ.js → sqlite-viewer-CfIer2x_.js} +1 -1
- package/dist/web/assets/{terminal-tab-CabMjIRO.js → terminal-tab-qJxp0iOK.js} +2 -2
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +16 -5
- package/docs/system-architecture.md +20 -2
- package/package.json +4 -1
- package/src/lib/account-crypto.ts +53 -0
- package/src/providers/claude-agent-sdk.ts +77 -3
- package/src/server/index.ts +8 -0
- package/src/server/routes/accounts.ts +165 -0
- package/src/server/routes/chat.ts +2 -0
- package/src/services/account-selector.service.ts +109 -0
- package/src/services/account.service.ts +411 -0
- package/src/services/claude-usage.service.ts +186 -124
- package/src/services/db.service.ts +117 -3
- package/src/types/chat.ts +2 -0
- package/src/web/app.tsx +0 -4
- package/src/web/components/chat/chat-history-bar.tsx +3 -0
- package/src/web/components/chat/usage-badge.tsx +86 -12
- package/src/web/components/settings/accounts-settings-section.tsx +358 -0
- package/src/web/components/settings/settings-tab.tsx +11 -0
- package/src/web/components/ui/badge.tsx +36 -0
- package/src/web/components/ui/switch.tsx +27 -0
- package/src/web/hooks/use-usage.ts +1 -1
- package/src/web/lib/api-settings.ts +65 -0
- package/dist/web/assets/ai-settings-section-ByRvOONz.js +0 -1
- package/dist/web/assets/chat-tab-DLfy6CBX.js +0 -7
- package/dist/web/assets/index-4pPCbWJp.css +0 -2
- package/dist/web/assets/index-DaQYRomz.js +0 -29
- package/dist/web/assets/input-P_K5CUiy.js +0 -41
- package/dist/web/assets/keybindings-store-xe6f5O18.js +0 -1
- package/dist/web/assets/settings-tab-CHONXRsW.js +0 -1
- package/src/web/hooks/use-health-check.ts +0 -95
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { accountService, type AccountWithTokens } from "./account.service.ts";
|
|
2
|
+
import { getConfigValue, setConfigValue } from "./db.service.ts";
|
|
3
|
+
|
|
4
|
+
export type AccountStrategy = "round-robin" | "fill-first";
|
|
5
|
+
|
|
6
|
+
const STRATEGY_CONFIG_KEY = "account_strategy";
|
|
7
|
+
const MAX_RETRY_CONFIG_KEY = "account_max_retry";
|
|
8
|
+
|
|
9
|
+
const BACKOFF_BASE_MS = 1_000;
|
|
10
|
+
const BACKOFF_MAX_MS = 30 * 60_000;
|
|
11
|
+
|
|
12
|
+
class AccountSelectorService {
|
|
13
|
+
private cursor = 0;
|
|
14
|
+
private retryCounts = new Map<string, number>();
|
|
15
|
+
private _lastPickedId: string | null = null;
|
|
16
|
+
|
|
17
|
+
/** ID of the last account returned by next() */
|
|
18
|
+
get lastPickedId(): string | null {
|
|
19
|
+
return this._lastPickedId;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getStrategy(): AccountStrategy {
|
|
23
|
+
return (getConfigValue(STRATEGY_CONFIG_KEY) as AccountStrategy) ?? "round-robin";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setStrategy(strategy: AccountStrategy): void {
|
|
27
|
+
setConfigValue(STRATEGY_CONFIG_KEY, strategy);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getMaxRetry(): number {
|
|
31
|
+
const v = getConfigValue(MAX_RETRY_CONFIG_KEY);
|
|
32
|
+
return v ? parseInt(v, 10) : 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setMaxRetry(n: number): void {
|
|
36
|
+
setConfigValue(MAX_RETRY_CONFIG_KEY, String(n));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pick next available account (skips cooldown/disabled).
|
|
41
|
+
* Returns null if no active accounts available.
|
|
42
|
+
*/
|
|
43
|
+
next(): AccountWithTokens | null {
|
|
44
|
+
const now = Math.floor(Date.now() / 1000);
|
|
45
|
+
const allAccounts = accountService.list();
|
|
46
|
+
|
|
47
|
+
// Clear expired cooldowns
|
|
48
|
+
for (const acc of allAccounts) {
|
|
49
|
+
if (acc.status === "cooldown" && acc.cooldownUntil && acc.cooldownUntil <= now) {
|
|
50
|
+
accountService.setEnabled(acc.id);
|
|
51
|
+
this.retryCounts.delete(acc.id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const active = accountService.list().filter((a) => a.status === "active");
|
|
56
|
+
if (active.length === 0) return null;
|
|
57
|
+
|
|
58
|
+
let pickedId: string;
|
|
59
|
+
if (this.getStrategy() === "fill-first") {
|
|
60
|
+
const sorted = [...active].sort((a, b) => b.priority - a.priority || a.createdAt - b.createdAt);
|
|
61
|
+
pickedId = sorted[0].id;
|
|
62
|
+
} else {
|
|
63
|
+
// Round-robin
|
|
64
|
+
this.cursor = this.cursor % active.length;
|
|
65
|
+
pickedId = active[this.cursor].id;
|
|
66
|
+
this.cursor = (this.cursor + 1) % active.length;
|
|
67
|
+
}
|
|
68
|
+
this._lastPickedId = pickedId;
|
|
69
|
+
return accountService.getWithTokens(pickedId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Called when account receives 429 — apply exponential backoff */
|
|
73
|
+
onRateLimit(accountId: string): void {
|
|
74
|
+
const retries = (this.retryCounts.get(accountId) ?? 0) + 1;
|
|
75
|
+
this.retryCounts.set(accountId, retries);
|
|
76
|
+
const backoffMs = Math.min(BACKOFF_BASE_MS * Math.pow(2, retries - 1), BACKOFF_MAX_MS);
|
|
77
|
+
const cooldownUntilMs = Date.now() + backoffMs;
|
|
78
|
+
accountService.setCooldown(accountId, cooldownUntilMs);
|
|
79
|
+
console.log(`[accounts] ${accountId} rate limited — cooldown ${Math.round(backoffMs / 1000)}s (retry #${retries})`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Called when 401 Unauthorized — disable account */
|
|
83
|
+
onAuthError(accountId: string): void {
|
|
84
|
+
console.log(`[accounts] ${accountId} auth error — disabling account`);
|
|
85
|
+
accountService.setDisabled(accountId);
|
|
86
|
+
this.retryCounts.delete(accountId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Called on successful request — reset retry count + track usage */
|
|
90
|
+
onSuccess(accountId: string): void {
|
|
91
|
+
this.retryCounts.delete(accountId);
|
|
92
|
+
accountService.trackUsage(accountId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** How many accounts are active or have expired cooldowns right now */
|
|
96
|
+
activeCount(): number {
|
|
97
|
+
const now = Math.floor(Date.now() / 1000);
|
|
98
|
+
return accountService.list().filter(
|
|
99
|
+
(a) => a.status === "active" || (a.status === "cooldown" && (a.cooldownUntil ?? 0) <= now),
|
|
100
|
+
).length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True if multi-account mode is enabled (≥1 account in DB) */
|
|
104
|
+
isEnabled(): boolean {
|
|
105
|
+
return accountService.list().length > 0;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const accountSelector = new AccountSelectorService();
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { randomUUID, createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { encrypt, decrypt } from "../lib/account-crypto.ts";
|
|
3
|
+
import {
|
|
4
|
+
getAccounts,
|
|
5
|
+
getAccountById,
|
|
6
|
+
insertAccount,
|
|
7
|
+
updateAccount,
|
|
8
|
+
deleteAccount,
|
|
9
|
+
incrementAccountRequests,
|
|
10
|
+
type AccountRow,
|
|
11
|
+
} from "./db.service.ts";
|
|
12
|
+
|
|
13
|
+
export interface Account {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string | null;
|
|
16
|
+
email: string | null;
|
|
17
|
+
expiresAt: number | null;
|
|
18
|
+
status: "active" | "cooldown" | "disabled";
|
|
19
|
+
cooldownUntil: number | null;
|
|
20
|
+
priority: number;
|
|
21
|
+
totalRequests: number;
|
|
22
|
+
lastUsedAt: number | null;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AccountWithTokens extends Account {
|
|
27
|
+
accessToken: string;
|
|
28
|
+
refreshToken: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
32
|
+
const OAUTH_AUTH_URL = "https://claude.ai/oauth/authorize";
|
|
33
|
+
const OAUTH_TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
|
|
34
|
+
const OAUTH_SCOPE = "org:create_api_key user:profile user:inference";
|
|
35
|
+
|
|
36
|
+
class AccountService {
|
|
37
|
+
private pendingStates = new Map<string, { verifier: string; createdAt: number }>();
|
|
38
|
+
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
39
|
+
|
|
40
|
+
private toAccount(row: AccountRow): Account {
|
|
41
|
+
return {
|
|
42
|
+
id: row.id,
|
|
43
|
+
label: row.label,
|
|
44
|
+
email: row.email,
|
|
45
|
+
expiresAt: row.expires_at,
|
|
46
|
+
status: row.status,
|
|
47
|
+
cooldownUntil: row.cooldown_until,
|
|
48
|
+
priority: row.priority,
|
|
49
|
+
totalRequests: row.total_requests,
|
|
50
|
+
lastUsedAt: row.last_used_at,
|
|
51
|
+
createdAt: row.created_at,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private toAccountWithTokens(row: AccountRow): AccountWithTokens {
|
|
56
|
+
return {
|
|
57
|
+
...this.toAccount(row),
|
|
58
|
+
accessToken: decrypt(row.access_token),
|
|
59
|
+
refreshToken: decrypt(row.refresh_token),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
list(): Account[] {
|
|
64
|
+
return getAccounts().map((r) => this.toAccount(r));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getWithTokens(id: string): AccountWithTokens | null {
|
|
68
|
+
const row = getAccountById(id);
|
|
69
|
+
return row ? this.toAccountWithTokens(row) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
add(params: {
|
|
73
|
+
email: string;
|
|
74
|
+
accessToken: string;
|
|
75
|
+
refreshToken: string;
|
|
76
|
+
expiresAt: number;
|
|
77
|
+
label?: string;
|
|
78
|
+
}): Account {
|
|
79
|
+
const id = randomUUID();
|
|
80
|
+
insertAccount({
|
|
81
|
+
id,
|
|
82
|
+
label: params.label ?? null,
|
|
83
|
+
email: params.email,
|
|
84
|
+
access_token: encrypt(params.accessToken),
|
|
85
|
+
refresh_token: encrypt(params.refreshToken),
|
|
86
|
+
expires_at: params.expiresAt,
|
|
87
|
+
status: "active",
|
|
88
|
+
cooldown_until: null,
|
|
89
|
+
priority: 0,
|
|
90
|
+
total_requests: 0,
|
|
91
|
+
last_used_at: null,
|
|
92
|
+
});
|
|
93
|
+
return this.toAccount(getAccountById(id)!);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async verifyToken(token: string): Promise<{
|
|
97
|
+
valid: boolean;
|
|
98
|
+
email?: string;
|
|
99
|
+
orgName?: string;
|
|
100
|
+
subscriptionType?: string;
|
|
101
|
+
authMethod?: string;
|
|
102
|
+
}> {
|
|
103
|
+
const isOAuth = token.startsWith("sk-ant-oat");
|
|
104
|
+
|
|
105
|
+
if (isOAuth) {
|
|
106
|
+
// Verify via usage API — 200/429 = valid, 401/403 = invalid
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
109
|
+
headers: {
|
|
110
|
+
Accept: "application/json",
|
|
111
|
+
Authorization: `Bearer ${token}`,
|
|
112
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
113
|
+
"User-Agent": "ppm/1.0",
|
|
114
|
+
},
|
|
115
|
+
signal: AbortSignal.timeout(10_000),
|
|
116
|
+
});
|
|
117
|
+
// 200 = valid, 429 = rate limited but valid token
|
|
118
|
+
if (res.status === 200 || res.status === 429) {
|
|
119
|
+
return { valid: true, authMethod: "oauth_token" };
|
|
120
|
+
}
|
|
121
|
+
return { valid: false };
|
|
122
|
+
} catch {
|
|
123
|
+
return { valid: false };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// API key: verify via claude auth status
|
|
128
|
+
try {
|
|
129
|
+
const proc = Bun.spawn(["claude", "auth", "status"], {
|
|
130
|
+
env: { ...process.env, ANTHROPIC_API_KEY: token, CLAUDE_CODE_OAUTH_TOKEN: "" },
|
|
131
|
+
stdout: "pipe",
|
|
132
|
+
stderr: "pipe",
|
|
133
|
+
});
|
|
134
|
+
const stdout = await new Response(proc.stdout).text();
|
|
135
|
+
await proc.exited;
|
|
136
|
+
const info = JSON.parse(stdout) as {
|
|
137
|
+
loggedIn?: boolean;
|
|
138
|
+
email?: string;
|
|
139
|
+
orgName?: string;
|
|
140
|
+
subscriptionType?: string;
|
|
141
|
+
authMethod?: string;
|
|
142
|
+
};
|
|
143
|
+
if (!info.loggedIn) return { valid: false };
|
|
144
|
+
return {
|
|
145
|
+
valid: true,
|
|
146
|
+
email: info.email,
|
|
147
|
+
orgName: info.orgName,
|
|
148
|
+
subscriptionType: info.subscriptionType,
|
|
149
|
+
authMethod: info.authMethod ?? "api_key",
|
|
150
|
+
};
|
|
151
|
+
} catch {
|
|
152
|
+
return { valid: false };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async addManual(params: { apiKey: string; label: string | null }): Promise<Account> {
|
|
157
|
+
const info = await this.verifyToken(params.apiKey);
|
|
158
|
+
if (!info.valid) throw new Error("Invalid token — could not authenticate");
|
|
159
|
+
const id = randomUUID();
|
|
160
|
+
const email = info.email ?? null;
|
|
161
|
+
// Auto-generate label: orgName (subscription) > authMethod-based > user-provided > fallback
|
|
162
|
+
let label = params.label;
|
|
163
|
+
if (!label) {
|
|
164
|
+
if (info.orgName) {
|
|
165
|
+
label = `${info.orgName}${info.subscriptionType ? ` (${info.subscriptionType})` : ""}`;
|
|
166
|
+
} else if (info.authMethod === "oauth_token") {
|
|
167
|
+
label = `Claude Pro/Max`;
|
|
168
|
+
} else if (info.authMethod === "api_key" || params.apiKey.startsWith("sk-ant-api")) {
|
|
169
|
+
label = "API Key";
|
|
170
|
+
} else {
|
|
171
|
+
label = `Account ${this.list().length + 1}`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
insertAccount({
|
|
175
|
+
id,
|
|
176
|
+
label,
|
|
177
|
+
email,
|
|
178
|
+
access_token: encrypt(params.apiKey),
|
|
179
|
+
refresh_token: encrypt(""),
|
|
180
|
+
expires_at: null,
|
|
181
|
+
status: "active",
|
|
182
|
+
cooldown_until: null,
|
|
183
|
+
priority: 0,
|
|
184
|
+
total_requests: 0,
|
|
185
|
+
last_used_at: null,
|
|
186
|
+
});
|
|
187
|
+
return this.toAccount(getAccountById(id)!);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
updateTokens(id: string, accessToken: string, refreshToken: string, expiresAt: number): void {
|
|
191
|
+
updateAccount(id, {
|
|
192
|
+
access_token: encrypt(accessToken),
|
|
193
|
+
refresh_token: encrypt(refreshToken),
|
|
194
|
+
expires_at: expiresAt,
|
|
195
|
+
status: "active",
|
|
196
|
+
cooldown_until: null,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
setCooldown(id: string, untilMs: number): void {
|
|
201
|
+
updateAccount(id, {
|
|
202
|
+
status: "cooldown",
|
|
203
|
+
cooldown_until: Math.floor(untilMs / 1000),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setDisabled(id: string): void {
|
|
208
|
+
updateAccount(id, { status: "disabled" });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
setEnabled(id: string): void {
|
|
212
|
+
updateAccount(id, { status: "active", cooldown_until: null });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
remove(id: string): void {
|
|
216
|
+
deleteAccount(id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
trackUsage(id: string): void {
|
|
220
|
+
incrementAccountRequests(id);
|
|
221
|
+
updateAccount(id, { last_used_at: Math.floor(Date.now() / 1000) });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// OAuth PKCE helpers
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
private generatePkce(): { verifier: string; challenge: string } {
|
|
229
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
230
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
231
|
+
return { verifier, challenge };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private cleanExpiredStates(): void {
|
|
235
|
+
const cutoff = Date.now() - 10 * 60 * 1000;
|
|
236
|
+
for (const [state, val] of this.pendingStates) {
|
|
237
|
+
if (val.createdAt < cutoff) this.pendingStates.delete(state);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
startOAuthFlow(redirectUri: string): string {
|
|
242
|
+
this.cleanExpiredStates();
|
|
243
|
+
const { verifier, challenge } = this.generatePkce();
|
|
244
|
+
const state = randomBytes(16).toString("hex");
|
|
245
|
+
this.pendingStates.set(state, { verifier, createdAt: Date.now() });
|
|
246
|
+
|
|
247
|
+
const params = new URLSearchParams({
|
|
248
|
+
response_type: "code",
|
|
249
|
+
client_id: OAUTH_CLIENT_ID,
|
|
250
|
+
redirect_uri: redirectUri,
|
|
251
|
+
scope: OAUTH_SCOPE,
|
|
252
|
+
state,
|
|
253
|
+
code_challenge: challenge,
|
|
254
|
+
code_challenge_method: "S256",
|
|
255
|
+
});
|
|
256
|
+
return `${OAUTH_AUTH_URL}?${params}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async completeOAuthFlow(code: string, state: string, redirectUri: string): Promise<Account> {
|
|
260
|
+
const pending = this.pendingStates.get(state);
|
|
261
|
+
if (!pending) throw new Error("Invalid or expired OAuth state");
|
|
262
|
+
this.pendingStates.delete(state);
|
|
263
|
+
|
|
264
|
+
const tokens = await this.exchangeCode(code, pending.verifier, redirectUri);
|
|
265
|
+
return this.add({
|
|
266
|
+
email: tokens.email,
|
|
267
|
+
accessToken: tokens.accessToken,
|
|
268
|
+
refreshToken: tokens.refreshToken,
|
|
269
|
+
expiresAt: tokens.expiresAt,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async exchangeCode(code: string, verifier: string, redirectUri: string): Promise<{
|
|
274
|
+
accessToken: string;
|
|
275
|
+
refreshToken: string;
|
|
276
|
+
expiresAt: number;
|
|
277
|
+
email: string;
|
|
278
|
+
}> {
|
|
279
|
+
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: { "Content-Type": "application/json" },
|
|
282
|
+
body: JSON.stringify({
|
|
283
|
+
grant_type: "authorization_code",
|
|
284
|
+
client_id: OAUTH_CLIENT_ID,
|
|
285
|
+
code,
|
|
286
|
+
redirect_uri: redirectUri,
|
|
287
|
+
code_verifier: verifier,
|
|
288
|
+
}),
|
|
289
|
+
});
|
|
290
|
+
if (!res.ok) {
|
|
291
|
+
const text = await res.text();
|
|
292
|
+
throw new Error(`OAuth token exchange failed: ${res.status} ${text}`);
|
|
293
|
+
}
|
|
294
|
+
const data = await res.json() as {
|
|
295
|
+
access_token: string;
|
|
296
|
+
refresh_token: string;
|
|
297
|
+
expires_in: number;
|
|
298
|
+
account?: { email_address?: string };
|
|
299
|
+
};
|
|
300
|
+
return {
|
|
301
|
+
accessToken: data.access_token,
|
|
302
|
+
refreshToken: data.refresh_token,
|
|
303
|
+
expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
|
|
304
|
+
email: data.account?.email_address ?? "",
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async refreshAccessToken(accountId: string): Promise<void> {
|
|
309
|
+
const account = this.getWithTokens(accountId);
|
|
310
|
+
if (!account) throw new Error(`Account ${accountId} not found`);
|
|
311
|
+
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: { "Content-Type": "application/json" },
|
|
314
|
+
body: JSON.stringify({
|
|
315
|
+
grant_type: "refresh_token",
|
|
316
|
+
client_id: OAUTH_CLIENT_ID,
|
|
317
|
+
refresh_token: account.refreshToken,
|
|
318
|
+
}),
|
|
319
|
+
});
|
|
320
|
+
if (!res.ok) {
|
|
321
|
+
this.setDisabled(accountId);
|
|
322
|
+
throw new Error(`Token refresh failed for account ${accountId}: ${res.status}`);
|
|
323
|
+
}
|
|
324
|
+
const data = await res.json() as {
|
|
325
|
+
access_token: string;
|
|
326
|
+
refresh_token?: string;
|
|
327
|
+
expires_in: number;
|
|
328
|
+
};
|
|
329
|
+
this.updateTokens(
|
|
330
|
+
accountId,
|
|
331
|
+
data.access_token,
|
|
332
|
+
data.refresh_token ?? account.refreshToken,
|
|
333
|
+
Math.floor(Date.now() / 1000) + data.expires_in,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Export / Import encrypted backup
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
exportEncrypted(): string {
|
|
342
|
+
// Export raw DB rows (tokens are already encrypted) as JSON
|
|
343
|
+
const rows = getAccounts();
|
|
344
|
+
return JSON.stringify(rows, null, 2);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
importEncrypted(json: string): number {
|
|
348
|
+
const rows = JSON.parse(json) as AccountRow[];
|
|
349
|
+
if (!Array.isArray(rows)) throw new Error("Invalid backup format");
|
|
350
|
+
let count = 0;
|
|
351
|
+
for (const row of rows) {
|
|
352
|
+
if (!row.id || !row.access_token || !row.refresh_token) continue;
|
|
353
|
+
// Skip if account already exists
|
|
354
|
+
if (getAccountById(row.id)) continue;
|
|
355
|
+
insertAccount({
|
|
356
|
+
id: row.id,
|
|
357
|
+
label: row.label,
|
|
358
|
+
email: row.email,
|
|
359
|
+
access_token: row.access_token,
|
|
360
|
+
refresh_token: row.refresh_token,
|
|
361
|
+
expires_at: row.expires_at,
|
|
362
|
+
status: row.status ?? "active",
|
|
363
|
+
cooldown_until: row.cooldown_until,
|
|
364
|
+
priority: row.priority ?? 0,
|
|
365
|
+
total_requests: row.total_requests ?? 0,
|
|
366
|
+
last_used_at: row.last_used_at,
|
|
367
|
+
});
|
|
368
|
+
count++;
|
|
369
|
+
}
|
|
370
|
+
return count;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Auto-refresh background timer
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
startAutoRefresh(): void {
|
|
378
|
+
if (this.refreshTimer) return;
|
|
379
|
+
const CHECK_INTERVAL_MS = 5 * 60_000;
|
|
380
|
+
const REFRESH_BUFFER_S = 5 * 60;
|
|
381
|
+
|
|
382
|
+
this.refreshTimer = setInterval(async () => {
|
|
383
|
+
const accounts = this.list();
|
|
384
|
+
const nowS = Math.floor(Date.now() / 1000);
|
|
385
|
+
for (const acc of accounts) {
|
|
386
|
+
if (acc.status === "disabled") continue;
|
|
387
|
+
if (!acc.expiresAt) continue;
|
|
388
|
+
if (acc.expiresAt - nowS > REFRESH_BUFFER_S) continue;
|
|
389
|
+
console.log(`[accounts] Auto-refreshing token for ${acc.email ?? acc.id}`);
|
|
390
|
+
try {
|
|
391
|
+
await this.refreshAccessToken(acc.id);
|
|
392
|
+
} catch (e) {
|
|
393
|
+
console.error(`[accounts] Auto-refresh failed for ${acc.id}:`, e);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}, CHECK_INTERVAL_MS);
|
|
397
|
+
|
|
398
|
+
if (typeof this.refreshTimer === "object" && this.refreshTimer !== null && "unref" in this.refreshTimer) {
|
|
399
|
+
(this.refreshTimer as NodeJS.Timeout).unref();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
stopAutoRefresh(): void {
|
|
404
|
+
if (this.refreshTimer) {
|
|
405
|
+
clearInterval(this.refreshTimer);
|
|
406
|
+
this.refreshTimer = null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export const accountService = new AccountService();
|