faux-studio 0.4.0 → 0.4.1

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 (2) hide show
  1. package/dist/index.js +190 -34
  2. package/package.json +8 -3
package/dist/index.js CHANGED
@@ -10414,9 +10414,6 @@ var require_dist = __commonJS({
10414
10414
  });
10415
10415
 
10416
10416
  // src/auth.ts
10417
- import { readFile, writeFile, mkdir } from "fs/promises";
10418
- import { join } from "path";
10419
- import { homedir } from "os";
10420
10417
  import { spawn } from "child_process";
10421
10418
 
10422
10419
  // src/logger.ts
@@ -10448,31 +10445,129 @@ function error(message) {
10448
10445
  send("error", message);
10449
10446
  }
10450
10447
 
10451
- // src/auth.ts
10448
+ // src/credential-store.ts
10449
+ import { readFile, writeFile, mkdir, unlink } from "fs/promises";
10450
+ import { join } from "path";
10451
+ import { homedir } from "os";
10452
10452
  var FAUX_DIR = join(homedir(), ".faux");
10453
10453
  var CREDENTIALS_PATH = join(FAUX_DIR, "credentials.json");
10454
- var AUTH_BASE = process.env.FAUX_AUTH_URL || "https://auth.faux.design";
10455
- var POLL_INTERVAL_MS = 2e3;
10456
- var POLL_TIMEOUT_MS = 5 * 60 * 1e3;
10457
- var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
10458
- async function loadCredentials() {
10454
+ var KEYCHAIN_SERVICE = "faux-studio";
10455
+ var KEYCHAIN_ACCOUNT = "credentials";
10456
+ var FileCredentialStore = class {
10457
+ name = "file";
10458
+ async load() {
10459
+ try {
10460
+ const raw = await readFile(CREDENTIALS_PATH, "utf-8");
10461
+ const creds = JSON.parse(raw);
10462
+ if (!creds.jwt || !creds.refreshToken || !creds.user) return null;
10463
+ return creds;
10464
+ } catch {
10465
+ return null;
10466
+ }
10467
+ }
10468
+ async save(creds) {
10469
+ try {
10470
+ await mkdir(FAUX_DIR, { recursive: true });
10471
+ await writeFile(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 384 });
10472
+ } catch (err) {
10473
+ warn(`Could not save credentials: ${err instanceof Error ? err.message : err}`);
10474
+ }
10475
+ }
10476
+ async clear() {
10477
+ try {
10478
+ await unlink(CREDENTIALS_PATH);
10479
+ } catch {
10480
+ }
10481
+ }
10482
+ };
10483
+ var KeychainCredentialStore = class {
10484
+ name = "keychain";
10485
+ entry;
10486
+ constructor(entry) {
10487
+ this.entry = entry;
10488
+ }
10489
+ async load() {
10490
+ try {
10491
+ const raw = await this.entry.getPassword();
10492
+ if (!raw) return null;
10493
+ const creds = JSON.parse(raw);
10494
+ if (!creds.jwt || !creds.refreshToken || !creds.user) return null;
10495
+ return creds;
10496
+ } catch {
10497
+ return null;
10498
+ }
10499
+ }
10500
+ async save(creds) {
10501
+ try {
10502
+ await this.entry.setPassword(JSON.stringify(creds));
10503
+ } catch (err) {
10504
+ warn(`Could not save to keychain: ${err instanceof Error ? err.message : err}`);
10505
+ }
10506
+ }
10507
+ async clear() {
10508
+ try {
10509
+ await this.entry.deletePassword();
10510
+ } catch {
10511
+ }
10512
+ }
10513
+ };
10514
+ async function tryCreateKeychainStore() {
10459
10515
  try {
10460
- const raw = await readFile(CREDENTIALS_PATH, "utf-8");
10461
- const creds = JSON.parse(raw);
10462
- if (!creds.jwt || !creds.refreshToken || !creds.user) return null;
10463
- return creds;
10464
- } catch {
10516
+ const { AsyncEntry } = await import("@napi-rs/keyring");
10517
+ const probe = new AsyncEntry(KEYCHAIN_SERVICE, "__probe__");
10518
+ await probe.setPassword("probe");
10519
+ try {
10520
+ const val = await probe.getPassword();
10521
+ if (val !== "probe") throw new Error("Keychain probe read-back mismatch");
10522
+ } finally {
10523
+ await probe.deletePassword().catch(() => {
10524
+ });
10525
+ }
10526
+ const entry = new AsyncEntry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
10527
+ return new KeychainCredentialStore(entry);
10528
+ } catch (err) {
10529
+ warn(
10530
+ `OS keychain not available, using file-based credential storage: ${err instanceof Error ? err.message : err}`
10531
+ );
10465
10532
  return null;
10466
10533
  }
10467
10534
  }
10468
- async function saveCredentials(creds) {
10469
- try {
10470
- await mkdir(FAUX_DIR, { recursive: true });
10471
- await writeFile(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 384 });
10472
- } catch (err) {
10473
- warn(`Could not save credentials: ${err instanceof Error ? err.message : err}`);
10535
+ var initPromise = null;
10536
+ function createCredentialStore() {
10537
+ if (!initPromise) {
10538
+ initPromise = initStore();
10539
+ }
10540
+ return initPromise;
10541
+ }
10542
+ async function initStore() {
10543
+ const fileStore = new FileCredentialStore();
10544
+ const keychainStore = await tryCreateKeychainStore();
10545
+ if (!keychainStore) {
10546
+ return fileStore;
10547
+ }
10548
+ const keychainCreds = await keychainStore.load();
10549
+ if (!keychainCreds) {
10550
+ const fileCreds = await fileStore.load();
10551
+ if (fileCreds) {
10552
+ await keychainStore.save(fileCreds);
10553
+ const verified = await keychainStore.load();
10554
+ if (verified) {
10555
+ await fileStore.clear();
10556
+ log("Credentials migrated to OS keychain");
10557
+ } else {
10558
+ warn("Keychain migration could not be verified \u2014 keeping file-based credentials");
10559
+ return fileStore;
10560
+ }
10561
+ }
10474
10562
  }
10563
+ return keychainStore;
10475
10564
  }
10565
+
10566
+ // src/auth.ts
10567
+ var AUTH_BASE = process.env.FAUX_AUTH_URL || "https://auth.faux.design";
10568
+ var POLL_INTERVAL_MS = 2e3;
10569
+ var POLL_TIMEOUT_MS = 5 * 60 * 1e3;
10570
+ var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
10476
10571
  function isExpiringSoon(creds) {
10477
10572
  const expiresAt = new Date(creds.expiresAt).getTime();
10478
10573
  return Date.now() > expiresAt - REFRESH_BUFFER_MS;
@@ -10543,7 +10638,7 @@ async function authenticate() {
10543
10638
  expiresAt: new Date(Date.now() + 3600 * 1e3).toISOString(),
10544
10639
  user: data.user
10545
10640
  };
10546
- await saveCredentials(creds);
10641
+ await (await createCredentialStore()).save(creds);
10547
10642
  return creds;
10548
10643
  }
10549
10644
  if (data.status === "error") {
@@ -10570,7 +10665,8 @@ async function ensureAuth() {
10570
10665
  source: "api-key"
10571
10666
  };
10572
10667
  }
10573
- const saved = await loadCredentials();
10668
+ const credStore = await createCredentialStore();
10669
+ const saved = await credStore.load();
10574
10670
  if (saved) {
10575
10671
  if (!isExpiringSoon(saved)) {
10576
10672
  log(`Authenticated as ${saved.user.handle}`);
@@ -10584,7 +10680,7 @@ async function ensureAuth() {
10584
10680
  try {
10585
10681
  log("Refreshing authentication...");
10586
10682
  const refreshed = await refreshJwt(saved);
10587
- await saveCredentials(refreshed);
10683
+ await credStore.save(refreshed);
10588
10684
  log(`Authenticated as ${refreshed.user.handle}`);
10589
10685
  return {
10590
10686
  jwt: refreshed.jwt,
@@ -10607,12 +10703,13 @@ async function ensureAuth() {
10607
10703
  }
10608
10704
  async function refreshIfNeeded(current, force = false) {
10609
10705
  if (current.source === "api-key") return current;
10610
- const saved = await loadCredentials();
10706
+ const credStore = await createCredentialStore();
10707
+ const saved = await credStore.load();
10611
10708
  if (!saved) return current;
10612
10709
  if (!force && !isExpiringSoon(saved)) return current;
10613
10710
  try {
10614
10711
  const refreshed = await refreshJwt(saved);
10615
- await saveCredentials(refreshed);
10712
+ await credStore.save(refreshed);
10616
10713
  return {
10617
10714
  jwt: refreshed.jwt,
10618
10715
  refreshToken: refreshed.refreshToken,
@@ -10628,9 +10725,34 @@ async function refreshIfNeeded(current, force = false) {
10628
10725
  import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
10629
10726
  import { join as join2 } from "path";
10630
10727
  import { homedir as homedir2 } from "os";
10728
+ import { subtle } from "crypto";
10631
10729
  var FAUX_DIR2 = join2(homedir2(), ".faux");
10632
10730
  var CACHE_PATH = join2(FAUX_DIR2, "tool-cache.json");
10633
10731
  var API_BASE = process.env.FAUX_API_URL || "https://api.faux.design";
10732
+ var _publicKey = null;
10733
+ var _cachedVerifyKey = null;
10734
+ var _cachedKeyInput = null;
10735
+ function getVerifyKey() {
10736
+ return _publicKey;
10737
+ }
10738
+ async function importPublicKey(jwkJson) {
10739
+ if (_cachedVerifyKey && _cachedKeyInput === jwkJson) return _cachedVerifyKey;
10740
+ const jwk = JSON.parse(jwkJson);
10741
+ _cachedVerifyKey = await subtle.importKey("jwk", jwk, { name: "Ed25519" }, false, ["verify"]);
10742
+ _cachedKeyInput = jwkJson;
10743
+ return _cachedVerifyKey;
10744
+ }
10745
+ async function verifyScriptSignature(script, signature, keyJwk) {
10746
+ if (signature.length !== 128) return false;
10747
+ try {
10748
+ const key = await importPublicKey(keyJwk);
10749
+ const sigBytes = Uint8Array.from(signature.match(/.{2}/g).map((h) => parseInt(h, 16)));
10750
+ const scriptBytes = new TextEncoder().encode(script);
10751
+ return await subtle.verify("Ed25519", key, sigBytes, scriptBytes);
10752
+ } catch {
10753
+ return false;
10754
+ }
10755
+ }
10634
10756
  async function loadCachedTools() {
10635
10757
  try {
10636
10758
  const raw = await readFile2(CACHE_PATH, "utf-8");
@@ -10679,6 +10801,9 @@ async function fetchRemoteTools(jwt2) {
10679
10801
  throw new Error(`Failed to fetch tools (HTTP ${res.status})`);
10680
10802
  }
10681
10803
  const data = await res.json();
10804
+ if (data.signingKey) {
10805
+ _publicKey = data.signingKey;
10806
+ }
10682
10807
  return sanitizeTools(data.tools);
10683
10808
  }
10684
10809
  async function getTools(jwt2) {
@@ -10732,7 +10857,7 @@ async function generateScript(jwt2, toolName, params) {
10732
10857
  throw new Error(body.error || `Script generation failed (HTTP ${res.status})`);
10733
10858
  }
10734
10859
  const data = await res.json();
10735
- return data.script;
10860
+ return { script: data.script, signature: data.signature };
10736
10861
  }
10737
10862
 
10738
10863
  // node_modules/ws/wrapper.mjs
@@ -25819,7 +25944,7 @@ Resources provide quick read-only access to Figma state without tool calls:
25819
25944
  - Create components for reusable UI patterns.`;
25820
25945
  function createMcpServer(deps) {
25821
25946
  const server2 = new Server(
25822
- { name: "faux-studio", version: "0.3.12" },
25947
+ { name: "faux-studio", version: "0.4.1" },
25823
25948
  {
25824
25949
  capabilities: { tools: { listChanged: true }, resources: {}, logging: {} },
25825
25950
  instructions: INSTRUCTIONS
@@ -25884,11 +26009,13 @@ function createMcpServer(deps) {
25884
26009
  }) }]
25885
26010
  };
25886
26011
  } catch (err) {
26012
+ const transport = deps.getTransport();
26013
+ const restartHint = transport === "cdp" ? "Please disable and re-enable the faux-studio MCP server in your MCP client settings, then try again." : transport === "plugin" ? "Please close and reopen the faux-studio plugin in Figma, then try again." : "Please restart faux-studio and try again.";
25887
26014
  return {
25888
26015
  content: [{ type: "text", text: JSON.stringify({
25889
26016
  authenticated: false,
25890
26017
  error: err instanceof Error ? err.message : "Re-authentication failed",
25891
- message: "Could not authenticate. Please restart faux-studio."
26018
+ message: `Could not authenticate. ${restartHint}`
25892
26019
  }) }],
25893
26020
  isError: true
25894
26021
  };
@@ -25976,12 +26103,23 @@ function createMcpServer(deps) {
25976
26103
  }
25977
26104
  const message = err instanceof Error ? err.message : String(err);
25978
26105
  const isAuthError = message.includes("auth") || message.includes("401") || message.includes("AUTH_EXPIRED");
26106
+ if (isAuthError) {
26107
+ const transport = deps.getTransport();
26108
+ const reconnectHint = transport === "cdp" ? "If the `login` tool fails, ask the user to disable and re-enable the faux-studio MCP server in their MCP client settings, then try again." : transport === "plugin" ? "If the `login` tool fails, ask the user to close and reopen the faux-studio plugin in Figma, then try again." : "If the `login` tool fails, ask the user to restart faux-studio and try again.";
26109
+ return {
26110
+ content: [{
26111
+ type: "text",
26112
+ text: `Error: ${message}
26113
+
26114
+ Run the \`login\` tool to re-authenticate. ${reconnectHint}`
26115
+ }],
26116
+ isError: true
26117
+ };
26118
+ }
25979
26119
  return {
25980
26120
  content: [{
25981
26121
  type: "text",
25982
- text: isAuthError ? `Error: ${message}
25983
-
25984
- Run the \`login\` tool to re-authenticate.` : `Error: ${message}`
26122
+ text: `Error: ${message}`
25985
26123
  }],
25986
26124
  isError: true
25987
26125
  };
@@ -26112,16 +26250,29 @@ async function recoverCdp(client, script) {
26112
26250
  }
26113
26251
  async function generateWithAuth(toolName, params) {
26114
26252
  auth = await refreshIfNeeded(auth);
26253
+ let result;
26115
26254
  try {
26116
- return await generateScript(auth.jwt, toolName, params);
26255
+ result = await generateScript(auth.jwt, toolName, params);
26117
26256
  } catch (err) {
26118
26257
  if (err instanceof Error && err.message === "AUTH_EXPIRED") {
26119
26258
  warn("JWT expired during request, refreshing...");
26120
26259
  auth = await refreshIfNeeded(auth, true);
26121
- return await generateScript(auth.jwt, toolName, params);
26260
+ result = await generateScript(auth.jwt, toolName, params);
26261
+ } else {
26262
+ throw err;
26263
+ }
26264
+ }
26265
+ const verifyKey = getVerifyKey();
26266
+ if (verifyKey && result.signature) {
26267
+ if (!await verifyScriptSignature(result.script, result.signature, verifyKey)) {
26268
+ throw new Error("Script signature verification failed \u2014 possible tampering");
26122
26269
  }
26123
- throw err;
26270
+ } else if (verifyKey && !result.signature) {
26271
+ throw new Error("Signing key is configured but response is missing a signature \u2014 possible signature stripping attack");
26272
+ } else if (!verifyKey) {
26273
+ warn("No signing key available \u2014 skipping script signature verification");
26124
26274
  }
26275
+ return result.script;
26125
26276
  }
26126
26277
  var PLUGIN_URL = "https://faux.design/plugin";
26127
26278
  var PLUGIN_WAIT_MS = 1e4;
@@ -26303,6 +26454,11 @@ async function main() {
26303
26454
  source: auth.source,
26304
26455
  isApiKey: auth.source === "api-key"
26305
26456
  }),
26457
+ getTransport: () => {
26458
+ if (pluginServer.hasConnections) return "plugin";
26459
+ if (cdpClient?.connected) return "cdp";
26460
+ return "none";
26461
+ },
26306
26462
  setupFigma
26307
26463
  });
26308
26464
  await startServer(server2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faux-studio",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "AI-powered Figma design via MCP — connect any AI client to Figma Desktop",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "dist"
11
11
  ],
12
12
  "engines": {
13
- "node": ">=18"
13
+ "node": ">=20"
14
14
  },
15
15
  "scripts": {
16
16
  "build": "tsup",
@@ -18,6 +18,7 @@
18
18
  "build:plugin": "cd plugin && npm run build",
19
19
  "build:all": "npm run build && npm run build:plugin",
20
20
  "dev": "tsup --watch",
21
+ "test": "npx tsx --test test/*.test.ts",
21
22
  "prepublishOnly": "npm run build"
22
23
  },
23
24
  "devDependencies": {
@@ -26,6 +27,7 @@
26
27
  "@types/node": "^22.0.0",
27
28
  "@types/ws": "^8.5.0",
28
29
  "tsup": "^8.4.0",
30
+ "tsx": "^4.19.0",
29
31
  "typescript": "^5.8.0"
30
32
  },
31
33
  "keywords": [
@@ -41,5 +43,8 @@
41
43
  "type": "git",
42
44
  "url": "https://github.com/uxfreak/faux-studio.git"
43
45
  },
44
- "homepage": "https://faux.design"
46
+ "homepage": "https://faux.design",
47
+ "optionalDependencies": {
48
+ "@napi-rs/keyring": "^1.2.0"
49
+ }
45
50
  }