opencode-account-manager 0.6.4 → 0.6.6

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.
Files changed (86) hide show
  1. package/README.md +235 -216
  2. package/README_VI.md +235 -216
  3. package/dist/cli.js +83 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/core/config-store.d.ts +12 -0
  6. package/dist/core/config-store.d.ts.map +1 -1
  7. package/dist/core/config-store.js +98 -0
  8. package/dist/core/config-store.js.map +1 -1
  9. package/dist/core/health-log.d.ts +9 -0
  10. package/dist/core/health-log.d.ts.map +1 -0
  11. package/dist/core/health-log.js +154 -0
  12. package/dist/core/health-log.js.map +1 -0
  13. package/dist/core/health-oauth.d.ts +5 -0
  14. package/dist/core/health-oauth.d.ts.map +1 -0
  15. package/dist/core/health-oauth.js +147 -0
  16. package/dist/core/health-oauth.js.map +1 -0
  17. package/dist/core/health-orchestrator.d.ts +32 -0
  18. package/dist/core/health-orchestrator.d.ts.map +1 -0
  19. package/dist/core/health-orchestrator.js +148 -0
  20. package/dist/core/health-orchestrator.js.map +1 -0
  21. package/dist/core/health-utils.d.ts +15 -0
  22. package/dist/core/health-utils.d.ts.map +1 -0
  23. package/dist/core/health-utils.js +60 -0
  24. package/dist/core/health-utils.js.map +1 -0
  25. package/dist/core/paths.d.ts +1 -0
  26. package/dist/core/paths.d.ts.map +1 -1
  27. package/dist/core/paths.js +4 -0
  28. package/dist/core/paths.js.map +1 -1
  29. package/dist/core/types.d.ts +26 -0
  30. package/dist/core/types.d.ts.map +1 -1
  31. package/dist/tui/Dashboard.d.ts.map +1 -1
  32. package/dist/tui/Dashboard.js +69 -2
  33. package/dist/tui/Dashboard.js.map +1 -1
  34. package/dist/tui/components/AccountList.d.ts +5 -3
  35. package/dist/tui/components/AccountList.d.ts.map +1 -1
  36. package/dist/tui/components/AccountList.js +9 -3
  37. package/dist/tui/components/AccountList.js.map +1 -1
  38. package/dist/tui/components/DashboardView.d.ts +3 -2
  39. package/dist/tui/components/DashboardView.d.ts.map +1 -1
  40. package/dist/tui/components/DashboardView.js +102 -17
  41. package/dist/tui/components/DashboardView.js.map +1 -1
  42. package/dist/tui/components/HealthBadge.d.ts +9 -0
  43. package/dist/tui/components/HealthBadge.d.ts.map +1 -0
  44. package/dist/tui/components/HealthBadge.js +56 -0
  45. package/dist/tui/components/HealthBadge.js.map +1 -0
  46. package/dist/tui/components/StatusBadge.d.ts +2 -1
  47. package/dist/tui/components/StatusBadge.d.ts.map +1 -1
  48. package/dist/tui/components/StatusBadge.js +30 -2
  49. package/dist/tui/components/StatusBadge.js.map +1 -1
  50. package/dist/tui/components/index.d.ts +1 -0
  51. package/dist/tui/components/index.d.ts.map +1 -1
  52. package/dist/tui/components/index.js +3 -1
  53. package/dist/tui/components/index.js.map +1 -1
  54. package/docs/BLUEPRINT.md +476 -476
  55. package/docs/ROADMAP.md +125 -107
  56. package/package.json +38 -38
  57. package/src/cli.ts +139 -38
  58. package/src/core/config-store.ts +278 -171
  59. package/src/core/crypto.ts +162 -162
  60. package/src/core/health-log.ts +173 -0
  61. package/src/core/health-oauth.ts +190 -0
  62. package/src/core/health-orchestrator.ts +224 -0
  63. package/src/core/importers/amExport.ts +177 -177
  64. package/src/core/opencode-config.ts +217 -217
  65. package/src/core/paths.ts +10 -6
  66. package/src/core/types.ts +193 -147
  67. package/src/tui/Dashboard.tsx +557 -478
  68. package/src/tui/components/AccountList.tsx +122 -104
  69. package/src/tui/components/ActionPalette.tsx +117 -117
  70. package/src/tui/components/Box.tsx +7 -7
  71. package/src/tui/components/DashboardView.tsx +324 -220
  72. package/src/tui/components/ExportModal.tsx +255 -255
  73. package/src/tui/components/FileBrowser.tsx +393 -393
  74. package/src/tui/components/Header.tsx +26 -26
  75. package/src/tui/components/HealthBadge.tsx +64 -0
  76. package/src/tui/components/ImportModal.tsx +334 -334
  77. package/src/tui/components/McpServerList.tsx +67 -67
  78. package/src/tui/components/Menu.tsx +61 -61
  79. package/src/tui/components/PasswordInput.tsx +159 -159
  80. package/src/tui/components/ProviderList.tsx +59 -59
  81. package/src/tui/components/SectionBox.tsx +35 -35
  82. package/src/tui/components/StatsRow.tsx +33 -33
  83. package/src/tui/components/StatusBadge.tsx +36 -3
  84. package/src/tui/components/index.ts +15 -14
  85. package/test-minimal.js +26 -26
  86. package/test-with-accounts.js +58 -58
@@ -1,162 +1,162 @@
1
- import * as crypto from "crypto";
2
-
3
- // ============================================================================
4
- // Constants
5
- // ============================================================================
6
-
7
- const ALGORITHM = "aes-256-gcm";
8
- const KEY_LENGTH = 32; // 256 bits
9
- const SALT_LENGTH = 32; // 256 bits
10
- const IV_LENGTH = 12; // 96 bits (recommended for GCM)
11
- const AUTH_TAG_LENGTH = 16; // 128 bits
12
-
13
- // scrypt parameters (N=16384, r=8, p=1)
14
- const SCRYPT_N = 16384;
15
- const SCRYPT_R = 8;
16
- const SCRYPT_P = 1;
17
-
18
- // ============================================================================
19
- // Types
20
- // ============================================================================
21
-
22
- export interface EncryptedData {
23
- salt: string; // hex
24
- iv: string; // hex
25
- authTag: string; // hex
26
- data: string; // hex (encrypted content)
27
- }
28
-
29
- // ============================================================================
30
- // Helper Functions
31
- // ============================================================================
32
-
33
- /**
34
- * Generate random bytes and return as hex string
35
- */
36
- function randomHex(length: number): string {
37
- return crypto.randomBytes(length).toString("hex");
38
- }
39
-
40
- /**
41
- * Derive encryption key from password using scrypt
42
- */
43
- function deriveKey(password: string, salt: Buffer): Buffer {
44
- return crypto.scryptSync(password, salt, KEY_LENGTH, {
45
- N: SCRYPT_N,
46
- r: SCRYPT_R,
47
- p: SCRYPT_P,
48
- });
49
- }
50
-
51
- // ============================================================================
52
- // Public Functions
53
- // ============================================================================
54
-
55
- /**
56
- * Generate a random salt for encryption
57
- */
58
- export function generateSalt(): string {
59
- return randomHex(SALT_LENGTH);
60
- }
61
-
62
- /**
63
- * Generate a random IV for encryption
64
- */
65
- export function generateIV(): string {
66
- return randomHex(IV_LENGTH);
67
- }
68
-
69
- /**
70
- * Encrypt data object with password using AES-256-GCM
71
- *
72
- * @param data - Object to encrypt (will be JSON stringified)
73
- * @param password - Password for encryption
74
- * @returns Encrypted data with salt, iv, authTag, and encrypted content
75
- */
76
- export function encrypt(data: object, password: string): EncryptedData {
77
- // Generate random salt and IV
78
- const salt = Buffer.from(generateSalt(), "hex");
79
- const iv = Buffer.from(generateIV(), "hex");
80
-
81
- // Derive key from password
82
- const key = deriveKey(password, salt);
83
-
84
- // Create cipher
85
- const cipher = crypto.createCipheriv(ALGORITHM, key, iv, {
86
- authTagLength: AUTH_TAG_LENGTH,
87
- });
88
-
89
- // Encrypt data
90
- const plaintext = JSON.stringify(data);
91
- const encrypted = Buffer.concat([
92
- cipher.update(plaintext, "utf8"),
93
- cipher.final(),
94
- ]);
95
-
96
- // Get auth tag
97
- const authTag = cipher.getAuthTag();
98
-
99
- return {
100
- salt: salt.toString("hex"),
101
- iv: iv.toString("hex"),
102
- authTag: authTag.toString("hex"),
103
- data: encrypted.toString("hex"),
104
- };
105
- }
106
-
107
- /**
108
- * Decrypt data with password using AES-256-GCM
109
- *
110
- * @param encrypted - Encrypted data object
111
- * @param password - Password for decryption
112
- * @returns Decrypted object
113
- * @throws Error if password is wrong or data is corrupted
114
- */
115
- export function decrypt<T = unknown>(encrypted: EncryptedData, password: string): T {
116
- // Convert hex strings to buffers
117
- const salt = Buffer.from(encrypted.salt, "hex");
118
- const iv = Buffer.from(encrypted.iv, "hex");
119
- const authTag = Buffer.from(encrypted.authTag, "hex");
120
- const data = Buffer.from(encrypted.data, "hex");
121
-
122
- // Derive key from password
123
- const key = deriveKey(password, salt);
124
-
125
- // Create decipher
126
- const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
127
- authTagLength: AUTH_TAG_LENGTH,
128
- });
129
-
130
- // Set auth tag for verification
131
- decipher.setAuthTag(authTag);
132
-
133
- try {
134
- // Decrypt data
135
- const decrypted = Buffer.concat([
136
- decipher.update(data),
137
- decipher.final(),
138
- ]);
139
-
140
- // Parse JSON
141
- return JSON.parse(decrypted.toString("utf8")) as T;
142
- } catch (error) {
143
- // Auth tag verification failed or JSON parse failed
144
- throw new Error("Invalid password or corrupted file");
145
- }
146
- }
147
-
148
- /**
149
- * Verify if a password can decrypt the data
150
- *
151
- * @param encrypted - Encrypted data object
152
- * @param password - Password to verify
153
- * @returns true if password is correct
154
- */
155
- export function verifyPassword(encrypted: EncryptedData, password: string): boolean {
156
- try {
157
- decrypt(encrypted, password);
158
- return true;
159
- } catch {
160
- return false;
161
- }
162
- }
1
+ import * as crypto from "crypto";
2
+
3
+ // ============================================================================
4
+ // Constants
5
+ // ============================================================================
6
+
7
+ const ALGORITHM = "aes-256-gcm";
8
+ const KEY_LENGTH = 32; // 256 bits
9
+ const SALT_LENGTH = 32; // 256 bits
10
+ const IV_LENGTH = 12; // 96 bits (recommended for GCM)
11
+ const AUTH_TAG_LENGTH = 16; // 128 bits
12
+
13
+ // scrypt parameters (N=16384, r=8, p=1)
14
+ const SCRYPT_N = 16384;
15
+ const SCRYPT_R = 8;
16
+ const SCRYPT_P = 1;
17
+
18
+ // ============================================================================
19
+ // Types
20
+ // ============================================================================
21
+
22
+ export interface EncryptedData {
23
+ salt: string; // hex
24
+ iv: string; // hex
25
+ authTag: string; // hex
26
+ data: string; // hex (encrypted content)
27
+ }
28
+
29
+ // ============================================================================
30
+ // Helper Functions
31
+ // ============================================================================
32
+
33
+ /**
34
+ * Generate random bytes and return as hex string
35
+ */
36
+ function randomHex(length: number): string {
37
+ return crypto.randomBytes(length).toString("hex");
38
+ }
39
+
40
+ /**
41
+ * Derive encryption key from password using scrypt
42
+ */
43
+ function deriveKey(password: string, salt: Buffer): Buffer {
44
+ return crypto.scryptSync(password, salt, KEY_LENGTH, {
45
+ N: SCRYPT_N,
46
+ r: SCRYPT_R,
47
+ p: SCRYPT_P,
48
+ });
49
+ }
50
+
51
+ // ============================================================================
52
+ // Public Functions
53
+ // ============================================================================
54
+
55
+ /**
56
+ * Generate a random salt for encryption
57
+ */
58
+ export function generateSalt(): string {
59
+ return randomHex(SALT_LENGTH);
60
+ }
61
+
62
+ /**
63
+ * Generate a random IV for encryption
64
+ */
65
+ export function generateIV(): string {
66
+ return randomHex(IV_LENGTH);
67
+ }
68
+
69
+ /**
70
+ * Encrypt data object with password using AES-256-GCM
71
+ *
72
+ * @param data - Object to encrypt (will be JSON stringified)
73
+ * @param password - Password for encryption
74
+ * @returns Encrypted data with salt, iv, authTag, and encrypted content
75
+ */
76
+ export function encrypt(data: object, password: string): EncryptedData {
77
+ // Generate random salt and IV
78
+ const salt = Buffer.from(generateSalt(), "hex");
79
+ const iv = Buffer.from(generateIV(), "hex");
80
+
81
+ // Derive key from password
82
+ const key = deriveKey(password, salt);
83
+
84
+ // Create cipher
85
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv, {
86
+ authTagLength: AUTH_TAG_LENGTH,
87
+ });
88
+
89
+ // Encrypt data
90
+ const plaintext = JSON.stringify(data);
91
+ const encrypted = Buffer.concat([
92
+ cipher.update(plaintext, "utf8"),
93
+ cipher.final(),
94
+ ]);
95
+
96
+ // Get auth tag
97
+ const authTag = cipher.getAuthTag();
98
+
99
+ return {
100
+ salt: salt.toString("hex"),
101
+ iv: iv.toString("hex"),
102
+ authTag: authTag.toString("hex"),
103
+ data: encrypted.toString("hex"),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Decrypt data with password using AES-256-GCM
109
+ *
110
+ * @param encrypted - Encrypted data object
111
+ * @param password - Password for decryption
112
+ * @returns Decrypted object
113
+ * @throws Error if password is wrong or data is corrupted
114
+ */
115
+ export function decrypt<T = unknown>(encrypted: EncryptedData, password: string): T {
116
+ // Convert hex strings to buffers
117
+ const salt = Buffer.from(encrypted.salt, "hex");
118
+ const iv = Buffer.from(encrypted.iv, "hex");
119
+ const authTag = Buffer.from(encrypted.authTag, "hex");
120
+ const data = Buffer.from(encrypted.data, "hex");
121
+
122
+ // Derive key from password
123
+ const key = deriveKey(password, salt);
124
+
125
+ // Create decipher
126
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
127
+ authTagLength: AUTH_TAG_LENGTH,
128
+ });
129
+
130
+ // Set auth tag for verification
131
+ decipher.setAuthTag(authTag);
132
+
133
+ try {
134
+ // Decrypt data
135
+ const decrypted = Buffer.concat([
136
+ decipher.update(data),
137
+ decipher.final(),
138
+ ]);
139
+
140
+ // Parse JSON
141
+ return JSON.parse(decrypted.toString("utf8")) as T;
142
+ } catch (error) {
143
+ // Auth tag verification failed or JSON parse failed
144
+ throw new Error("Invalid password or corrupted file");
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Verify if a password can decrypt the data
150
+ *
151
+ * @param encrypted - Encrypted data object
152
+ * @param password - Password to verify
153
+ * @returns true if password is correct
154
+ */
155
+ export function verifyPassword(encrypted: EncryptedData, password: string): boolean {
156
+ try {
157
+ decrypt(encrypted, password);
158
+ return true;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
@@ -0,0 +1,173 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { AccountHealthResult, AccountHealthStatus } from "./types";
4
+ import { getAntigravityLogsPath } from "./paths";
5
+ import { normalizeHealthKey } from "./config-store";
6
+
7
+ const DEFAULT_MAX_FILES = 10;
8
+ const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
9
+ const EMAIL_REGEX = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
10
+
11
+ const STATUS_PRIORITY: Record<AccountHealthStatus, number> = {
12
+ verification_required: 9,
13
+ disabled: 8,
14
+ deleted: 7,
15
+ password_changed: 6,
16
+ revoked: 5,
17
+ network_error: 4,
18
+ unknown_error: 3,
19
+ not_configured: 2,
20
+ not_checked: 1,
21
+ ok: 0,
22
+ };
23
+
24
+ function mapLineToStatus(line: string): AccountHealthStatus | undefined {
25
+ const text = line.toLowerCase();
26
+
27
+ if (
28
+ text.includes("verification required") ||
29
+ text.includes("complete verification") ||
30
+ text.includes("verify your account") ||
31
+ text.includes("login_required") ||
32
+ text.includes("challenge")
33
+ ) {
34
+ return "verification_required";
35
+ }
36
+
37
+ if (text.includes("invalid_grant")) {
38
+ if (text.includes("disabled")) return "disabled";
39
+ if (text.includes("deleted")) return "deleted";
40
+ if (text.includes("password")) return "password_changed";
41
+ if (text.includes("revoked") || text.includes("expired")) return "revoked";
42
+ return "revoked";
43
+ }
44
+
45
+ if (text.includes("account has been disabled")) return "disabled";
46
+ if (text.includes("account has been deleted")) return "deleted";
47
+ if (text.includes("password changed")) return "password_changed";
48
+ if (text.includes("revoked")) return "revoked";
49
+
50
+ if (
51
+ text.includes("timeout") ||
52
+ text.includes("econnreset") ||
53
+ text.includes("enetunreach") ||
54
+ text.includes("eai_again") ||
55
+ text.includes("rate limit")
56
+ ) {
57
+ return "network_error";
58
+ }
59
+
60
+ return undefined;
61
+ }
62
+
63
+ function mergeHealthResults(
64
+ left: AccountHealthResult | undefined,
65
+ right: AccountHealthResult | undefined
66
+ ): AccountHealthResult | undefined {
67
+ if (!left) return right;
68
+ if (!right) return left;
69
+
70
+ const leftPriority = STATUS_PRIORITY[left.status] ?? 0;
71
+ const rightPriority = STATUS_PRIORITY[right.status] ?? 0;
72
+ if (leftPriority !== rightPriority) {
73
+ return leftPriority > rightPriority ? left : right;
74
+ }
75
+
76
+ if (left.checkedAt !== right.checkedAt) {
77
+ return left.checkedAt > right.checkedAt ? left : right;
78
+ }
79
+
80
+ const sourcePriority = { oauth: 3, log: 2, cache: 1, manual: 0 } as const;
81
+ const leftSource = sourcePriority[left.source] ?? 0;
82
+ const rightSource = sourcePriority[right.source] ?? 0;
83
+ return leftSource >= rightSource ? left : right;
84
+ }
85
+
86
+ function readFileTail(filePath: string, maxBytes: number): string {
87
+ const stat = fs.statSync(filePath);
88
+ const size = stat.size;
89
+ if (size <= maxBytes) {
90
+ return fs.readFileSync(filePath, "utf8");
91
+ }
92
+
93
+ const fd = fs.openSync(filePath, "r");
94
+ try {
95
+ const buffer = Buffer.allocUnsafe(maxBytes);
96
+ const start = Math.max(0, size - maxBytes);
97
+ fs.readSync(fd, buffer, 0, maxBytes, start);
98
+ return buffer.toString("utf8");
99
+ } finally {
100
+ fs.closeSync(fd);
101
+ }
102
+ }
103
+
104
+ export interface LogHealthOptions {
105
+ logDir?: string;
106
+ maxFiles?: number;
107
+ maxBytes?: number;
108
+ }
109
+
110
+ export function collectLogHealthResults(
111
+ options: LogHealthOptions = {}
112
+ ): Record<string, AccountHealthResult> {
113
+ const logDir = options.logDir || getAntigravityLogsPath();
114
+ if (!fs.existsSync(logDir)) return {};
115
+
116
+ const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
117
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
118
+
119
+ const entries = fs.readdirSync(logDir)
120
+ .map((name) => {
121
+ const fullPath = path.join(logDir, name);
122
+ try {
123
+ const stat = fs.statSync(fullPath);
124
+ return { name, fullPath, stat };
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ })
129
+ .filter((entry): entry is { name: string; fullPath: string; stat: fs.Stats } => !!entry)
130
+ .filter((entry) => entry.stat.isFile())
131
+ .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
132
+ .slice(0, maxFiles);
133
+
134
+ const results: Record<string, AccountHealthResult> = {};
135
+
136
+ for (const entry of entries) {
137
+ let content = "";
138
+ try {
139
+ content = readFileTail(entry.fullPath, maxBytes);
140
+ } catch {
141
+ continue;
142
+ }
143
+
144
+ const lines = content.split(/\r?\n/);
145
+ for (const line of lines) {
146
+ const status = mapLineToStatus(line);
147
+ if (!status) continue;
148
+
149
+ const emails = line.match(EMAIL_REGEX) || [];
150
+ if (emails.length === 0) continue;
151
+
152
+ for (const email of emails) {
153
+ const key = normalizeHealthKey(email);
154
+ const candidate: AccountHealthResult = {
155
+ status,
156
+ source: "log",
157
+ checkedAt: entry.stat.mtimeMs,
158
+ message: line.trim().slice(0, 200),
159
+ };
160
+ results[key] = mergeHealthResults(results[key], candidate) || candidate;
161
+ }
162
+ }
163
+ }
164
+
165
+ return results;
166
+ }
167
+
168
+ export function mergeAccountHealth(
169
+ primary: AccountHealthResult | undefined,
170
+ secondary: AccountHealthResult | undefined
171
+ ): AccountHealthResult | undefined {
172
+ return mergeHealthResults(primary, secondary);
173
+ }
@@ -0,0 +1,190 @@
1
+ import https from "https";
2
+ import {
3
+ AccountHealthResult,
4
+ AccountHealthStatus,
5
+ HealthOAuthConfig,
6
+ } from "./types";
7
+ import { getHealthOAuthConfig } from "./config-store";
8
+
9
+ const DEFAULT_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
10
+ const REQUEST_TIMEOUT_MS = 10000;
11
+
12
+ interface OAuthErrorResponse {
13
+ error?: string;
14
+ error_description?: string;
15
+ }
16
+
17
+ interface OAuthSuccessResponse {
18
+ access_token: string;
19
+ expires_in?: number;
20
+ scope?: string;
21
+ token_type?: string;
22
+ }
23
+
24
+ function resolveOAuthConfig(): HealthOAuthConfig | undefined {
25
+ const config = getHealthOAuthConfig() || {};
26
+ const clientId = process.env.OCAM_OAUTH_CLIENT_ID || config.clientId;
27
+ const clientSecret = process.env.OCAM_OAUTH_CLIENT_SECRET || config.clientSecret;
28
+ const tokenEndpoint = process.env.OCAM_OAUTH_TOKEN_ENDPOINT || config.tokenEndpoint;
29
+ if (!clientId || !clientSecret) return undefined;
30
+ return {
31
+ clientId,
32
+ clientSecret,
33
+ tokenEndpoint: tokenEndpoint || DEFAULT_TOKEN_ENDPOINT,
34
+ };
35
+ }
36
+
37
+ export function isOAuthHealthCheckConfigured(): boolean {
38
+ return !!resolveOAuthConfig();
39
+ }
40
+
41
+ function mapOAuthError(
42
+ error?: string,
43
+ description?: string,
44
+ httpStatus?: number
45
+ ): AccountHealthStatus {
46
+ const desc = (description || "").toLowerCase();
47
+
48
+ if (error === "invalid_client") return "not_configured";
49
+
50
+ if (error === "invalid_grant") {
51
+ if (desc.includes("disabled")) return "disabled";
52
+ if (desc.includes("deleted")) return "deleted";
53
+ if (desc.includes("password")) return "password_changed";
54
+ if (
55
+ desc.includes("verify") ||
56
+ desc.includes("verification") ||
57
+ desc.includes("challenge") ||
58
+ desc.includes("login_required") ||
59
+ desc.includes("login required")
60
+ ) {
61
+ return "verification_required";
62
+ }
63
+ if (desc.includes("revoked") || desc.includes("expired")) return "revoked";
64
+ return "revoked";
65
+ }
66
+
67
+ if (error === "consent_required") return "verification_required";
68
+ if (error === "access_denied") return "disabled";
69
+ if (error === "rate_limit_exceeded") return "network_error";
70
+ if (error === "server_error") return "network_error";
71
+ if (error === "temporarily_unavailable") return "network_error";
72
+
73
+ if (httpStatus && httpStatus >= 500) return "network_error";
74
+
75
+ return "unknown_error";
76
+ }
77
+
78
+ function buildResult(
79
+ status: AccountHealthStatus,
80
+ source: "oauth",
81
+ detail?: {
82
+ error?: string;
83
+ errorDescription?: string;
84
+ httpStatus?: number;
85
+ message?: string;
86
+ }
87
+ ): AccountHealthResult {
88
+ return {
89
+ status,
90
+ source,
91
+ checkedAt: Date.now(),
92
+ message: detail?.message,
93
+ errorCode: detail?.error,
94
+ errorDescription: detail?.errorDescription,
95
+ httpStatus: detail?.httpStatus,
96
+ };
97
+ }
98
+
99
+ export function buildOAuthRequestBody(refreshToken: string, config: HealthOAuthConfig): string {
100
+ const params = new URLSearchParams({
101
+ client_id: config.clientId || "",
102
+ client_secret: config.clientSecret || "",
103
+ refresh_token: refreshToken,
104
+ grant_type: "refresh_token",
105
+ });
106
+ return params.toString();
107
+ }
108
+
109
+ export async function checkAccountHealthOAuth(
110
+ refreshToken: string
111
+ ): Promise<AccountHealthResult> {
112
+ const config = resolveOAuthConfig();
113
+ if (!config) {
114
+ return buildResult("not_configured", "oauth", {
115
+ message: "Missing OAuth client_id/client_secret",
116
+ });
117
+ }
118
+
119
+ if (!refreshToken) {
120
+ return buildResult("unknown_error", "oauth", {
121
+ message: "Missing refresh token",
122
+ });
123
+ }
124
+
125
+ const tokenEndpoint = config.tokenEndpoint || DEFAULT_TOKEN_ENDPOINT;
126
+ const body = buildOAuthRequestBody(refreshToken, config);
127
+
128
+ return new Promise<AccountHealthResult>((resolve) => {
129
+ const request = https.request(
130
+ tokenEndpoint,
131
+ {
132
+ method: "POST",
133
+ headers: {
134
+ "Content-Type": "application/x-www-form-urlencoded",
135
+ "Content-Length": Buffer.byteLength(body),
136
+ },
137
+ timeout: REQUEST_TIMEOUT_MS,
138
+ },
139
+ (response) => {
140
+ const chunks: Buffer[] = [];
141
+ response.on("data", (chunk) => chunks.push(chunk));
142
+ response.on("end", () => {
143
+ const raw = Buffer.concat(chunks).toString("utf8");
144
+ const httpStatus = response.statusCode || 0;
145
+
146
+ try {
147
+ const parsed = JSON.parse(raw) as OAuthSuccessResponse & OAuthErrorResponse;
148
+
149
+ if (httpStatus >= 200 && httpStatus < 300 && parsed.access_token) {
150
+ resolve(buildResult("ok", "oauth", { httpStatus }));
151
+ return;
152
+ }
153
+
154
+ const status = mapOAuthError(parsed.error, parsed.error_description, httpStatus);
155
+ resolve(
156
+ buildResult(status, "oauth", {
157
+ error: parsed.error,
158
+ errorDescription: parsed.error_description,
159
+ httpStatus,
160
+ })
161
+ );
162
+ } catch (error) {
163
+ const status = httpStatus >= 500 ? "network_error" : "unknown_error";
164
+ resolve(
165
+ buildResult(status, "oauth", {
166
+ httpStatus,
167
+ message: `Invalid response: ${error instanceof Error ? error.message : "unknown"}`,
168
+ })
169
+ );
170
+ }
171
+ });
172
+ }
173
+ );
174
+
175
+ request.on("timeout", () => {
176
+ request.destroy(new Error("Request timeout"));
177
+ });
178
+
179
+ request.on("error", (err) => {
180
+ resolve(
181
+ buildResult("network_error", "oauth", {
182
+ message: err.message,
183
+ })
184
+ );
185
+ });
186
+
187
+ request.write(body);
188
+ request.end();
189
+ });
190
+ }