@hienlh/ppm 0.7.27 → 0.7.28

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 (31) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CLAUDE.md +18 -0
  3. package/dist/web/assets/chat-tab-B0UcLXFA.js +7 -0
  4. package/dist/web/assets/{code-editor-Gf5j24XD.js → code-editor-Bb-RxKRW.js} +1 -1
  5. package/dist/web/assets/{database-viewer-CavLUDcg.js → database-viewer-B4pr_bwC.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-DCtD7LAK.js → diff-viewer-DuHuqbG4.js} +1 -1
  7. package/dist/web/assets/{git-graph-ihMFS2VR.js → git-graph-BkTGWVMA.js} +1 -1
  8. package/dist/web/assets/index-BCibi3mV.css +2 -0
  9. package/dist/web/assets/index-BWVej31S.js +28 -0
  10. package/dist/web/assets/keybindings-store-DoOYThSa.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-BQoBZBCF.js → markdown-renderer-CyObkWZ-.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-SV4HmMM_.js → postgres-viewer-CHVUVt7c.js} +1 -1
  13. package/dist/web/assets/{settings-tab-omvX466u.js → settings-tab-C1Uj7t80.js} +1 -1
  14. package/dist/web/assets/{sqlite-viewer-CZz6cYBU.js → sqlite-viewer-D1ohxjF9.js} +1 -1
  15. package/dist/web/assets/{switch-PAf5UhcN.js → switch-UODDpwuO.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-C-fMaKoC.js → terminal-tab-DGzY_K3A.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/dist/web/sw.js +1 -1
  19. package/docs/design-guidelines.md +79 -0
  20. package/docs/project-roadmap.md +121 -397
  21. package/package.json +1 -1
  22. package/src/lib/account-crypto.ts +52 -1
  23. package/src/server/routes/accounts.ts +18 -9
  24. package/src/services/account.service.ts +46 -9
  25. package/src/web/components/chat/usage-badge.tsx +1 -1
  26. package/src/web/components/settings/accounts-settings-section.tsx +237 -165
  27. package/src/web/lib/api-settings.ts +4 -0
  28. package/dist/web/assets/chat-tab-RhAZhVvp.js +0 -7
  29. package/dist/web/assets/index-DVuyQcnI.css +0 -2
  30. package/dist/web/assets/index-bndwgasB.js +0 -28
  31. package/dist/web/assets/keybindings-store-C69-mCE5.js +0 -1
@@ -1,4 +1,4 @@
1
- import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { homedir } from "node:os";
@@ -39,6 +39,57 @@ export function encrypt(plaintext: string): string {
39
39
  return `${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
40
40
  }
41
41
 
42
+ // ---------------------------------------------------------------------------
43
+ // Password-based encryption for portable export (cross-machine)
44
+ // ---------------------------------------------------------------------------
45
+
46
+ interface EncryptedExport {
47
+ version: 1;
48
+ kdf: "scrypt";
49
+ salt: string; // hex, 32 bytes
50
+ iv: string; // hex, 12 bytes
51
+ authTag: string; // hex, 16 bytes
52
+ ciphertext: string; // base64
53
+ }
54
+
55
+ /** Encrypt payload with user password → portable encrypted JSON blob */
56
+ export function encryptWithPassword(plaintext: string, password: string): string {
57
+ const salt = randomBytes(32);
58
+ const iv = randomBytes(12);
59
+ // scrypt N=16384 (r=8, p=1) → 16MB mem, ~50ms — secure & within Node/Bun default limits
60
+ const key = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
61
+ const cipher = createCipheriv(ALGO, key, iv);
62
+ const enc = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
63
+ const envelope: EncryptedExport = {
64
+ version: 1,
65
+ kdf: "scrypt",
66
+ salt: salt.toString("hex"),
67
+ iv: iv.toString("hex"),
68
+ authTag: cipher.getAuthTag().toString("hex"),
69
+ ciphertext: enc.toString("base64"),
70
+ };
71
+ return JSON.stringify(envelope);
72
+ }
73
+
74
+ /** Decrypt portable encrypted JSON blob with user password → plaintext */
75
+ export function decryptWithPassword(blob: string, password: string): string {
76
+ let envelope: EncryptedExport;
77
+ try { envelope = JSON.parse(blob); } catch { throw new Error("Invalid backup format"); }
78
+ if (envelope.version !== 1 || envelope.kdf !== "scrypt") throw new Error("Unsupported backup version");
79
+ const salt = Buffer.from(envelope.salt, "hex");
80
+ const iv = Buffer.from(envelope.iv, "hex");
81
+ const authTag = Buffer.from(envelope.authTag, "hex");
82
+ const ciphertext = Buffer.from(envelope.ciphertext, "base64");
83
+ const key = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
84
+ const decipher = createDecipheriv(ALGO, key, iv);
85
+ decipher.setAuthTag(authTag);
86
+ try {
87
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf-8");
88
+ } catch {
89
+ throw new Error("Wrong password or corrupted backup");
90
+ }
91
+ }
92
+
42
93
  /** Decrypt "iv:authTag:ciphertext" → plaintext */
43
94
  export function decrypt(encoded: string): string {
44
95
  const parts = encoded.split(":");
@@ -153,19 +153,28 @@ accountsRoutes.post("/oauth/refresh/:id", async (c) => {
153
153
  }
154
154
  });
155
155
 
156
- /** GET /api/accounts/export — download encrypted accounts backup */
157
- accountsRoutes.get("/export", (c) => {
158
- const blob = accountService.exportEncrypted();
159
- c.header("Content-Disposition", "attachment; filename=ppm-accounts-backup.json");
160
- c.header("Content-Type", "application/json");
161
- return c.body(blob);
156
+ /** POST /api/accounts/export — download password-encrypted accounts backup */
157
+ accountsRoutes.post("/export", async (c) => {
158
+ try {
159
+ const { password, accountIds } = await c.req.json() as { password: string; accountIds?: string[] };
160
+ if (!password) return c.json(err("Password required"), 400);
161
+ await accountService.refreshBeforeExport(accountIds);
162
+ const blob = accountService.exportEncrypted(password, accountIds);
163
+ c.header("Content-Disposition", "attachment; filename=ppm-accounts-backup.json");
164
+ c.header("Content-Type", "application/json");
165
+ return c.body(blob);
166
+ } catch (e) {
167
+ return c.json(err((e as Error).message), 400);
168
+ }
162
169
  });
163
170
 
164
- /** POST /api/accounts/import — restore accounts from backup */
171
+ /** POST /api/accounts/import — restore accounts from password-encrypted backup */
165
172
  accountsRoutes.post("/import", async (c) => {
166
173
  try {
167
- const body = await c.req.text();
168
- const count = accountService.importEncrypted(body);
174
+ const { data, password } = await c.req.json() as { data: string; password: string };
175
+ if (!data) return c.json(err("Backup data required"), 400);
176
+ if (!password) return c.json(err("Password required"), 400);
177
+ const count = accountService.importEncrypted(data, password);
169
178
  return c.json(ok({ imported: count }));
170
179
  } catch (e) {
171
180
  return c.json(err((e as Error).message), 400);
@@ -1,5 +1,5 @@
1
1
  import { randomUUID, createHash, randomBytes } from "node:crypto";
2
- import { encrypt, decrypt } from "../lib/account-crypto.ts";
2
+ import { encrypt, decrypt, encryptWithPassword, decryptWithPassword } from "../lib/account-crypto.ts";
3
3
  import {
4
4
  getAccounts,
5
5
  getAccountById,
@@ -57,6 +57,12 @@ export interface OAuthProfileData {
57
57
  };
58
58
  }
59
59
 
60
+ /** Check if a token string looks like our encrypted format "iv:authTag:ciphertext" (all hex) */
61
+ function looksEncrypted(value: string): boolean {
62
+ const parts = value.split(":");
63
+ return parts.length === 3 && parts.every((p) => /^[0-9a-f]+$/i.test(p));
64
+ }
65
+
60
66
  const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
61
67
  const OAUTH_AUTH_URL = "https://claude.ai/oauth/authorize";
62
68
  const OAUTH_TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
@@ -506,14 +512,40 @@ class AccountService {
506
512
  // Export / Import encrypted backup
507
513
  // ---------------------------------------------------------------------------
508
514
 
509
- exportEncrypted(): string {
510
- // Export raw DB rows (tokens are already encrypted) as JSON
511
- const rows = getAccounts();
512
- return JSON.stringify(rows, null, 2);
515
+ /** Refresh all OAuth tokens before export so they're fresh on the target machine */
516
+ async refreshBeforeExport(accountIds?: string[]): Promise<void> {
517
+ const accounts = accountIds?.length
518
+ ? accountIds.map((id) => this.getWithTokens(id)).filter(Boolean) as AccountWithTokens[]
519
+ : this.list().map((a) => this.getWithTokens(a.id)).filter(Boolean) as AccountWithTokens[];
520
+ for (const acc of accounts) {
521
+ if (!acc.accessToken.startsWith("sk-ant-oat")) continue; // only OAuth tokens
522
+ if (!acc.expiresAt) continue;
523
+ try {
524
+ await this.refreshAccessToken(acc.id);
525
+ } catch {
526
+ // Best-effort — skip accounts whose refresh token is already invalid
527
+ }
528
+ }
529
+ }
530
+
531
+ exportEncrypted(password: string, accountIds?: string[]): string {
532
+ // Fetch requested accounts (or all), decrypt tokens, encrypt whole payload with user password
533
+ const rows = accountIds?.length
534
+ ? accountIds.map((id) => getAccountById(id)).filter(Boolean) as AccountRow[]
535
+ : getAccounts();
536
+ const portable = rows.map((row) => {
537
+ let accessToken = row.access_token;
538
+ let refreshToken = row.refresh_token;
539
+ try { accessToken = decrypt(accessToken); } catch { /* already plaintext or corrupt */ }
540
+ try { refreshToken = decrypt(refreshToken); } catch { /* already plaintext or corrupt */ }
541
+ return { ...row, access_token: accessToken, refresh_token: refreshToken };
542
+ });
543
+ return encryptWithPassword(JSON.stringify(portable), password);
513
544
  }
514
545
 
515
- importEncrypted(json: string): number {
516
- const rows = JSON.parse(json) as AccountRow[];
546
+ importEncrypted(blob: string, password: string): number {
547
+ const plaintext = decryptWithPassword(blob, password);
548
+ const rows = JSON.parse(plaintext) as AccountRow[];
517
549
  if (!Array.isArray(rows)) throw new Error("Invalid backup format");
518
550
  let count = 0;
519
551
  for (const row of rows) {
@@ -521,12 +553,17 @@ class AccountService {
521
553
  // Skip if account already exists (by id or email)
522
554
  if (getAccountById(row.id)) continue;
523
555
  if (row.email && this.list().some((a) => a.email === row.email)) continue;
556
+ // Re-encrypt with this machine's key
557
+ let accessToken = row.access_token;
558
+ let refreshToken = row.refresh_token;
559
+ if (!looksEncrypted(accessToken)) accessToken = encrypt(accessToken);
560
+ if (!looksEncrypted(refreshToken)) refreshToken = encrypt(refreshToken);
524
561
  insertAccount({
525
562
  id: row.id,
526
563
  label: row.label,
527
564
  email: row.email,
528
- access_token: row.access_token,
529
- refresh_token: row.refresh_token,
565
+ access_token: accessToken,
566
+ refresh_token: refreshToken,
530
567
  expires_at: row.expires_at,
531
568
  status: row.status ?? "active",
532
569
  cooldown_until: row.cooldown_until,
@@ -295,7 +295,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
295
295
  <div className="flex items-center gap-1">
296
296
  {onReload && (
297
297
  <button
298
- onClick={() => { setRefreshing(true); onReload(); }}
298
+ onClick={() => { onReload(); loadAll(); }}
299
299
  disabled={loading || refreshing}
300
300
  className="text-xs text-text-subtle hover:text-text-primary px-1 disabled:opacity-50 cursor-pointer"
301
301
  title="Refresh"