From 981ca9ee5e1067512c668a325a6ebcb33f23dec6 Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Fri, 12 Jun 2026 17:43:05 +0200 Subject: [PATCH] fix: never persist apiKey in shared document storage The plugin wrote the Tolgee apiKey into figma.root pluginData in addition to figma.clientStorage. Document pluginData is stored in the .fig file in plaintext, syncs to every collaborator and travels with file copies and exports, so the last-entered key leaked to all users and overrode their own key (getPluginData merges document settings over clientStorage). Keep the apiKey in per-user clientStorage only: - types: CurrentDocumentSettings now omits apiKey; TolgeeConfig picks it from GlobalSettings so the UI still works with it. - setDocumentData strips apiKey defensively before writing. - getDocumentData strips apiKey on read to self-heal documents that already contain a leaked key. Because the key is now per-user, a collaborator can open an already configured file (documentInfo is set in the document) while having no key of their own. Router previously gated the setup screen on documentInfo alone, so such a collaborator landed on a non-working Index. Force the Settings view when the apiKey is missing so they are prompted to enter one. apiUrl/namespace/branch intentionally stay document-scoped (connection info, not secrets). Relates to #50 (key "forgotten" = document override) and reduces document pluginData per Figma's request in #56. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/utils/settingsTools.ts | 24 +++++++++++++++++++----- src/types.ts | 13 +++++++++++-- src/ui/views/Router.tsx | 7 ++++++- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/main/utils/settingsTools.ts b/src/main/utils/settingsTools.ts index d00be79..aa19b25 100644 --- a/src/main/utils/settingsTools.ts +++ b/src/main/utils/settingsTools.ts @@ -36,19 +36,33 @@ export const deleteGlobalSettings = async () => { export const getDocumentData = (): Partial => { const pluginData = figma.root.getPluginData(TOLGEE_PLUGIN_CONFIG_NAME); - const result = pluginData - ? (JSON.parse(pluginData) as Partial) + // Parse into a loose record first so the self-heal can strip a field the + // declared type no longer admits, then narrow on return. + const stored = pluginData + ? (JSON.parse(pluginData) as Record) : {}; + // Self-heal: older versions persisted the apiKey into the shared document. + // Drop it so a leaked key can never override the per-user key from + // clientStorage (and so it stops being surfaced to other collaborators). + delete stored.apiKey; + return { ignorePrefix: "_", ignoreNumbers: true, - ...result, + ...(stored as Partial), }; }; const setDocumentData = (data: Partial) => { - figma.root.setPluginData(TOLGEE_PLUGIN_CONFIG_NAME, JSON.stringify(data)); + // Defense in depth: never write the apiKey into the shared document, even if + // a caller bypasses the type. The secret belongs in clientStorage only. + const documentData = { ...data }; + delete (documentData as { apiKey?: string }).apiKey; + figma.root.setPluginData( + TOLGEE_PLUGIN_CONFIG_NAME, + JSON.stringify(documentData), + ); }; export const deleteDocumentData = () => { @@ -93,10 +107,10 @@ export const setPluginData = async (data: Partial) => { updateScreenshots, variableCasing, } = data; + // apiKey -> per-user clientStorage only (never the shared document). await setGlobalSettings({ apiKey, apiUrl, ignorePrefix, ignoreNumbers }); setDocumentData({ addTags, - apiKey, apiUrl, branch, documentInfo: true, diff --git a/src/types.ts b/src/types.ts index ce41383..804b60f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -130,7 +130,12 @@ export type GlobalSettings = { | "noSpaces"; }; -export type CurrentDocumentSettings = GlobalSettings & { +// NOTE: `apiKey` is intentionally omitted. The API key is a secret and must +// never be persisted in the shared document (figma.root pluginData), which is +// stored in plaintext in the .fig file, syncs to every collaborator and travels +// with file copies/exports. The key lives in figma.clientStorage (per-user) +// only — see settingsTools.ts. +export type CurrentDocumentSettings = Omit & { namespace: string; branch?: string; documentInfo: true; @@ -144,7 +149,11 @@ export type CurrentPageSettings = { nodeInfo?: NodeInfo; }; -export type TolgeeConfig = CurrentDocumentSettings & CurrentPageSettings; +// The full in-memory config the UI works with. `apiKey` comes from the +// per-user clientStorage layer, the rest from the document/page layers. +export type TolgeeConfig = Pick & + CurrentDocumentSettings & + CurrentPageSettings; export type FormattedNode = { characters: string; diff --git a/src/ui/views/Router.tsx b/src/ui/views/Router.tsx index ecc83fe..59956d1 100644 --- a/src/ui/views/Router.tsx +++ b/src/ui/views/Router.tsx @@ -64,6 +64,7 @@ export const Router = () => { const routeKey = useGlobalState((c) => c.routeKey); const globalError = useGlobalState((c) => c.globalError); const documentInfo = useGlobalState((c) => c.config?.documentInfo); + const apiKey = useGlobalState((c) => c.config?.apiKey); const pageInfo = useGlobalState((c) => c.config?.pageInfo); const pageCopy = useGlobalState((c) => c.config?.pageCopy); const pageStringDetails = useGlobalState((c) => c.config?.pageStringDetails); @@ -71,7 +72,11 @@ export const Router = () => { const { setRoute } = useGlobalActions(); const editorMode = useEditorMode(); - const forceSettings = !pageCopy && !documentInfo; + // The apiKey now lives in per-user clientStorage, not the shared document, so + // a collaborator can open a configured file (documentInfo is set) yet have no + // key of their own. Force them into Settings to enter one instead of dropping + // them on a non-working Index. + const forceSettings = !pageCopy && (!documentInfo || !apiKey); const errorOnTop = !forceSettings && routeKey !== "settings"; const handleResolveError = () => {