diff --git a/docs/docs/Advanced/scriptsWithSettings.md b/docs/docs/Advanced/scriptsWithSettings.md index f9dea320..c72c1f78 100644 --- a/docs/docs/Advanced/scriptsWithSettings.md +++ b/docs/docs/Advanced/scriptsWithSettings.md @@ -46,6 +46,11 @@ module.exports = { placeholder: "Placeholder", description: "Description here.", }, + "API Key": { + type: "secret", + placeholder: "Paste API key", + description: "Stored securely with Obsidian SecretStorage.", + }, "Checkbox": { type: "checkbox", defaultValue: false, @@ -71,6 +76,7 @@ module.exports = { ## Setting types - `text` and `input`: A text field. +- `secret`: A password-style input stored with Obsidian SecretStorage. QuickAdd stores only a reference in `data.json`; package exports omit secret values and local secret references. The older `text` / `input` plus `secret: true` form is still treated as a secret setting. - `textarea`: A multi-line text area. - `checkbox` and `toggle`: A checkbox. - `dropdown` and `select`: A dropdown. diff --git a/docs/docs/UserScripts.md b/docs/docs/UserScripts.md index 1997c7da..afbfcf32 100644 --- a/docs/docs/UserScripts.md +++ b/docs/docs/UserScripts.md @@ -213,20 +213,36 @@ User scripts can define configurable options that users can set through the Quic ### Option Types #### Text Input -For string values, API keys, paths, etc. +For regular string values, paths, names, etc. ```javascript options: { - "API Key": { + "Project": { type: "text", // or "input" defaultValue: "", - placeholder: "Enter API key", - secret: true, // Optional: masks input for sensitive data - description: "Your API key" // Optional: help text + placeholder: "Project name", + description: "Default project" // Optional: help text + } +} +``` + +#### Secret Input +For API keys, access tokens, and other sensitive values. Secrets are stored in Obsidian's SecretStorage and QuickAdd stores only a reference in `data.json`. + +```javascript +options: { + "API Key": { + type: "secret", + placeholder: "Paste API key", + description: "Your API key" } } ``` +`type: "text"` / `type: "input"` with `secret: true` is still supported for older scripts and is treated as a secret setting. + +Secret values are local to the Obsidian app profile. They are not included in QuickAdd package exports and are not synced through the plugin's `data.json`; users must enter them on each device where the script runs. + #### Toggle/Checkbox For boolean on/off settings. @@ -280,10 +296,8 @@ module.exports = { author: "Your Name", options: { "API Key": { - type: "text", - defaultValue: "", + type: "secret", placeholder: "sk-...", - secret: true, description: "OpenAI API key" }, "Model": { diff --git a/src/engine/MacroChoiceEngine.entry.test.ts b/src/engine/MacroChoiceEngine.entry.test.ts index 3ae9a252..db472834 100644 --- a/src/engine/MacroChoiceEngine.entry.test.ts +++ b/src/engine/MacroChoiceEngine.entry.test.ts @@ -227,6 +227,57 @@ describe("MacroChoiceEngine user script entry handling", () => { ); }); + it("migrates legacy plaintext secret settings before invoking the entry export", async () => { + const entryFn = vi.fn().mockResolvedValue("entry-result"); + const secretStore = new Map(); + const secretApp = { + secretStorage: { + getSecret: vi.fn((name: string) => secretStore.get(name) ?? null), + setSecret: vi.fn((name: string, value: string) => { + secretStore.set(name, value); + }), + }, + } as unknown as App; + const saveSettings = vi.fn().mockResolvedValue(undefined); + const pluginWithSave = { saveSettings } as unknown as QuickAdd; + userScriptCommand.settings = { + "API Key": "legacy-secret", + }; + + mockGetUserScript.mockResolvedValue({ + entry: entryFn, + settings: { + options: { + "API Key": { + type: "secret", + }, + }, + }, + }); + + const engine = new MacroChoiceEngine( + secretApp, + pluginWithSave, + macroChoice, + choiceExecutor, + variables, + ); + + await engine["executeUserScript"](userScriptCommand); + + expect(secretStore.get("quickadd-user-script-user-script-api-key")).toBe( + "legacy-secret", + ); + expect(JSON.stringify(userScriptCommand.settings)).not.toContain( + "legacy-secret", + ); + expect(entryFn).toHaveBeenCalledWith( + expect.objectContaining({ variables: expect.any(Object) }), + { "API Key": "legacy-secret" }, + ); + expect(saveSettings).toHaveBeenCalledTimes(1); + }); + it("ignores malformed primitive settings exports at the user-script boundary", async () => { const entryFn = vi.fn().mockResolvedValue("entry-result"); diff --git a/src/engine/MacroChoiceEngine.ts b/src/engine/MacroChoiceEngine.ts index 07d08e5f..8e2800bd 100644 --- a/src/engine/MacroChoiceEngine.ts +++ b/src/engine/MacroChoiceEngine.ts @@ -49,6 +49,11 @@ import { TFile } from "obsidian"; import { MacroAbortError } from "../errors/MacroAbortError"; import { UserCancelError } from "../errors/UserCancelError"; import { initializeUserScriptSettings } from "../utils/userScriptSettings"; +import { + migrateUserScriptSecretSettings, + resolveUserScriptSettings, + type UserScriptSettingsDefinition, +} from "../utils/userScriptSecrets"; import type { IConditionalCommand } from "../types/macros/Conditional/IConditionalCommand"; import type { ScriptCondition } from "../types/macros/Conditional/types"; import { evaluateCondition } from "./helpers/conditionalEvaluator"; @@ -162,6 +167,7 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { protected choiceExecutor: IChoiceExecutor; protected readonly plugin: QuickAdd; private userScriptCommand: IUserScript | null; + private userScriptSettingsDefinition: UserScriptSettingsDefinition | undefined; private conditionalScriptCache = new Map(); private readonly preloadedUserScripts: Map; private readonly promptLabel?: string; @@ -248,6 +254,7 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { this.choiceExecutor = choiceExecutor; this.preloadedUserScripts = preloadedUserScripts ?? new Map(); this.promptLabel = promptLabel; + this.userScriptSettingsDefinition = undefined; const sharedVariables = this.initSharedVariables( choiceExecutor, variables @@ -340,13 +347,13 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { command.settings = {}; } - this.userScriptCommand = command; - const userScriptSettings = getUserScriptSettings(userScript); if (userScriptSettings) { // Initialize default values for settings before executing the script initializeUserScriptSettings(command.settings, userScriptSettings); } + this.userScriptCommand = command; + this.userScriptSettingsDefinition = userScriptSettings; try { await this.userScriptDelegator(userScript); @@ -359,9 +366,28 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { throw err; } finally { this.userScriptCommand = null; + this.userScriptSettingsDefinition = undefined; } } + private async getResolvedUserScriptSettings(command: IUserScript) { + if ( + await migrateUserScriptSecretSettings( + this.app, + command, + this.userScriptSettingsDefinition, + ) + ) { + await this.plugin.saveSettings?.(); + } + + return resolveUserScriptSettings( + this.app, + command, + this.userScriptSettingsDefinition, + ); + } + private async runScriptWithSettings( userScript: | (( @@ -383,12 +409,15 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { ) { return await this.onExportIsFunction( userScript.entry, - command.settings + await this.getResolvedUserScriptSettings(command), ); } if (typeof userScript === "function") { - return await this.onExportIsFunction(userScript, command.settings); + return await this.onExportIsFunction( + userScript, + await this.getResolvedUserScriptSettings(command), + ); } } diff --git a/src/engine/SingleMacroEngine.member-access.test.ts b/src/engine/SingleMacroEngine.member-access.test.ts index 3017f0db..cd066925 100644 --- a/src/engine/SingleMacroEngine.member-access.test.ts +++ b/src/engine/SingleMacroEngine.member-access.test.ts @@ -199,6 +199,108 @@ describe("SingleMacroEngine member access", () => { expect(choiceExecutor.variables.get("newValue")).toBe("hello"); }); + it("migrates legacy plaintext secret settings before member-access execution", async () => { + const secretStore = new Map(); + const secretApp = { + secretStorage: { + getSecret: vi.fn((name: string) => secretStore.get(name) ?? null), + setSecret: vi.fn((name: string, value: string) => { + secretStore.set(name, value); + }), + }, + } as unknown as App; + const saveSettings = vi.fn().mockResolvedValue(undefined); + const pluginWithSave = { saveSettings } as unknown as QuickAdd; + const userScript = createUserScript("user-script", "script.js"); + userScript.settings = { + "API Key": "legacy-secret", + }; + const macroChoice = baseMacroChoice([userScript]); + const choices: IChoice[] = [macroChoice]; + + const engineInstance = macroEngineFactory(); + macroEngineFactory = () => engineInstance; + const exportFn = vi.fn().mockReturnValue("secret-result"); + + mockGetUserScript.mockResolvedValue({ + settings: { + options: { + "API Key": { + type: "secret", + }, + }, + }, + f: exportFn, + }); + + const engine = new SingleMacroEngine( + secretApp, + pluginWithSave, + choices, + choiceExecutor, + ); + + await engine.runAndGetOutput("My Macro::f"); + + expect(secretStore.get("quickadd-user-script-user-script-api-key")).toBe( + "legacy-secret", + ); + expect(JSON.stringify(userScript.settings)).not.toContain("legacy-secret"); + expect(exportFn).toHaveBeenCalledWith(engineInstance.params, { + "API Key": "legacy-secret", + }); + expect(saveSettings).toHaveBeenCalledTimes(1); + }); + + it("does not resolve or migrate settings for non-callable member access", async () => { + const getSecret = vi.fn(() => { + throw new Error("should not read secrets for metadata access"); + }); + const setSecret = vi.fn(); + const secretApp = { + secretStorage: { + getSecret, + setSecret, + }, + } as unknown as App; + const saveSettings = vi.fn().mockResolvedValue(undefined); + const pluginWithSave = { saveSettings } as unknown as QuickAdd; + const userScript = createUserScript("user-script", "script.js"); + userScript.settings = { + "API Key": { + __quickaddSecret: true, + secretRef: "missing-secret", + }, + }; + const macroChoice = baseMacroChoice([userScript]); + const choices: IChoice[] = [macroChoice]; + const exportedSettings = { + options: { + "API Key": { + type: "secret", + }, + }, + }; + + mockGetUserScript.mockResolvedValue({ + settings: exportedSettings, + }); + + const engine = new SingleMacroEngine( + secretApp, + pluginWithSave, + choices, + choiceExecutor, + ); + + const result = await engine.runAndGetOutput("My Macro::settings"); + + expect(result).toBe(JSON.stringify(exportedSettings)); + expect(getSecret).not.toHaveBeenCalled(); + expect(setSecret).not.toHaveBeenCalled(); + expect(saveSettings).not.toHaveBeenCalled(); + }); + it("reaches a unique member on a later script while preserving pre and post execution", async () => { const preCommand = { id: "wait-1", diff --git a/src/engine/SingleMacroEngine.ts b/src/engine/SingleMacroEngine.ts index 68c5afc2..973c5178 100644 --- a/src/engine/SingleMacroEngine.ts +++ b/src/engine/SingleMacroEngine.ts @@ -9,6 +9,10 @@ import { CommandType } from "../types/macros/CommandType"; import { getUserScript, getUserScriptMemberAccess } from "../utilityObsidian"; import { flattenChoices } from "../utils/choiceUtils"; import { initializeUserScriptSettings } from "../utils/userScriptSettings"; +import { + migrateUserScriptSecretSettings, + resolveUserScriptSettings, +} from "../utils/userScriptSecrets"; import { MacroChoiceEngine } from "./MacroChoiceEngine"; import { handleMacroAbort } from "../utils/macroAbortHandler"; import { MacroAbortError } from "../errors/MacroAbortError"; @@ -299,11 +303,32 @@ export class SingleMacroEngine { } const postCommands = updatedCommands.slice(refreshedIndex + 1); + let memberSettings = userScriptCommand.settings; + if (typeof resolvedMember.value === "function") { + if ( + await migrateUserScriptSecretSettings( + this.app, + userScriptCommand, + settingsExport && typeof settingsExport === "object" + ? (settingsExport as Record) + : undefined, + ) + ) { + await this.plugin.saveSettings?.(); + } + memberSettings = await resolveUserScriptSettings( + this.app, + userScriptCommand, + settingsExport && typeof settingsExport === "object" + ? (settingsExport as Record) + : undefined, + ); + } const result = await this.executeResolvedMember( resolvedMember.value, engine, - userScriptCommand.settings, + memberSettings, ); this.ensureNotAborted(); diff --git a/src/gui/MacroGUIs/CommandSequenceEditor.ts b/src/gui/MacroGUIs/CommandSequenceEditor.ts index 20435f05..291e943a 100644 --- a/src/gui/MacroGUIs/CommandSequenceEditor.ts +++ b/src/gui/MacroGUIs/CommandSequenceEditor.ts @@ -52,6 +52,7 @@ import { OpenFileCommand } from "../../types/macros/QuickCommands/OpenFileComman import type { IConditionalCommand } from "../../types/macros/Conditional/IConditionalCommand"; import { getUserScriptMemberAccess } from "../../utilityObsidian"; import { ConditionalCommand } from "../../types/macros/Conditional/ConditionalCommand"; +import { clearUserScriptSecretsFromCommand } from "../../utils/userScriptSecrets"; type ConditionalHandler = (command: IConditionalCommand) => Promise; @@ -153,6 +154,17 @@ export class CommandSequenceEditor { ); if (!promptAnswer) return; + const secretsCleared = await clearUserScriptSecretsFromCommand( + this.app, + command + ); + if (!secretsCleared) { + new Notice( + "Could not clear user script secrets. Command was not deleted." + ); + return; + } + this.commandsRef = this.commandsRef.filter((c) => c.id !== commandId); this.emitCommandsChanged(); }, diff --git a/src/gui/MacroGUIs/UserScriptSettingsModal.test.ts b/src/gui/MacroGUIs/UserScriptSettingsModal.test.ts new file mode 100644 index 00000000..2a0e092a --- /dev/null +++ b/src/gui/MacroGUIs/UserScriptSettingsModal.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { App } from "obsidian"; +import { CommandType } from "../../types/macros/CommandType"; +import type { IUserScript } from "../../types/macros/IUserScript"; +import { createUserScriptSecretRef } from "../../utils/userScriptSecrets"; +import { UserScriptSettingsModal } from "./UserScriptSettingsModal"; + +vi.mock("../../quickAddInstance", () => ({ + getQuickAddInstance: vi.fn(() => ({})), +})); + +vi.mock("../../formatters/formatDisplayFormatter", () => ({ + FormatDisplayFormatter: class { + format(value: string): Promise { + return Promise.resolve(value); + } + }, +})); + +vi.mock("../suggesters/formatSyntaxSuggester", () => ({ + FormatSyntaxSuggester: class {}, +})); + +function createCommand(settings: Record = {}): IUserScript { + return { + id: "command-1", + name: "Script", + type: CommandType.UserScript, + path: "scripts/script.js", + settings, + }; +} + +function createSettings() { + return { + name: "Script Settings", + options: { + "API Key": { + type: "secret" as const, + defaultValue: "must-not-persist", + placeholder: "Paste API key", + description: "API key", + }, + }, + }; +} + +function flushPromises(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function inputValue(input: HTMLInputElement, value: string) { + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("UserScriptSettingsModal secret settings", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("does not write typed secret text into command settings until Save", async () => { + const app = new App(); + const command = createCommand(); + const onCommandChange = vi.fn(); + const modal = new UserScriptSettingsModal( + app, + command, + createSettings(), + onCommandChange, + ); + await flushPromises(); + + const input = modal.contentEl.querySelector("input") as HTMLInputElement; + inputValue(input, "secret-value"); + + expect(command.settings["API Key"]).toBeUndefined(); + expect(JSON.stringify(command.settings)).not.toContain("secret-value"); + expect(onCommandChange).not.toHaveBeenCalled(); + + const save = modal.contentEl.querySelector( + 'button[aria-label="Save API Key"]', + ) as HTMLButtonElement; + save.click(); + await flushPromises(); + + expect(app.secretStorage.getSecret("quickadd-user-script-command-1-api-key")) + .toBe("secret-value"); + expect(command.settings["API Key"]).toEqual( + createUserScriptSecretRef("quickadd-user-script-command-1-api-key"), + ); + expect(JSON.stringify(command.settings)).not.toContain("secret-value"); + expect(onCommandChange).toHaveBeenCalledTimes(1); + expect(input.value).toBe(""); + }); + + it("migrates existing plaintext secret settings after opening", async () => { + const app = new App(); + const command = createCommand({ + "API Key": "legacy-secret", + }); + const onCommandChange = vi.fn(); + + new UserScriptSettingsModal(app, command, createSettings(), onCommandChange); + await flushPromises(); + + expect(app.secretStorage.getSecret("quickadd-user-script-command-1-api-key")) + .toBe("legacy-secret"); + expect(command.settings["API Key"]).toEqual( + createUserScriptSecretRef("quickadd-user-script-command-1-api-key"), + ); + expect(JSON.stringify(command.settings)).not.toContain("legacy-secret"); + expect(onCommandChange).toHaveBeenCalledTimes(1); + }); + + it("clears the marker and stored secret", async () => { + const app = new App(); + app.secretStorage.setSecret( + "quickadd-user-script-command-1-api-key", + "secret-value", + ); + const command = createCommand({ + "API Key": createUserScriptSecretRef( + "quickadd-user-script-command-1-api-key", + ), + }); + const onCommandChange = vi.fn(); + const modal = new UserScriptSettingsModal( + app, + command, + createSettings(), + onCommandChange, + ); + await flushPromises(); + + const clear = modal.contentEl.querySelector( + 'button[aria-label="Clear API Key"]', + ) as HTMLButtonElement; + clear.click(); + await flushPromises(); + + expect(app.secretStorage.getSecret("quickadd-user-script-command-1-api-key")) + .toBeNull(); + expect(command.settings["API Key"]).toBeUndefined(); + expect(onCommandChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/gui/MacroGUIs/UserScriptSettingsModal.ts b/src/gui/MacroGUIs/UserScriptSettingsModal.ts index 6a631059..b48ac1f6 100644 --- a/src/gui/MacroGUIs/UserScriptSettingsModal.ts +++ b/src/gui/MacroGUIs/UserScriptSettingsModal.ts @@ -1,11 +1,19 @@ import type { App } from "obsidian"; -import { Modal, Setting, TextAreaComponent } from "obsidian"; +import { Modal, Notice, Setting, TextAreaComponent } from "obsidian"; import type { IUserScript } from "../../types/macros/IUserScript"; import { getQuickAddInstance } from "../../quickAddInstance"; import { FormatDisplayFormatter } from "../../formatters/formatDisplayFormatter"; import { FormatSyntaxSuggester } from "../suggesters/formatSyntaxSuggester"; import { setPasswordOnBlur } from "../../utils/setPasswordOnBlur"; import { initializeUserScriptSettings } from "../../utils/userScriptSettings"; +import { + clearUserScriptSecret, + createUserScriptSecretRef, + getSecretRefFromCommandSetting, + isSecretUserScriptOption, + migrateUserScriptSecretSettings, + storeUserScriptSecret, +} from "../../utils/userScriptSecrets"; type Option = { description?: string } & ( | { @@ -21,6 +29,12 @@ type Option = { description?: string } & ( placeholder?: string; defaultValue: string; } + | { + type: "secret"; + value?: string; + placeholder?: string; + defaultValue?: string; + } | { type: "checkbox" | "toggle"; value: boolean; @@ -65,11 +79,12 @@ export class UserScriptSettingsModal extends Modal { ) { super(app); - this.display(); if (!this.command.settings) this.command.settings = {}; // Initialize default values for settings initializeUserScriptSettings(this.command.settings, this.settings); + this.display(); + void this.migrateSecretSettings(); } protected display() { @@ -89,6 +104,16 @@ export class UserScriptSettingsModal extends Modal { for (const option in options) { if (!Object.prototype.hasOwnProperty.call(options, option)) continue; const entry = options[option]; + if (isSecretUserScriptOption(entry)) { + const setting = this.addSecretInput( + option, + "placeholder" in entry ? entry.placeholder : undefined, + ); + if (entry.description) { + setting.setDesc(entry.description); + } + continue; + } let value = entry.defaultValue; @@ -154,6 +179,92 @@ export class UserScriptSettingsModal extends Modal { }); } + private addSecretInput(name: string, placeholder?: string) { + const setting = new Setting(this.contentEl).setName(name); + let pendingValue = ""; + let inputEl: HTMLInputElement | undefined; + + const hasSecret = () => { + const value = this.command.settings?.[name]; + return ( + getSecretRefFromCommandSetting(this.command, name) !== undefined || + (typeof value === "string" && value.length > 0) + ); + }; + const updatePlaceholder = () => { + if (!inputEl) return; + inputEl.placeholder = hasSecret() + ? "Secret saved" + : (placeholder ?? "Paste secret"); + }; + + setting.addText((input) => { + input + .setValue("") + .onChange((value) => { + pendingValue = value; + }); + input.inputEl.type = "password"; + input.inputEl.addClass("qa-user-script-secret-input"); + input.inputEl.setAttribute("aria-label", name); + inputEl = input.inputEl; + updatePlaceholder(); + }); + + setting.addButton((button) => { + button.setIcon("save").setTooltip("Save secret").onClick(async () => { + if (pendingValue.length === 0) { + new Notice("Paste a secret before saving."); + return; + } + + const secretRef = await storeUserScriptSecret( + this.app, + this.command, + name, + pendingValue, + getSecretRefFromCommandSetting(this.command, name), + ); + + if (!secretRef) { + new Notice("SecretStorage is unavailable. Secret was not saved."); + return; + } + + this.command.settings[name] = createUserScriptSecretRef(secretRef); + pendingValue = ""; + if (inputEl) inputEl.value = ""; + updatePlaceholder(); + this.onCommandChange?.(); + new Notice("Secret saved."); + }); + button.buttonEl.setAttribute("aria-label", `Save ${name}`); + }); + + setting.addButton((button) => { + button.setIcon("trash-2").setTooltip("Clear secret").onClick(async () => { + const secretRef = getSecretRefFromCommandSetting(this.command, name); + if (secretRef) { + const cleared = await clearUserScriptSecret(this.app, secretRef); + if (!cleared) { + new Notice("SecretStorage is unavailable. Secret was not cleared."); + return; + } + } + + delete this.command.settings[name]; + pendingValue = ""; + if (inputEl) inputEl.value = ""; + updatePlaceholder(); + this.onCommandChange?.(); + new Notice("Secret cleared."); + }); + button.buttonEl.setAttribute("aria-label", `Clear ${name}`); + }); + + return setting; + } + private addTextArea( name: string, value: string, @@ -225,4 +336,17 @@ export class UserScriptSettingsModal extends Modal { return setting; } + + private async migrateSecretSettings() { + if ( + await migrateUserScriptSecretSettings( + this.app, + this.command, + this.settings, + ) + ) { + this.onCommandChange?.(); + this.display(); + } + } } diff --git a/src/gui/choiceList/ChoiceView.svelte b/src/gui/choiceList/ChoiceView.svelte index 9fb755de..126f3685 100644 --- a/src/gui/choiceList/ChoiceView.svelte +++ b/src/gui/choiceList/ChoiceView.svelte @@ -12,7 +12,7 @@ createToggleCommandChoice, findChoiceById, deleteChoiceWithConfirmation, - duplicateChoice, + duplicateChoiceWithUserScriptSecretSanitization, addChoiceToTree, moveChoice as moveChoiceService, removeChoiceById, @@ -238,8 +238,11 @@ save(); } - function handleDuplicateChoice(sourceChoice: IChoice) { - const newChoice = duplicateChoice(liveChoice(sourceChoice)); + async function handleDuplicateChoice(sourceChoice: IChoice) { + const newChoice = await duplicateChoiceWithUserScriptSecretSanitization( + liveChoice(sourceChoice), + app, + ); choices = [...choices, newChoice]; save(); } diff --git a/src/services/choiceService.test.ts b/src/services/choiceService.test.ts index 1992a751..698cc69b 100644 --- a/src/services/choiceService.test.ts +++ b/src/services/choiceService.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { App } from "obsidian"; +import { Notice, type App } from "obsidian"; import type QuickAdd from "../main"; import type IChoice from "../types/choices/IChoice"; import type IMacroChoice from "../types/choices/IMacroChoice"; @@ -105,6 +105,7 @@ const { configureChoice, createToggleCommandChoice, CommandRegistry, + duplicateChoiceWithUserScriptSecretSanitization, moveChoice, findChoiceById, } = await import("./choiceService"); @@ -113,6 +114,7 @@ const { TemplateChoice } = await import("../types/choices/TemplateChoice"); const { CaptureChoice } = await import("../types/choices/CaptureChoice"); const { MacroChoice } = await import("../types/choices/MacroChoice"); const { MultiChoice } = await import("../types/choices/MultiChoice"); +const { createUserScriptSecretRef } = await import("../utils/userScriptSecrets"); // Minimal fakes — App/QuickAdd are only used as opaque references here. const fakeApp = { name: "fake-app" } as unknown as App; @@ -125,6 +127,7 @@ describe("choiceService", () => { mocks.multiModal.mockReset(); mocks.yesNoPrompt.mockReset(); mocks.storeChoices = []; + (Notice as unknown as { instances: unknown[] }).instances.length = 0; }); describe("createChoice", () => { @@ -233,6 +236,62 @@ describe("choiceService", () => { expect(original.macro.commands[0].name).toBe("Cmd"); }); + it("strips user-script secrets when duplicating macros", async () => { + const original = createChoice("Macro", "Mac") as IMacroChoice; + original.macro.commands.push({ + id: "cmd-1", + name: "Run script", + type: "UserScript", + path: "Scripts/script.md", + settings: { + "API Key": "legacy-secret", + Token: createUserScriptSecretRef("local-secret-ref"), + Model: "gpt-4", + Enabled: true, + }, + } as unknown as IMacroChoice["macro"]["commands"][number]); + const app = { + vault: { + adapter: { + exists: vi.fn().mockResolvedValue(true), + read: vi.fn().mockResolvedValue( + [ + "# Script note", + "", + "```js", + "module.exports = {", + " settings: {", + " options: {", + " \"API Key\": { \"type\": \"secret\" },", + " Token: { type: \"text\", secret: true },", + " Model: { type: \"text\" },", + " },", + " },", + "};", + "```", + ].join("\n"), + ), + }, + }, + } as unknown as App; + + const copy = await duplicateChoiceWithUserScriptSecretSanitization( + original, + app, + ) as IMacroChoice; + const copiedCommand = copy.macro.commands[0] as unknown as { + settings: Record; + }; + + expect(copiedCommand.settings).toEqual({ + Model: "gpt-4", + Enabled: true, + }); + expect(JSON.stringify(copy)).not.toContain("legacy-secret"); + expect(JSON.stringify(copy)).not.toContain("local-secret-ref"); + expect(JSON.stringify(copy)).not.toContain("__quickaddSecret"); + }); + it("recursively duplicates nested Multi choices and preserves placeholder/collapsed", () => { const inner = createChoice("Template", "Inner"); const nestedMulti = createChoice("Multi", "Nested") as IMultiChoice; @@ -354,6 +413,59 @@ describe("choiceService", () => { expect(message).toContain("macro commands"); }); + it("clears nested user-script secrets when deleting a Macro choice", async () => { + mocks.yesNoPrompt.mockResolvedValue(true); + const deleteSecret = vi.fn(); + const app = { + secretStorage: { + delete: deleteSecret, + }, + } as unknown as App; + const macro = createChoice("Macro", "MyMacro") as IMacroChoice; + macro.macro.commands.push({ + id: "cmd", + name: "Run script", + type: "UserScript", + path: "Scripts/script.js", + settings: { + "API Key": createUserScriptSecretRef("local-secret-ref"), + }, + } as unknown as IMacroChoice["macro"]["commands"][number]); + + await deleteChoiceWithConfirmation(macro, app); + + expect(deleteSecret).toHaveBeenCalledWith("local-secret-ref"); + }); + + it("keeps a Macro choice when clearing nested secrets fails", async () => { + mocks.yesNoPrompt.mockResolvedValue(true); + const app = { + secretStorage: { + delete: vi.fn().mockRejectedValue(new Error("delete failed")), + }, + } as unknown as App; + const macro = createChoice("Macro", "MyMacro") as IMacroChoice; + macro.macro.commands.push({ + id: "cmd", + name: "Run script", + type: "UserScript", + path: "Scripts/script.js", + settings: { + "API Key": createUserScriptSecretRef("local-secret-ref"), + }, + } as unknown as IMacroChoice["macro"]["commands"][number]); + + const result = await deleteChoiceWithConfirmation(macro, app); + + expect(result).toBe(false); + expect((Notice as unknown as { instances: { message: string }[] }).instances) + .toContainEqual({ + message: "Could not clear user script secrets. Choice was not deleted.", + timeout: undefined, + messageEl: expect.any(HTMLElement), + }); + }); + it("does not include Multi/Macro warnings for a plain Template choice", async () => { mocks.yesNoPrompt.mockResolvedValue(true); const choice = createChoice("Template", "Plain"); diff --git a/src/services/choiceService.ts b/src/services/choiceService.ts index 55d7b08f..1d0bba54 100644 --- a/src/services/choiceService.ts +++ b/src/services/choiceService.ts @@ -1,4 +1,4 @@ -import type { App } from "obsidian"; +import { Notice, type App } from "obsidian"; import type { ChoiceType } from "src/types/choices/choiceType"; import { CaptureChoiceBuilder } from "../gui/ChoiceBuilder/captureChoiceBuilder"; import { TemplateChoiceBuilder } from "../gui/ChoiceBuilder/templateChoiceBuilder"; @@ -19,6 +19,13 @@ import { TemplateChoice } from "../types/choices/TemplateChoice"; import { regenerateIds } from "../utils/macroUtils"; import { excludeKeys } from "../utils/excludeKeys"; import { deepClone } from "../utils/deepClone"; +import { + clearUserScriptSecretsFromCommand, + detectUserScriptSecretOptions, + stripUserScriptSecretRefsFromChoice, +} from "../utils/userScriptSecrets"; +import type { UserScriptSecretSanitizerOptions } from "../utils/userScriptSecrets"; +import { log } from "../logger/logManager"; const choiceConstructors: Record IChoice> = { Template: TemplateChoice, @@ -36,7 +43,10 @@ export function createChoice(type: ChoiceType, name: string): IChoice { /** * Recursively duplicates a choice, ensuring unique ids and deep-cloning macros. */ -export function duplicateChoice(choice: IChoice): IChoice { +export function duplicateChoice( + choice: IChoice, + secretSanitizerOptions?: UserScriptSecretSanitizerOptions, +): IChoice { const newChoice = createChoice(choice.type, `${choice.name} (copy)`); if (choice.type === "Multi") { @@ -46,7 +56,9 @@ export function duplicateChoice(choice: IChoice): IChoice { // the other choice types). `choices` is excluded here and set via the // recursive map below so children get fresh ids, not the source's. Object.assign(newMulti, excludeKeys(sourceMulti, ["id", "name", "choices"])); - newMulti.choices = sourceMulti.choices.map(duplicateChoice); + newMulti.choices = sourceMulti.choices.map((child) => + duplicateChoice(child, secretSanitizerOptions) + ); return newMulti; } @@ -56,11 +68,110 @@ export function duplicateChoice(choice: IChoice): IChoice { if (choice.type === "Macro") { (newChoice as IMacroChoice).macro = deepClone((choice as IMacroChoice).macro); regenerateIds((newChoice as IMacroChoice).macro); + stripUserScriptSecretRefsFromChoice(newChoice, secretSanitizerOptions); } return newChoice; } +export async function duplicateChoiceWithUserScriptSecretSanitization( + choice: IChoice, + app: App, +): Promise { + const secretOptionNamesByPath = await buildSecretOptionNamesByPath(app, choice); + + return duplicateChoice(choice, { + secretOptionNamesByPath, + stripUnknownStringSettings: true, + }); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function collectUserScriptPathsFromCommand( + command: unknown, + paths: Set, +): void { + if (!isRecord(command)) return; + + if (command.type === "UserScript" && typeof command.path === "string") { + paths.add(command.path); + } + + if (Array.isArray(command.thenCommands)) { + for (const child of command.thenCommands) { + collectUserScriptPathsFromCommand(child, paths); + } + } + + if (Array.isArray(command.elseCommands)) { + for (const child of command.elseCommands) { + collectUserScriptPathsFromCommand(child, paths); + } + } + + collectUserScriptPathsFromChoice(command.choice, paths); +} + +function collectUserScriptPathsFromChoice( + choice: unknown, + paths: Set, +): void { + if (!isRecord(choice)) return; + + if (choice.type === "Macro" && isRecord(choice.macro)) { + const commands = choice.macro.commands; + if (Array.isArray(commands)) { + for (const command of commands) { + collectUserScriptPathsFromCommand(command, paths); + } + } + } + + if (choice.type === "Multi" && Array.isArray(choice.choices)) { + for (const child of choice.choices) { + collectUserScriptPathsFromChoice(child, paths); + } + } +} + +async function buildSecretOptionNamesByPath( + app: App, + choice: IChoice, +): Promise | null>> { + const paths = new Set(); + collectUserScriptPathsFromChoice(choice, paths); + const secretOptionNamesByPath = new Map | null>(); + + for (const path of paths) { + try { + const exists = await app.vault.adapter.exists(path); + if (!exists) continue; + + const detection = detectUserScriptSecretOptions( + await app.vault.adapter.read(path), + path, + ); + secretOptionNamesByPath.set( + path, + detection.foundSecretOptions && detection.names.size === 0 + ? null + : detection.names, + ); + } catch (error) { + log.logWarning( + `QuickAdd could not inspect user-script settings '${path}' while duplicating choice: ${ + (error as Error)?.message ?? error + }`, + ); + } + } + + return secretOptionNamesByPath; +} + /** * Get the appropriate builder for a choice */ @@ -121,7 +232,20 @@ export async function deleteChoiceWithConfirmation( `, ); - return userConfirmed; + if (!userConfirmed) return false; + + if (isMacro || isMulti) { + const cleared = await clearUserScriptSecretsFromCommand(app, { + type: "NestedChoice", + choice, + }); + if (!cleared) { + new Notice("Could not clear user script secrets. Choice was not deleted."); + return false; + } + } + + return true; } /** diff --git a/src/services/packageExportService.test.ts b/src/services/packageExportService.test.ts index de492c6e..d4a2e5e6 100644 --- a/src/services/packageExportService.test.ts +++ b/src/services/packageExportService.test.ts @@ -14,6 +14,7 @@ import type IMultiChoice from "../types/choices/IMultiChoice"; import type IMacroChoice from "../types/choices/IMacroChoice"; import type ITemplateChoice from "../types/choices/ITemplateChoice"; import type ICaptureChoice from "../types/choices/ICaptureChoice"; +import { createUserScriptSecretRef } from "../utils/userScriptSecrets"; // --- Choice factory helpers (minimal but type-shaped) ----------------------- @@ -436,6 +437,56 @@ describe("buildPackage", () => { expect(result.missingAssets).toEqual([]); }); + it("strips user-script secrets from exported package choices", async () => { + const macro = makeMacroChoice("macro1", "Macro", [ + { + id: "cmd-us", + name: "Run script", + type: CommandType.UserScript, + path: "Scripts/userScript.md", + settings: { + "API Key": "legacy-secret", + Token: createUserScriptSecretRef("local-secret-ref"), + Model: "gpt-4", + }, + } as never, + ]); + const { app } = makeFakeApp({ + files: { + "Scripts/userScript.md": [ + "# Script note", + "", + "```js", + "module.exports = {", + " settings: {", + " options: {", + " \"API Key\": { \"type\": \"secret\" },", + " Token: { type: \"text\", secret: true },", + " Model: { type: \"text\" },", + " },", + " },", + " entry: () => {},", + "};", + "```", + ].join("\n"), + }, + }); + + const result = await buildPackage( + app as never, + buildOptions({ choices: [macro], rootChoiceIds: ["macro1"] }), + ); + const exported = result.pkg.choices[0].choice as IMacroChoice; + const exportedCommand = exported.macro.commands[0] as unknown as { + settings: Record; + }; + + expect(exportedCommand.settings).toEqual({ Model: "gpt-4" }); + expect(JSON.stringify(result.pkg)).not.toContain("legacy-secret"); + expect(JSON.stringify(result.pkg)).not.toContain("local-secret-ref"); + expect(JSON.stringify(result.pkg)).not.toContain("__quickaddSecret"); + }); + it("collects capture-template assets from a Capture choice", async () => { const capture = makeCaptureChoice( "cap1", diff --git a/src/services/packageExportService.ts b/src/services/packageExportService.ts index 97cf2482..0f7ce225 100644 --- a/src/services/packageExportService.ts +++ b/src/services/packageExportService.ts @@ -3,10 +3,10 @@ import { normalizePath } from "obsidian"; import type IChoice from "../types/choices/IChoice"; import type IMultiChoice from "../types/choices/IMultiChoice"; import type { - QuickAddPackage, - QuickAddPackageAsset, - QuickAddPackageChoice, - QuickAddPackageAssetKind, + QuickAddPackage, + QuickAddPackageAsset, + QuickAddPackageChoice, + QuickAddPackageAssetKind, } from "../types/packages/QuickAddPackage"; import { QUICKADD_PACKAGE_SCHEMA_VERSION } from "../types/packages/QuickAddPackage"; import { @@ -15,9 +15,13 @@ import { collectFileDependencies, } from "../utils/packageTraversal"; import { log } from "../logger/logManager"; -import { encodeToBase64 } from "../utils/base64"; +import { decodeFromBase64, encodeToBase64 } from "../utils/base64"; import { deepClone } from "../utils/deepClone"; import { ensureParentFolders } from "../utils/ensureParentFolders"; +import { + detectUserScriptSecretOptions, + stripUserScriptSecretRefsFromChoice, +} from "../utils/userScriptSecrets"; export interface BuildPackageOptions { choices: IChoice[]; @@ -61,6 +65,9 @@ export async function buildPackage( const assetDescriptors = collectAssetDescriptors(scripts, files); const assets = await encodeAssets(app, assetDescriptors); + const secretOptionNamesByPath = buildSecretOptionNamesByPath( + assets.encodedAssets, + ); const packageChoices: QuickAddPackageChoice[] = closure.choiceIds.map( (choiceId) => { @@ -68,6 +75,10 @@ export async function buildPackage( if (!entry) throw new Error(`Choice '${choiceId}' missing from catalog.`); const clonedChoice = deepClone(entry.choice); pruneChoiceTree(clonedChoice, includedChoiceIds); + stripUserScriptSecretRefsFromChoice(clonedChoice, { + secretOptionNamesByPath, + stripUnknownStringSettings: true, + }); return { choice: clonedChoice, pathHint: [...entry.path], @@ -141,39 +152,70 @@ async function encodeAssets( app: App, descriptors: AssetDescriptor[], ): Promise { - const encodedAssets: QuickAddPackageAsset[] = []; - const missingAssets: MissingAsset[] = []; - - for (const { path, kind } of descriptors) { - try { - const exists = await app.vault.adapter.exists(path); - if (!exists) { - missingAssets.push({ path, kind }); - log.logWarning(`QuickAdd export skipped missing ${kind}: ${path}`); - continue; - } - - const content = await app.vault.adapter.read(path); - - encodedAssets.push({ - kind, - originalPath: path, - contentEncoding: "base64", - content: encodeToBase64(content), - }); - } catch (error) { - missingAssets.push({ path, kind }); - log.logWarning( - `QuickAdd export failed to read ${kind} '${path}': ${ - (error as Error)?.message ?? error - }`, - ); - } - } + const encodedAssets: QuickAddPackageAsset[] = []; + const missingAssets: MissingAsset[] = []; + + for (const { path, kind } of descriptors) { + try { + const exists = await app.vault.adapter.exists(path); + if (!exists) { + missingAssets.push({ path, kind }); + log.logWarning(`QuickAdd export skipped missing ${kind}: ${path}`); + continue; + } + + const content = await app.vault.adapter.read(path); + + encodedAssets.push({ + kind, + originalPath: path, + contentEncoding: "base64", + content: encodeToBase64(content), + }); + } catch (error) { + missingAssets.push({ path, kind }); + log.logWarning( + `QuickAdd export failed to read ${kind} '${path}': ${ + (error as Error)?.message ?? error + }`, + ); + } + } return { encodedAssets, missingAssets }; } +function buildSecretOptionNamesByPath( + assets: QuickAddPackageAsset[], +): Map | null> { + const secretOptionNamesByPath = new Map | null>(); + + for (const asset of assets) { + if (asset.kind !== "user-script") continue; + + try { + const detection = detectUserScriptSecretOptions( + decodeFromBase64(asset.content), + asset.originalPath, + ); + secretOptionNamesByPath.set( + asset.originalPath, + detection.foundSecretOptions && detection.names.size === 0 + ? null + : detection.names, + ); + } catch (error) { + log.logWarning( + `QuickAdd export could not inspect user-script settings '${asset.originalPath}': ${ + (error as Error)?.message ?? error + }`, + ); + } + } + + return secretOptionNamesByPath; +} + function pruneChoiceTree( choice: IChoice, includedIds: ReadonlySet, diff --git a/src/services/packageImportService.test.ts b/src/services/packageImportService.test.ts index 5d30af83..0e37ed49 100644 --- a/src/services/packageImportService.test.ts +++ b/src/services/packageImportService.test.ts @@ -34,6 +34,7 @@ import type { ChoiceImportDecision, AssetImportDecision, } from "./packageImportService"; +const { createUserScriptSecretRef } = await import("../utils/userScriptSecrets"); // --- Fake app / vault ------------------------------------------------------- @@ -570,6 +571,75 @@ describe("applyPackageImport - duplicate mode and id remapping", () => { expect(result.updatedChoices[0].name).toBe("Orig"); }); + it("strips user-script secrets from imported package choices", async () => { + const { app } = createFakeApp(); + const macro = { + ...makeChoice("macro", "Macro", "Macro"), + macro: { + id: "macro-id", + name: "M", + commands: [ + { + id: "cmd", + name: "Run script", + type: CommandType.UserScript, + path: "Scripts/script.md", + settings: { + "API Key": "legacy-secret", + Token: createUserScriptSecretRef("local-secret-ref"), + Model: "gpt-4", + }, + } as IUserScript, + ], + }, + runOnStartup: false, + } as IMacroChoice; + const pkg = makePackage({ + choices: [makePackageChoice(macro)], + assets: [ + { + kind: "user-script", + originalPath: "Scripts/script.md", + contentEncoding: "base64", + content: encodeToBase64( + [ + "# Script note", + "", + "```js", + "module.exports = {", + " settings: {", + " options: {", + " \"API Key\": { \"type\": \"secret\" },", + " Token: { type: \"text\", secret: true },", + " Model: { type: \"text\" },", + " },", + " },", + " entry: () => {},", + "};", + "```", + ].join("\n"), + ), + }, + ], + }); + + const result = await applyPackageImport({ + app, + existingChoices: [], + pkg, + choiceDecisions: decisions([["macro", "import"]]), + assetDecisions: [], + }); + + const inserted = result.updatedChoices[0] as IMacroChoice; + const command = inserted.macro.commands[0] as IUserScript; + + expect(command.settings).toEqual({ Model: "gpt-4" }); + expect(JSON.stringify(inserted)).not.toContain("legacy-secret"); + expect(JSON.stringify(inserted)).not.toContain("local-secret-ref"); + expect(JSON.stringify(inserted)).not.toContain("__quickaddSecret"); + }); + it("propagates duplication to descendants and regenerates macro/command ids", async () => { const { app } = createFakeApp(); const macroChild = { diff --git a/src/services/packageImportService.ts b/src/services/packageImportService.ts index 4feb0fb8..cbb5522f 100644 --- a/src/services/packageImportService.ts +++ b/src/services/packageImportService.ts @@ -22,6 +22,11 @@ import { log } from "../logger/logManager"; import { decodeFromBase64 } from "../utils/base64"; import { deepClone } from "../utils/deepClone"; import { ensureParentFolders } from "../utils/ensureParentFolders"; +import { + detectUserScriptSecretOptions, + stripUserScriptSecretRefsFromCommand, +} from "../utils/userScriptSecrets"; +import type { UserScriptSecretSanitizerOptions } from "../utils/userScriptSecrets"; import { buildPackagePreview, collectReferencedAssetPaths, @@ -252,6 +257,7 @@ export async function applyPackageImport( ); const catalog = new Map(pkg.choices.map((entry) => [entry.choice.id, entry])); + const secretOptionNamesByPath = buildSecretOptionNamesByPath(pkg); const importableChoiceIds = new Set(); const importableCache = new Map(); const importableVisiting = new Set(); @@ -375,7 +381,15 @@ export async function applyPackageImport( } const clone = deepClone(entry.choice); - const remapped = remapChoiceTree(clone, idMap, importableChoiceIds); + const remapped = remapChoiceTree( + clone, + idMap, + importableChoiceIds, + { + secretOptionNamesByPath, + stripUnknownStringSettings: true, + }, + ); preparedChoices.set(entry.choice.id, remapped); } @@ -497,6 +511,37 @@ export async function applyPackageImport( }; } +function buildSecretOptionNamesByPath( + pkg: QuickAddPackage, +): Map | null> { + const secretOptionNamesByPath = new Map | null>(); + + for (const asset of pkg.assets) { + if (asset.kind !== "user-script") continue; + + try { + const detection = detectUserScriptSecretOptions( + decodeFromBase64(asset.content), + asset.originalPath, + ); + secretOptionNamesByPath.set( + asset.originalPath, + detection.foundSecretOptions && detection.names.size === 0 + ? null + : detection.names, + ); + } catch (error) { + log.logWarning( + `QuickAdd import could not inspect user-script settings '${asset.originalPath}': ${ + (error as Error)?.message ?? error + }`, + ); + } + } + + return secretOptionNamesByPath; +} + async function assetExists(app: App, path: string): Promise { try { return await app.vault.adapter.exists(path); @@ -509,6 +554,7 @@ function remapChoiceTree( choice: IChoice, idMap: Map, importableChoiceIds: Set, + secretSanitizerOptions: UserScriptSecretSanitizerOptions, ): IChoice { const originalId = choice.id; const finalId = idMap.get(originalId) ?? originalId; @@ -520,7 +566,13 @@ function remapChoiceTree( if (isDuplicated) { macroChoice.macro.id = uuidv4(); } - remapCommands(macroChoice.macro.commands, idMap, importableChoiceIds, isDuplicated); + remapCommands( + macroChoice.macro.commands, + idMap, + importableChoiceIds, + isDuplicated, + secretSanitizerOptions, + ); } if (choice.type === "Multi") { @@ -528,7 +580,14 @@ function remapChoiceTree( if (Array.isArray(multi.choices)) { multi.choices = multi.choices .filter((child) => importableChoiceIds.has(child.id)) - .map((child) => remapChoiceTree(child, idMap, importableChoiceIds)); + .map((child) => + remapChoiceTree( + child, + idMap, + importableChoiceIds, + secretSanitizerOptions, + ), + ); } } @@ -540,9 +599,11 @@ function remapCommands( idMap: Map, importableChoiceIds: Set, shouldRegenerateIds: boolean, + secretSanitizerOptions: UserScriptSecretSanitizerOptions, ): void { for (const command of commands) { if (!command) continue; + stripUserScriptSecretRefsFromCommand(command, secretSanitizerOptions); if (shouldRegenerateIds) { command.id = uuidv4(); @@ -562,12 +623,14 @@ function remapCommands( idMap, importableChoiceIds, shouldRegenerateIds, + secretSanitizerOptions, ); remapCommands( conditional.elseCommands, idMap, importableChoiceIds, shouldRegenerateIds, + secretSanitizerOptions, ); break; } @@ -578,6 +641,7 @@ function remapCommands( nested.choice, idMap, importableChoiceIds, + secretSanitizerOptions, ); } break; diff --git a/src/utils/userScriptSecrets.test.ts b/src/utils/userScriptSecrets.test.ts new file mode 100644 index 00000000..5f0734f4 --- /dev/null +++ b/src/utils/userScriptSecrets.test.ts @@ -0,0 +1,468 @@ +import type { App } from "obsidian"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CommandType } from "../types/macros/CommandType"; +import type { IUserScript } from "../types/macros/IUserScript"; +import { initializeUserScriptSettings } from "./userScriptSettings"; + +const { logWarningMock } = vi.hoisted(() => ({ + logWarningMock: vi.fn(), +})); + +vi.mock("../logger/logManager", () => ({ + log: { + logWarning: logWarningMock, + }, +})); + +const { + buildUserScriptSecretId, + clearUserScriptSecret, + clearUserScriptSecretsFromCommand, + createUserScriptSecretRef, + detectUserScriptSecretOptions, + isSecretUserScriptOption, + isUserScriptSecretRef, + migrateUserScriptSecretSettings, + resolveUserScriptSettings, + storeUserScriptSecret, + stripUserScriptSecretRefsFromCommand, +} = await import("./userScriptSecrets"); + +function createCommand(settings: Record = {}): IUserScript { + return { + id: "command-1", + name: "Script", + type: CommandType.UserScript, + path: "scripts/script.js", + settings, + }; +} + +function createApp(secretStorage: { + getSecret?: (name: string) => Promise | string | null; + setSecret?: (name: string, value: string) => Promise | void; + delete?: (name: string) => Promise | void; +}): App { + return { secretStorage } as unknown as App; +} + +describe("userScriptSecrets", () => { + beforeEach(() => { + logWarningMock.mockReset(); + }); + + it("detects explicit and legacy secret option declarations", () => { + expect(isSecretUserScriptOption({ type: "secret" })).toBe(true); + expect( + isSecretUserScriptOption({ type: "text", secret: true }), + ).toBe(true); + expect( + isSecretUserScriptOption({ type: "input", secret: true }), + ).toBe(true); + expect( + isSecretUserScriptOption({ type: "textarea", secret: true }), + ).toBe(false); + expect(isSecretUserScriptOption({ type: "text" })).toBe(false); + }); + + it("detects secret option names from user-script source", () => { + const detection = detectUserScriptSecretOptions(` + const TOKEN = "Token"; + module.exports = { + settings: { + options: { + "API Key": { "type": "secret" }, + [TOKEN]: { "type": "text", "secret": true }, + Model: { type: "text" }, + }, + }, + }; + `); + + expect(detection.foundSecretOptions).toBe(true); + expect([...detection.names].sort()).toEqual(["API Key", "Token"]); + }); + + it("detects secret option names inside markdown note script fences", () => { + const detection = detectUserScriptSecretOptions( + [ + "# Script note", + "", + "```js", + "module.exports = {", + " settings: {", + " options: {", + " \"API Key\": { \"type\": \"secret\" },", + " Model: { type: \"text\" },", + " },", + " },", + "};", + "```", + ].join("\n"), + "Scripts/script.md", + ); + + expect(detection.foundSecretOptions).toBe(true); + expect([...detection.names]).toEqual(["API Key"]); + }); + + it("ignores options-looking text inside comments and string literals", () => { + const detection = detectUserScriptSecretOptions(` + const help = "options: { Model: { type: \\"secret\\" } }"; + // options: { Token: { type: "secret" } } + module.exports = { + settings: { + options: { + Model: { type: "text" }, + }, + }, + }; + `); + + expect(detection.foundSecretOptions).toBe(false); + expect(detection.names.size).toBe(0); + }); + + it("tracks dynamic secret options even when the setting name is not static", () => { + const detection = detectUserScriptSecretOptions(` + module.exports = { + settings: { + options: { + [process.env.SECRET_NAME]: { type: "secret" }, + }, + }, + }; + `); + + expect(detection.foundSecretOptions).toBe(true); + expect(detection.names.size).toBe(0); + }); + + it("treats computed expressions as unknown secret option names", () => { + const detection = detectUserScriptSecretOptions(` + const PREFIX = "API"; + module.exports = { + settings: { + options: { + [PREFIX + " Key"]: { type: "secret" }, + }, + }, + }; + `); + + expect(detection.foundSecretOptions).toBe(true); + expect(detection.names.size).toBe(0); + }); + + it("does not initialize secret default values into command settings", () => { + const settings: Record = {}; + + initializeUserScriptSettings(settings, { + options: { + "API Key": { + type: "secret", + defaultValue: "must-not-persist", + }, + Model: { + type: "text", + defaultValue: "gpt-4", + }, + }, + }); + + expect(settings).toEqual({ Model: "gpt-4" }); + }); + + it("builds valid deterministic SecretStorage IDs", () => { + expect(buildUserScriptSecretId(createCommand(), "API Key")).toBe( + "quickadd-user-script-command-1-api-key", + ); + }); + + it("stores a secret and creates a marker without exposing the value", async () => { + const setSecret = vi.fn().mockResolvedValue(undefined); + const getSecret = vi.fn().mockResolvedValue(null); + const app = createApp({ getSecret, setSecret }); + const command = createCommand(); + + const secretRef = await storeUserScriptSecret( + app, + command, + "API Key", + "super-secret", + ); + + expect(secretRef).toBe("quickadd-user-script-command-1-api-key"); + expect(setSecret).toHaveBeenCalledWith( + "quickadd-user-script-command-1-api-key", + "super-secret", + ); + const marker = createUserScriptSecretRef(secretRef!); + expect(isUserScriptSecretRef(marker)).toBe(true); + expect(JSON.stringify(marker)).not.toContain("super-secret"); + }); + + it("resolves markers into an ephemeral settings object", async () => { + const command = createCommand({ + "API Key": createUserScriptSecretRef( + "quickadd-user-script-command-1-api-key", + ), + Model: "gpt-4", + }); + const app = createApp({ + getSecret: vi.fn().mockResolvedValue("secret-value"), + }); + + const resolved = await resolveUserScriptSettings(app, command, { + options: { + "API Key": { type: "secret" }, + Model: { type: "text" }, + }, + }); + + expect(resolved).toEqual({ + "API Key": "secret-value", + Model: "gpt-4", + }); + expect(command.settings["API Key"]).toEqual( + createUserScriptSecretRef("quickadd-user-script-command-1-api-key"), + ); + }); + + it("preserves legacy plaintext secret settings when migration has not run", async () => { + const command = createCommand({ + "API Key": "legacy-secret", + }); + + const resolved = await resolveUserScriptSettings(undefined, command, { + options: { + "API Key": { type: "text", secret: true }, + }, + }); + + expect(resolved["API Key"]).toBe("legacy-secret"); + expect(command.settings["API Key"]).toBe("legacy-secret"); + }); + + it("preserves absence for unset secret settings at runtime", async () => { + const command = createCommand(); + + const resolved = await resolveUserScriptSettings(undefined, command, { + options: { + "API Key": { type: "secret" }, + }, + }); + + expect("API Key" in resolved).toBe(false); + }); + + it("throws a clear error when a marker cannot resolve on this device", async () => { + const command = createCommand({ + "API Key": createUserScriptSecretRef( + "quickadd-user-script-command-1-api-key", + ), + }); + + await expect( + resolveUserScriptSettings(undefined, command, { + options: { "API Key": { type: "secret" } }, + }), + ).rejects.toThrow(/Re-enter it on this device/); + }); + + it("clears through SecretStorage delete when available", async () => { + const deleteSecret = vi.fn().mockResolvedValue(undefined); + const app = createApp({ delete: deleteSecret }); + + await expect(clearUserScriptSecret(app, "secret-ref")).resolves.toBe(true); + expect(deleteSecret).toHaveBeenCalledWith("secret-ref"); + }); + + it("falls back to overwriting with an empty value when delete is unavailable", async () => { + const setSecret = vi.fn().mockResolvedValue(undefined); + const app = createApp({ setSecret }); + + await expect(clearUserScriptSecret(app, "secret-ref")).resolves.toBe(true); + expect(setSecret).toHaveBeenCalledWith("secret-ref", ""); + }); + + it("treats missing SecretStorage as a clear no-op", async () => { + await expect( + clearUserScriptSecret(undefined, "secret-ref"), + ).resolves.toBe(true); + }); + + it("migrates legacy plaintext secret settings into SecretStorage", async () => { + const command = createCommand({ + "API Key": "legacy-secret", + Model: "gpt-4", + }); + const setSecret = vi.fn().mockResolvedValue(undefined); + const app = createApp({ + getSecret: vi.fn().mockResolvedValue(null), + setSecret, + }); + + const changed = await migrateUserScriptSecretSettings(app, command, { + options: { + "API Key": { type: "secret" }, + Model: { type: "text" }, + }, + }); + + expect(changed).toBe(true); + expect(setSecret).toHaveBeenCalledWith( + "quickadd-user-script-command-1-api-key", + "legacy-secret", + ); + expect(command.settings["API Key"]).toEqual( + createUserScriptSecretRef("quickadd-user-script-command-1-api-key"), + ); + expect(JSON.stringify(command.settings)).not.toContain("legacy-secret"); + }); + + it("does not migrate or clear legacy plaintext when SecretStorage is unavailable", async () => { + const command = createCommand({ + "API Key": "legacy-secret", + }); + + const changed = await migrateUserScriptSecretSettings(undefined, command, { + options: { + "API Key": { type: "secret" }, + }, + }); + + expect(changed).toBe(false); + expect(command.settings["API Key"]).toBe("legacy-secret"); + expect(logWarningMock).toHaveBeenCalledWith( + expect.stringContaining("SecretStorage unavailable"), + ); + }); + + it("leaves plaintext untouched when SecretStorage write fails", async () => { + const command = createCommand({ + "API Key": "legacy-secret", + }); + const app = createApp({ + getSecret: vi.fn().mockResolvedValue(null), + setSecret: vi.fn().mockRejectedValue(new Error("write failed")), + }); + + const changed = await migrateUserScriptSecretSettings(app, command, { + options: { + "API Key": { type: "secret" }, + }, + }); + + expect(changed).toBe(false); + expect(command.settings["API Key"]).toBe("legacy-secret"); + expect(logWarningMock).toHaveBeenCalledWith( + expect.stringContaining("Failed to write user script SecretStorage entry"), + ); + }); + + it("is a no-op for already migrated secret settings", async () => { + const command = createCommand({ + "API Key": createUserScriptSecretRef( + "quickadd-user-script-command-1-api-key", + ), + }); + const setSecret = vi.fn(); + const app = createApp({ + getSecret: vi.fn().mockResolvedValue("existing-secret"), + setSecret, + }); + + const changed = await migrateUserScriptSecretSettings(app, command, { + options: { + "API Key": { type: "secret" }, + }, + }); + + expect(changed).toBe(false); + expect(setSecret).not.toHaveBeenCalled(); + }); + + it("clears secret refs recursively from deleted command trees", async () => { + const deleteSecret = vi.fn().mockResolvedValue(undefined); + const app = createApp({ delete: deleteSecret }); + const commandTree = { + type: "Conditional", + thenCommands: [ + createCommand({ + "API Key": createUserScriptSecretRef("then-secret"), + }), + ], + elseCommands: [ + { + type: "NestedChoice", + choice: { + type: "Macro", + macro: { + commands: [ + createCommand({ + "API Key": createUserScriptSecretRef("nested-secret"), + }), + ], + }, + }, + }, + ], + }; + + await expect( + clearUserScriptSecretsFromCommand(app, commandTree), + ).resolves.toBe(true); + + expect(deleteSecret).toHaveBeenCalledWith("then-secret"); + expect(deleteSecret).toHaveBeenCalledWith("nested-secret"); + }); + + it("reports recursive clear failure when a SecretStorage delete fails", async () => { + const app = createApp({ + delete: vi.fn().mockRejectedValue(new Error("delete failed")), + }); + const command = createCommand({ + "API Key": createUserScriptSecretRef("secret-ref"), + }); + + await expect( + clearUserScriptSecretsFromCommand(app, command), + ).resolves.toBe(false); + expect(logWarningMock).toHaveBeenCalledWith( + expect.stringContaining("Failed to clear user script SecretStorage entry"), + ); + }); + + it("strips markers and detected legacy plaintext secret settings", () => { + const command = createCommand({ + "API Key": "legacy-secret", + Token: createUserScriptSecretRef("local-secret-ref"), + Model: "gpt-4", + }); + + stripUserScriptSecretRefsFromCommand(command, { + secretOptionNamesByPath: new Map([ + ["scripts/script.js", new Set(["API Key", "Token"])], + ]), + stripUnknownStringSettings: true, + }); + + expect(command.settings).toEqual({ Model: "gpt-4" }); + }); + + it("conservatively strips string settings when secret names are unknown", () => { + const command = createCommand({ + "API Key": "legacy-secret", + Enabled: true, + Model: "gpt-4", + }); + + stripUserScriptSecretRefsFromCommand(command, { + secretOptionNamesByPath: new Map([["scripts/script.js", null]]), + stripUnknownStringSettings: true, + }); + + expect(command.settings).toEqual({ Enabled: true }); + }); +}); diff --git a/src/utils/userScriptSecrets.ts b/src/utils/userScriptSecrets.ts new file mode 100644 index 00000000..e3f128c9 --- /dev/null +++ b/src/utils/userScriptSecrets.ts @@ -0,0 +1,845 @@ +import type { App } from "obsidian"; +import { log } from "../logger/logManager"; +import type { IUserScript } from "../types/macros/IUserScript"; +import { extractScriptFromMarkdown } from "./extractScriptFromMarkdown"; + +const USER_SCRIPT_SECRET_PREFIX = "quickadd-user-script"; +const SECRET_MARKER = "__quickaddSecret"; +const MARKDOWN_FILE_EXTENSION_REGEX = /\.md$/i; + +type SecretStorageLike = { + getSecret?: (id: string) => string | null | Promise; + setSecret?: (id: string, value: string) => void | Promise; + listSecrets?: () => string[] | Promise; + deleteSecret?: (id: string) => void | Promise; + removeSecret?: (id: string) => void | Promise; + delete?: (id: string) => void | Promise; +}; + +export type UserScriptOptionDefinition = { + type?: unknown; + secret?: unknown; + defaultValue?: unknown; +}; + +export type UserScriptSettingsDefinition = { + options?: Record; +}; + +export type UserScriptSecretRef = { + [SECRET_MARKER]: true; + secretRef: string; +}; + +export type UserScriptSecretSanitizerOptions = { + /** + * Map a user-script path to the secret option names found in that script. + * `null` means the script declares at least one secret option but the setting + * name could not be statically recovered. + */ + secretOptionNamesByPath?: ReadonlyMap | null>; + stripUnknownStringSettings?: boolean; +}; + +export type UserScriptSecretOptionDetection = { + names: Set; + foundSecretOptions: boolean; +}; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function getSecretStorage(app: App | undefined): SecretStorageLike | undefined { + return app?.secretStorage as SecretStorageLike | undefined; +} + +function normalizeSecretIdPart(value: string): string { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return normalized || "setting"; +} + +function formatSecretError(error: unknown): string { + return (error as Error)?.message ?? String(error); +} + +function skipWhitespaceAndComments(source: string, index: number): number { + let current = index; + while (current < source.length) { + const char = source[current]; + const next = source[current + 1]; + + if (/\s/.test(char)) { + current += 1; + continue; + } + + if (char === "/" && next === "/") { + const newline = source.indexOf("\n", current + 2); + current = newline === -1 ? source.length : newline + 1; + continue; + } + + if (char === "/" && next === "*") { + const end = source.indexOf("*/", current + 2); + current = end === -1 ? source.length : end + 2; + continue; + } + + return current; + } + + return current; +} + +function readStringLiteral( + source: string, + index: number, +): { value: string; end: number } | null { + const quote = source[index]; + if (quote !== "\"" && quote !== "'" && quote !== "`") return null; + + let value = ""; + for (let current = index + 1; current < source.length; current++) { + const char = source[current]; + + if (char === "\\") { + const escaped = source[current + 1]; + if (escaped === undefined) return null; + value += escaped; + current += 1; + continue; + } + + if (char === quote) { + return { value, end: current + 1 }; + } + + value += char; + } + + return null; +} + +function readIdentifier( + source: string, + index: number, +): { value: string; end: number } | null { + const match = /^[A-Za-z_$][\w$]*/.exec(source.slice(index)); + if (!match) return null; + + return { + value: match[0], + end: index + match[0].length, + }; +} + +function findMatchingDelimiter( + source: string, + openIndex: number, + open: "{" | "[", + close: "}" | "]", +): number { + let depth = 0; + + for (let current = openIndex; current < source.length; current++) { + const char = source[current]; + const next = source[current + 1]; + + if (char === "\"" || char === "'" || char === "`") { + const literal = readStringLiteral(source, current); + if (!literal) return -1; + current = literal.end - 1; + continue; + } + + if (char === "/" && next === "/") { + const newline = source.indexOf("\n", current + 2); + current = newline === -1 ? source.length : newline; + continue; + } + + if (char === "/" && next === "*") { + const end = source.indexOf("*/", current + 2); + if (end === -1) return -1; + current = end + 1; + continue; + } + + if (char === open) { + depth += 1; + continue; + } + + if (char === close) { + depth -= 1; + if (depth === 0) return current; + } + } + + return -1; +} + +function collectStringConstants(source: string): Map { + const constants = new Map(); + const regex = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(["'`])/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(source)) !== null) { + const literal = readStringLiteral(source, regex.lastIndex - 1); + if (!literal) continue; + constants.set(match[1], literal.value); + regex.lastIndex = literal.end; + } + + return constants; +} + +function findOptionsObjectSpans(source: string): Array<{ start: number; end: number }> { + const spans: Array<{ start: number; end: number }> = []; + const constants = collectStringConstants(source); + let current = 0; + + while (current < source.length) { + current = skipWhitespaceAndComments(source, current); + const char = source[current]; + const next = source[current + 1]; + + if (char === "\"" || char === "'" || char === "`") { + const literal = readStringLiteral(source, current); + current = literal ? literal.end : current + 1; + continue; + } + + if (char === "/" && next === "/") { + const newline = source.indexOf("\n", current + 2); + current = newline === -1 ? source.length : newline + 1; + continue; + } + + if (char === "/" && next === "*") { + const end = source.indexOf("*/", current + 2); + current = end === -1 ? source.length : end + 2; + continue; + } + + const key = readOptionKey(source, current, constants); + if (!key) { + current += 1; + continue; + } + + current = skipWhitespaceAndComments(source, key.end); + if (source[current] !== ":") { + current = key.end; + continue; + } + + current = skipWhitespaceAndComments(source, current + 1); + if (key.name !== "options") continue; + if (source[current] !== "{") continue; + + const end = findMatchingDelimiter(source, current, "{", "}"); + if (end === -1) break; + spans.push({ start: current + 1, end }); + current = end + 1; + } + + return spans; +} + +function readOptionKey( + source: string, + index: number, + constants: ReadonlyMap, +): { name: string | null; end: number } | null { + if (source[index] === "\"" || source[index] === "'" || source[index] === "`") { + const literal = readStringLiteral(source, index); + if (!literal) return null; + return { name: literal.value, end: literal.end }; + } + + if (source[index] === "[") { + const current = skipWhitespaceAndComments(source, index + 1); + const identifier = readIdentifier(source, current); + const end = findMatchingDelimiter(source, index, "[", "]"); + if (end === -1) return null; + const afterIdentifier = identifier + ? skipWhitespaceAndComments(source, identifier.end) + : current; + + return { + name: + identifier && afterIdentifier === end + ? (constants.get(identifier.value) ?? null) + : null, + end: end + 1, + }; + } + + const identifier = readIdentifier(source, index); + if (!identifier) return null; + + return { + name: identifier.value, + end: identifier.end, + }; +} + +function readTopLevelObjectProperties(source: string): Map { + const properties = new Map(); + let current = 0; + + while (current < source.length) { + current = skipWhitespaceAndComments(source, current); + if (source[current] === ",") { + current += 1; + continue; + } + if (current >= source.length) break; + + const key = readOptionKey(source, current, new Map()); + if (!key?.name) { + current += 1; + continue; + } + + current = skipWhitespaceAndComments(source, key.end); + if (source[current] !== ":") { + current += 1; + continue; + } + + current = skipWhitespaceAndComments(source, current + 1); + if ( + source[current] === "\"" || + source[current] === "'" || + source[current] === "`" + ) { + const literal = readStringLiteral(source, current); + if (!literal) break; + properties.set(key.name, literal.value); + current = literal.end; + continue; + } + + if (source.slice(current, current + 4) === "true") { + properties.set(key.name, true); + current += 4; + continue; + } + + if (source.slice(current, current + 5) === "false") { + properties.set(key.name, false); + current += 5; + continue; + } + + if (source[current] === "{") { + const end = findMatchingDelimiter(source, current, "{", "}"); + if (end === -1) break; + current = end + 1; + continue; + } + + if (source[current] === "[") { + const end = findMatchingDelimiter(source, current, "[", "]"); + if (end === -1) break; + current = end + 1; + continue; + } + + while (current < source.length && source[current] !== ",") { + current += 1; + } + } + + return properties; +} + +function optionBodyDeclaresSecret(body: string): boolean { + const properties = readTopLevelObjectProperties(body); + const type = properties.get("type"); + const secret = properties.get("secret"); + + return type === "secret" || ((type === "text" || type === "input") && secret === true); +} + +export function detectUserScriptSecretOptions( + source: string, + path?: string, +): UserScriptSecretOptionDetection { + const sourceToInspect = + path && MARKDOWN_FILE_EXTENSION_REGEX.test(path) + ? (extractScriptFromMarkdown(source).code ?? "") + : source; + const names = new Set(); + let foundSecretOptions = false; + const constants = collectStringConstants(sourceToInspect); + + for (const { start, end } of findOptionsObjectSpans(sourceToInspect)) { + let current = start; + + while (current < end) { + current = skipWhitespaceAndComments(sourceToInspect, current); + if (sourceToInspect[current] === ",") { + current += 1; + continue; + } + if (current >= end) break; + + const key = readOptionKey(sourceToInspect, current, constants); + if (!key) { + current += 1; + continue; + } + + current = skipWhitespaceAndComments(sourceToInspect, key.end); + if (sourceToInspect[current] !== ":") { + current += 1; + continue; + } + + current = skipWhitespaceAndComments(sourceToInspect, current + 1); + if (sourceToInspect[current] !== "{") { + current += 1; + continue; + } + + const valueEnd = findMatchingDelimiter( + sourceToInspect, + current, + "{", + "}", + ); + if (valueEnd === -1 || valueEnd > end) break; + + const body = sourceToInspect.slice(current + 1, valueEnd); + if (optionBodyDeclaresSecret(body)) { + foundSecretOptions = true; + if (key.name) names.add(key.name); + } + + current = valueEnd + 1; + } + } + + return { names, foundSecretOptions }; +} + +export function isSecretUserScriptOption(option: unknown): boolean { + if (!isRecord(option)) return false; + + if (option.type === "secret") return true; + if ( + (option.type === "text" || option.type === "input") && + option.secret === true + ) { + return true; + } + + return false; +} + +export function isUserScriptSecretRef( + value: unknown, +): value is UserScriptSecretRef { + return ( + isRecord(value) && + value[SECRET_MARKER] === true && + typeof value.secretRef === "string" && + value.secretRef.trim().length > 0 + ); +} + +export function createUserScriptSecretRef(secretRef: string): UserScriptSecretRef { + return { + [SECRET_MARKER]: true, + secretRef, + }; +} + +export function buildUserScriptSecretId( + command: IUserScript, + settingName: string, +): string { + const commandId = command.id?.trim() || command.path || command.name || "script"; + return [ + USER_SCRIPT_SECRET_PREFIX, + normalizeSecretIdPart(commandId), + normalizeSecretIdPart(settingName), + ].join("-"); +} + +export function getSecretOptionNames( + userScriptSettings: UserScriptSettingsDefinition | undefined, +): string[] { + const options = userScriptSettings?.options; + if (!options) return []; + + return Object.entries(options) + .filter(([, option]) => isSecretUserScriptOption(option)) + .map(([name]) => name); +} + +async function readSecretStorageEntry( + app: App | undefined, + secretRef: string, +): Promise { + const secretStorage = getSecretStorage(app); + if (!secretStorage?.getSecret) return null; + + try { + return await Promise.resolve(secretStorage.getSecret(secretRef)); + } catch (error) { + log.logWarning( + `Failed to read user script SecretStorage entry "${secretRef}": ${formatSecretError(error)}`, + ); + return null; + } +} + +async function writeSecretStorageEntry( + app: App | undefined, + secretRef: string, + value: string, +): Promise { + const secretStorage = getSecretStorage(app); + if (!secretStorage?.setSecret) return false; + + try { + await Promise.resolve(secretStorage.setSecret(secretRef, value)); + return true; + } catch (error) { + log.logWarning( + `Failed to write user script SecretStorage entry "${secretRef}": ${formatSecretError(error)}`, + ); + return false; + } +} + +async function buildAvailableSecretRef( + app: App | undefined, + command: IUserScript, + settingName: string, + value: string, +): Promise { + const base = buildUserScriptSecretId(command, settingName); + let candidate = base; + let suffix = 1; + + while (true) { + const existing = await readSecretStorageEntry(app, candidate); + if (!existing || existing === value) return candidate; + + suffix += 1; + candidate = `${base}-${suffix}`; + } +} + +export async function storeUserScriptSecret( + app: App | undefined, + command: IUserScript, + settingName: string, + value: string, + existingRef?: string, +): Promise { + if (value.length === 0) return null; + + const secretRef = + existingRef?.trim() || + (await buildAvailableSecretRef(app, command, settingName, value)); + const stored = await writeSecretStorageEntry(app, secretRef, value); + + return stored ? secretRef : null; +} + +export async function clearUserScriptSecret( + app: App | undefined, + secretRef: string | undefined, +): Promise { + const trimmedRef = secretRef?.trim(); + if (!trimmedRef) return true; + + const secretStorage = getSecretStorage(app); + if (!secretStorage) return true; + + const deleteMethod = + secretStorage.deleteSecret ?? + secretStorage.removeSecret ?? + secretStorage.delete; + + try { + if (deleteMethod) { + await Promise.resolve(deleteMethod.call(secretStorage, trimmedRef)); + return true; + } + + if (secretStorage.setSecret) { + await Promise.resolve(secretStorage.setSecret(trimmedRef, "")); + return true; + } + } catch (error) { + log.logWarning( + `Failed to clear user script SecretStorage entry "${trimmedRef}": ${formatSecretError(error)}`, + ); + } + + return false; +} + +export async function resolveUserScriptSettings( + app: App | undefined, + command: IUserScript, + userScriptSettings: UserScriptSettingsDefinition | undefined, +): Promise> { + const commandSettings = command.settings ?? {}; + const resolvedSettings = { ...commandSettings }; + const secretOptionNames = new Set(getSecretOptionNames(userScriptSettings)); + + for (const [name, value] of Object.entries(commandSettings)) { + if (isUserScriptSecretRef(value)) { + const secret = await readSecretStorageEntry(app, value.secretRef); + if (secret) { + resolvedSettings[name] = secret; + continue; + } + + throw new Error( + `Secret setting "${name}" for user script "${command.name}" is unavailable. Re-enter it on this device.`, + ); + } + + if (secretOptionNames.has(name)) { + resolvedSettings[name] = typeof value === "string" ? value : ""; + } + } + + return resolvedSettings; +} + +export async function migrateUserScriptSecretSettings( + app: App | undefined, + command: IUserScript, + userScriptSettings: UserScriptSettingsDefinition | undefined, +): Promise { + const secretOptionNames = getSecretOptionNames(userScriptSettings); + if (secretOptionNames.length === 0) return false; + + const secretStorage = getSecretStorage(app); + if (!secretStorage?.getSecret || !secretStorage?.setSecret) { + const hasLegacySecrets = secretOptionNames.some((name) => { + const value = command.settings?.[name]; + return typeof value === "string" && value.length > 0; + }); + + if (hasLegacySecrets) { + log.logWarning( + `SecretStorage unavailable; leaving plaintext user script secret settings for "${command.name}" unchanged.`, + ); + } + + return false; + } + + let migrated = false; + + for (const settingName of secretOptionNames) { + const value = command.settings?.[settingName]; + if (isUserScriptSecretRef(value)) continue; + if (typeof value !== "string" || value.length === 0) continue; + + const secretRef = await storeUserScriptSecret( + app, + command, + settingName, + value, + ); + if (!secretRef) continue; + + command.settings[settingName] = createUserScriptSecretRef(secretRef); + migrated = true; + } + + return migrated; +} + +export function getSecretRefFromCommandSetting( + command: IUserScript, + settingName: string, +): string | undefined { + const value = command.settings?.[settingName]; + return isUserScriptSecretRef(value) ? value.secretRef : undefined; +} + +async function clearRefsFromSettings( + app: App | undefined, + settings: unknown, +): Promise { + if (!isRecord(settings)) return true; + + const secretRefs = Object.values(settings) + .filter(isUserScriptSecretRef) + .map((value) => value.secretRef); + + const results = await Promise.all( + secretRefs.map((secretRef) => clearUserScriptSecret(app, secretRef)), + ); + return results.every(Boolean); +} + +async function clearSecretsFromChoice( + app: App | undefined, + choice: unknown, +): Promise { + if (!isRecord(choice)) return true; + + let cleared = true; + + if (choice.type === "Macro" && isRecord(choice.macro)) { + cleared = + (await clearUserScriptSecretsFromCommands(app, choice.macro.commands)) && + cleared; + } + + if (choice.type === "Multi" && Array.isArray(choice.choices)) { + for (const child of choice.choices) { + cleared = (await clearSecretsFromChoice(app, child)) && cleared; + } + } + + return cleared; +} + +export async function clearUserScriptSecretsFromCommand( + app: App | undefined, + command: unknown, +): Promise { + if (!isRecord(command)) return true; + + let cleared = true; + + if (command.type === "UserScript") { + cleared = (await clearRefsFromSettings(app, command.settings)) && cleared; + } + + if (Array.isArray(command.thenCommands)) { + cleared = + (await clearUserScriptSecretsFromCommands(app, command.thenCommands)) && + cleared; + } + if (Array.isArray(command.elseCommands)) { + cleared = + (await clearUserScriptSecretsFromCommands(app, command.elseCommands)) && + cleared; + } + + cleared = (await clearSecretsFromChoice(app, command.choice)) && cleared; + return cleared; +} + +export async function clearUserScriptSecretsFromCommands( + app: App | undefined, + commands: unknown, +): Promise { + if (!Array.isArray(commands)) return true; + + let cleared = true; + + for (const command of commands) { + cleared = (await clearUserScriptSecretsFromCommand(app, command)) && cleared; + } + + return cleared; +} + +function getSecretOptionNamesForCommand( + command: Record, + options?: UserScriptSecretSanitizerOptions, +): ReadonlySet | null | undefined { + const path = command.path; + if (typeof path !== "string") return undefined; + + return options?.secretOptionNamesByPath?.get(path); +} + +function stripSecretRefsFromSettings( + settings: unknown, + secretOptionNames: ReadonlySet | null | undefined, + options?: UserScriptSecretSanitizerOptions, +): void { + if (!isRecord(settings)) return; + + for (const [name, value] of Object.entries(settings)) { + if (isUserScriptSecretRef(value)) { + delete settings[name]; + continue; + } + + if ( + typeof value === "string" && + (secretOptionNames === null || + secretOptionNames?.has(name) || + (secretOptionNames === undefined && + options?.stripUnknownStringSettings === true)) + ) { + delete settings[name]; + } + } +} + +export function stripUserScriptSecretRefsFromCommand( + command: unknown, + options?: UserScriptSecretSanitizerOptions, +): void { + if (!isRecord(command)) return; + + if (command.type === "UserScript") { + stripSecretRefsFromSettings( + command.settings, + getSecretOptionNamesForCommand(command, options), + options, + ); + } + + if (Array.isArray(command.thenCommands)) { + stripUserScriptSecretRefsFromCommands(command.thenCommands, options); + } + if (Array.isArray(command.elseCommands)) { + stripUserScriptSecretRefsFromCommands(command.elseCommands, options); + } + + stripUserScriptSecretRefsFromChoice(command.choice, options); +} + +export function stripUserScriptSecretRefsFromCommands( + commands: unknown, + options?: UserScriptSecretSanitizerOptions, +): void { + if (!Array.isArray(commands)) return; + + for (const command of commands) { + stripUserScriptSecretRefsFromCommand(command, options); + } +} + +export function stripUserScriptSecretRefsFromChoice( + choice: unknown, + options?: UserScriptSecretSanitizerOptions, +): void { + if (!isRecord(choice)) return; + + if (choice.type === "Macro" && isRecord(choice.macro)) { + stripUserScriptSecretRefsFromCommands(choice.macro.commands, options); + } + + if (choice.type === "Multi" && Array.isArray(choice.choices)) { + for (const child of choice.choices) { + stripUserScriptSecretRefsFromChoice(child, options); + } + } +} diff --git a/src/utils/userScriptSettings.ts b/src/utils/userScriptSettings.ts index 1c6c3966..d4b08866 100644 --- a/src/utils/userScriptSettings.ts +++ b/src/utils/userScriptSettings.ts @@ -1,3 +1,8 @@ +import { + isSecretUserScriptOption, + type UserScriptOptionDefinition, +} from "./userScriptSecrets"; + /** * Initializes default values for user script settings. * @@ -13,9 +18,7 @@ export function initializeUserScriptSettings( userScriptSettings: { [key: string]: unknown; options?: { - [key: string]: { - defaultValue?: unknown; - }; + [key: string]: UserScriptOptionDefinition; }; } ): void { @@ -29,7 +32,11 @@ export function initializeUserScriptSettings( "defaultValue" in userScriptSettings.options[setting] && userScriptSettings.options[setting].defaultValue !== undefined; - if (valueIsNotSetAlready && defaultValueAvailable) { + if ( + valueIsNotSetAlready && + defaultValueAvailable && + !isSecretUserScriptOption(userScriptSettings.options[setting]) + ) { commandSettings[setting] = userScriptSettings.options[setting].defaultValue; } diff --git a/tests/obsidian-stub.ts b/tests/obsidian-stub.ts index 61ac1942..864773d3 100644 --- a/tests/obsidian-stub.ts +++ b/tests/obsidian-stub.ts @@ -516,9 +516,32 @@ export class FuzzySuggestModal { }; export const Modal = class { - constructor(app: any) {} - open() {} - close() {} + app: any; + containerEl: HTMLElement; + modalEl: HTMLElement; + contentEl: HTMLElement; + titleEl: HTMLElement; + + constructor(app: any) { + this.app = app; + this.containerEl = document.createElement("div"); + this.modalEl = document.createElement("div"); + this.titleEl = document.createElement("div"); + this.contentEl = document.createElement("div"); + this.modalEl.appendChild(this.titleEl); + this.modalEl.appendChild(this.contentEl); + this.containerEl.appendChild(this.modalEl); + document.body.appendChild(this.containerEl); + } + + open() { + (this as any).onOpen?.(); + } + + close() { + (this as any).onClose?.(); + this.containerEl.remove(); + } }; export const Scope = class { diff --git a/tests/vitest-setup.ts b/tests/vitest-setup.ts index 2dbbde0a..328e551a 100644 --- a/tests/vitest-setup.ts +++ b/tests/vitest-setup.ts @@ -24,7 +24,56 @@ for (const proto of [HTMLElement.prototype, SVGElement.prototype]) { const p = proto as unknown as { setCssStyles?: unknown; setCssProps?: unknown; + addClass?: unknown; + removeClass?: unknown; + empty?: unknown; + createDiv?: unknown; + createEl?: unknown; }; if (typeof p.setCssStyles !== "function") p.setCssStyles = setCssStyles; if (typeof p.setCssProps !== "function") p.setCssProps = setCssProps; + if (typeof p.addClass !== "function") { + p.addClass = function addClass(this: Element, ...classes: string[]) { + this.classList.add(...classes); + }; + } + if (typeof p.removeClass !== "function") { + p.removeClass = function removeClass(this: Element, ...classes: string[]) { + this.classList.remove(...classes); + }; + } + if (typeof p.empty !== "function") { + p.empty = function empty(this: Element) { + this.textContent = ""; + }; + } + if (typeof p.createDiv !== "function") { + p.createDiv = function createDiv( + this: Element, + cls?: string | { cls?: string; text?: string }, + ) { + const div = document.createElement("div"); + if (typeof cls === "string") div.className = cls; + if (typeof cls === "object") { + if (cls.cls) div.className = cls.cls; + if (cls.text) div.textContent = cls.text; + } + this.appendChild(div); + return div; + }; + } + if (typeof p.createEl !== "function") { + p.createEl = function createEl( + this: Element, + tag: string, + options?: { cls?: string; text?: string; href?: string }, + ) { + const el = document.createElement(tag); + if (options?.cls) el.className = options.cls; + if (options?.text) el.textContent = options.text; + if (options?.href) el.setAttribute("href", options.href); + this.appendChild(el); + return el; + }; + } }