Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/main.persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,55 @@ describe("PodNotes persistence integration", () => {
expect(persisted).not.toHaveProperty("diarizationApiKey");
});

it("names exactly the legacy keys it moved into SecretStorage", async () => {
const notice = vi.spyOn(obsidian, "Notice");
const migrate = async (legacy: Record<string, string>) => {
notice.mockClear();
const { storage } = memorySecretStorage();
const plugin = makePlugin();
Object.assign(plugin, {
app: { ...plugin.app, secretStorage: storage },
credentials: new CredentialRepository(storage),
loadData: vi.fn().mockResolvedValue({ schemaVersion: 1, ...legacy }),
});
await plugin.loadSettings();
return notice.mock.calls;
};

expect(await migrate({ openAIApiKey: "sk", diarizationApiKey: "dg" })).toEqual([
["Moved your OpenAI and Deepgram API keys into Obsidian SecretStorage."],
]);
expect(await migrate({ diarizationApiKey: "dg" })).toEqual([
["Moved your Deepgram API key into Obsidian SecretStorage."],
]);
});

it("says its API keys could not move when only a Deepgram key fails to migrate", async () => {
const notice = vi.spyOn(obsidian, "Notice");
const storage = {
getSecret: vi.fn(() => null),
setSecret: vi.fn(() => {
throw new Error("keychain unavailable");
}),
listSecrets: vi.fn(() => []),
} as unknown as SecretStorage;
const plugin = makePlugin();
Object.assign(plugin, {
app: { ...plugin.app, secretStorage: storage },
credentials: new CredentialRepository(storage),
loadData: vi.fn().mockResolvedValue({ schemaVersion: 1, diarizationApiKey: "dg" }),
});
vi.spyOn(console, "error").mockImplementation(() => undefined);

await expect(plugin.loadSettings()).rejects.toThrow("keychain unavailable");
expect(notice.mock.calls).toEqual([
[
"PodNotes could not securely migrate its API keys. Your existing data was kept unchanged. Restart PodNotes to retry.",
0,
],
]);
});

it("leaves legacy data untouched when SecretStorage migration fails", async () => {
const saveData = vi.fn().mockResolvedValue(undefined);
const raw = { schemaVersion: 1, openAIApiKey: "sk", defaultVolume: 0.3 };
Expand Down
13 changes: 5 additions & 8 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
WriteRefusedError,
} from "./persistence/writer";
import { CredentialRepository } from "./services/CredentialRepository";
import { CREDENTIAL_KINDS, CREDENTIALS } from "./types/Credentials";
import { FeedUrlRepository } from "./services/FeedUrlRepository";
import { privateFeedAddresses, protectPrivateFeeds, resolveFeedUrl } from "./services/privateFeeds";
import { redactPrivateFeedAddresses } from "./utility/privateFeedUrl";
Expand Down Expand Up @@ -370,6 +371,9 @@ export default class PodNotes extends Plugin implements IPodNotes {
decoded.retiredPlaintextPresent ||
movedFeeds
) {
const migratedProviders = CREDENTIAL_KINDS.filter(
(kind) => decoded.legacySecrets[CREDENTIALS[kind].exportKey],
).map((kind) => CREDENTIALS[kind].label);
// SecretStorage writes are synchronous. Store every legacy value and
// verify it first, then write the v2 snapshot directly because the normal
// save queue intentionally stays dormant until onload finishes. A v2 file
Expand All @@ -388,15 +392,8 @@ export default class PodNotes extends Plugin implements IPodNotes {
);
if (written.kind !== "verified") throw this.writeFailure();
} catch (error) {
throw new PodNotesSchemaMigrationError(
error,
Boolean(decoded.legacySecrets.openAI || decoded.legacySecrets.deepgram),
);
throw new PodNotesSchemaMigrationError(error, migratedProviders.length > 0);
}
const migratedProviders = [
decoded.legacySecrets.openAI ? "OpenAI" : null,
decoded.legacySecrets.deepgram ? "Deepgram" : null,
].filter((provider): provider is string => provider !== null);
if (migratedProviders.length > 0) {
new Notice(
`Moved your ${migratedProviders.join(" and ")} API ${migratedProviders.length === 1 ? "key" : "keys"} into Obsidian SecretStorage.`,
Expand Down
19 changes: 19 additions & 0 deletions src/persistence/podNotesData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,25 @@ describe("PodNotes data schema", () => {
);
});

it("keeps every numbered SecretStorage ID that released versions accept", () => {
const accepted = decodePodNotesData({
schemaVersion: 2,
openAISecretId: "podnotes-openai-api-key-1",
deepgramSecretId: "podnotes-deepgram-api-key-10",
});
const rejected = decodePodNotesData({
schemaVersion: 2,
openAISecretId: "podnotes-openai-api-key-0",
deepgramSecretId: "podnotes-deepgram-api-key-01",
});

expect(accepted.settings.openAISecretId).toBe("podnotes-openai-api-key-1");
expect(accepted.settings.deepgramSecretId).toBe("podnotes-deepgram-api-key-10");
expect(accepted.warnings).toEqual([]);
expect(rejected.settings.openAISecretId).toBe("");
expect(rejected.settings.deepgramSecretId).toBe("");
});

it("salvages valid fields and only drops individually invalid collection entries", () => {
const validEpisode = {
...episode("Valid"),
Expand Down
37 changes: 23 additions & 14 deletions src/persistence/settingsCodec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import {
} from "src/settingsMigrations";
import { NEXT_TRACK_ACTIONS, PREVIOUS_TRACK_ACTIONS } from "src/types/Headset";
import type { IPodNotesSettings } from "src/types/IPodNotesSettings";
import { isPodNotesSecretId, type CredentialKind } from "src/types/Credentials";
import {
CREDENTIAL_KINDS,
CREDENTIALS,
isPodNotesSecretId,
type CredentialReferenceSettings,
} from "src/types/Credentials";
import { sanitizeEpisodeListLimit } from "src/utility/episodeListLimit";
import { normalizePlaybackRate } from "src/utility/playbackRate";
import {
Expand Down Expand Up @@ -119,23 +124,27 @@ export function decodeSettings(
feedNote: decodeFeedNote(root.feedNote, warnings),
download: decodeDownload(root.download, warnings),
downloadedEpisodes: decodeDownloadedEpisodes(root.downloadedEpisodes, warnings),
openAISecretId: decodeSecretId(root, "openAISecretId", warnings),
deepgramSecretId: decodeSecretId(root, "deepgramSecretId", warnings),
...decodeSecretIds(root, warnings),
transcript: decodeTranscript(root.transcript, warnings),
};
}

function decodeSecretId(
root: UnknownRecord,
key: "openAISecretId" | "deepgramSecretId",
warnings: Set<string>,
): string {
const value = readString(root, key, DEFAULT_SETTINGS[key], warnings);
const kind: CredentialKind = key === "openAISecretId" ? "openai" : "deepgram";
if (!value || isPodNotesSecretId(kind, value)) return value;

warn(warnings, key, "foreign or wrong-provider SecretStorage ID; reference was removed");
return "";
function decodeSecretIds(root: UnknownRecord, warnings: Set<string>): CredentialReferenceSettings {
const ids = {} as CredentialReferenceSettings;
for (const kind of CREDENTIAL_KINDS) {
const key = CREDENTIALS[kind].settingsKey;
const value = readString(root, key, DEFAULT_SETTINGS[key], warnings);
const owned = !value || isPodNotesSecretId(kind, value);
if (!owned) {
warn(
warnings,
key,
"foreign or wrong-provider SecretStorage ID; reference was removed",
);
}
ids[key] = owned ? value : "";
}
return ids;
}

/**
Expand Down
17 changes: 17 additions & 0 deletions src/services/CredentialRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,21 @@ describe("CredentialRepository", () => {
}),
).toThrow("not available in SecretStorage on this device");
});

it("names the provider whose configured key is missing from an explicit export", () => {
const { api } = storage({ "podnotes-openai-api-key": "sk" });
const repository = new CredentialRepository(api);

expect(() =>
repository.exportValues(
settings({
openAISecretId: "podnotes-openai-api-key",
deepgramSecretId: "podnotes-deepgram-api-key",
}),
{ requireConfigured: true },
),
).toThrow(
"The selected Deepgram API key is not available in SecretStorage on this device.",
);
});
});
58 changes: 17 additions & 41 deletions src/services/CredentialRepository.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,18 @@
import type { SecretStorage } from "obsidian";
import type { IPodNotesSettings } from "src/types/IPodNotesSettings";
import {
CREDENTIAL_KINDS,
CREDENTIALS,
isPodNotesSecretId,
isValidSecretId,
secretBaseId,
type CredentialKind,
type CredentialReferences,
type CredentialValues,
} from "src/types/Credentials";

export type { CredentialKind, CredentialReferences, CredentialValues } from "src/types/Credentials";

export type CredentialStatus = "unconfigured" | "available" | "missing";

const CREDENTIALS: Record<
CredentialKind,
{
baseId: string;
settingsKey: keyof Pick<IPodNotesSettings, "openAISecretId" | "deepgramSecretId">;
}
> = {
openai: {
baseId: "podnotes-openai-api-key",
settingsKey: "openAISecretId",
},
deepgram: {
baseId: "podnotes-deepgram-api-key",
settingsKey: "deepgramSecretId",
},
};

/**
* Narrow boundary around Obsidian's vault-local SecretStorage.
*
Expand Down Expand Up @@ -64,25 +48,18 @@ export class CredentialRepository {
settings: IPodNotesSettings,
options: { requireConfigured?: boolean } = {},
): CredentialValues {
const openAI = this.get(settings, "openai");
const deepgram = this.get(settings, "deepgram");
if (options.requireConfigured) {
if (settings.openAISecretId.trim() && !openAI) {
throw new Error(
"The selected OpenAI API key is not available in SecretStorage on this device.",
);
}
if (settings.deepgramSecretId.trim() && !deepgram) {
const values: CredentialValues = {};
for (const kind of CREDENTIAL_KINDS) {
const { settingsKey, exportKey, label } = CREDENTIALS[kind];
const value = this.get(settings, kind);
if (options.requireConfigured && settings[settingsKey].trim() && !value) {
throw new Error(
"The selected Deepgram API key is not available in SecretStorage on this device.",
`The selected ${label} API key is not available in SecretStorage on this device.`,
);
}
if (value) values[exportKey] = value;
}

return {
...(openAI ? { openAI } : {}),
...(deepgram ? { deepgram } : {}),
};
return values;
}

/**
Expand All @@ -92,12 +69,11 @@ export class CredentialRepository {
*/
storeValues(values: CredentialValues): CredentialReferences {
const references: CredentialReferences = {};
const openAI = values.openAI?.trim();
const deepgram = values.deepgram?.trim();

if (openAI) references.openAISecretId = this.store("openai", openAI);
if (deepgram) references.deepgramSecretId = this.store("deepgram", deepgram);

for (const kind of CREDENTIAL_KINDS) {
const { settingsKey, exportKey } = CREDENTIALS[kind];
const value = values[exportKey]?.trim();
if (value) references[settingsKey] = this.store(kind, value);
}
return references;
}

Expand All @@ -122,7 +98,7 @@ export class CredentialRepository {
}

private store(kind: CredentialKind, secret: string): string {
const { baseId } = CREDENTIALS[kind];
const baseId = secretBaseId(kind);

for (let suffix = 1; suffix <= 10_000; suffix++) {
const id = suffix === 1 ? baseId : `${baseId}-${suffix}`;
Expand Down
43 changes: 19 additions & 24 deletions src/settingsTransfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type PersistedPodNotesSettings,
PodNotesDataError,
} from "./persistence/podNotesData";
import type { CredentialValues } from "./types/Credentials";
import { CREDENTIAL_KINDS, CREDENTIALS, type CredentialValues } from "./types/Credentials";
import type { Episode } from "./types/Episode";
import type { IPodNotesSettings } from "./types/IPodNotesSettings";
import { ownRecord } from "./utility/ownRecord";
Expand Down Expand Up @@ -35,33 +35,26 @@ export const VAULT_LOCAL_KEYS: readonly (keyof IPodNotesSettings)[] = [
];

/** Provider API-key references are device-local, not settings transfer data. */
const SECRET_REFERENCE_KEYS: ReadonlySet<keyof IPodNotesSettings> = new Set([
"openAISecretId",
"deepgramSecretId",
]);
const SECRET_REFERENCE_KEYS: ReadonlySet<keyof IPodNotesSettings> = new Set(
CREDENTIAL_KINDS.map((kind) => CREDENTIALS[kind].settingsKey),
);

/** Plaintext fields that versions before 2.19.0 wrote. No new kind joins them. */
const LEGACY_SECRET_KEYS = {
openAIApiKey: "openAI",
diarizationApiKey: "deepgram",
} as const;

/** Human-facing names for each secret, so export/import copy can name exactly
* which keys leave or enter the vault instead of hard-coding "OpenAI". */
const SECRET_KEY_LABELS: Record<keyof CredentialValues, string> = {
openAI: "OpenAI API key",
deepgram: "Deepgram API key",
};

/**
* The human-facing labels for the secrets actually present (non-empty) in a
* settings object. Used to keep the export toggle, the export notice, and the
* import confirmation honest about which keys are involved, so a Deepgram-only
* user is never told only "OpenAI API key" (and vice versa). See issue #168.
*/
export function describeSecrets(secrets: CredentialValues): string[] {
return (Object.keys(SECRET_KEY_LABELS) as (keyof CredentialValues)[])
.filter((key) => Boolean(secrets[key]?.trim()))
.map((key) => SECRET_KEY_LABELS[key]);
return CREDENTIAL_KINDS.filter((kind) =>
Boolean(secrets[CREDENTIALS[kind].exportKey]?.trim()),
).map((kind) => `${CREDENTIALS[kind].label} API key`);
}

/** Known top-level setting keys that are safe to import (excludes vault-local state). */
Expand Down Expand Up @@ -382,22 +375,24 @@ function parseSecretsPayload(value: unknown): { values: CredentialValues } | { e
return { error: "Export file has an invalid secrets payload." };
}

for (const key of ["openAI", "deepgram"] as const) {
if (value[key] !== undefined && typeof value[key] !== "string") {
return { error: `Export file has an invalid ${SECRET_KEY_LABELS[key]}.` };
for (const kind of CREDENTIAL_KINDS) {
const { exportKey, label } = CREDENTIALS[kind];
if (value[exportKey] !== undefined && typeof value[exportKey] !== "string") {
return { error: `Export file has an invalid ${label} API key.` };
}
}

return { values: normalizeSecrets(value) };
}

function normalizeSecrets(values: CredentialValues): CredentialValues {
const openAI = values.openAI?.trim();
const deepgram = values.deepgram?.trim();
return {
...(openAI ? { openAI } : {}),
...(deepgram ? { deepgram } : {}),
};
const normalized: CredentialValues = {};
for (const kind of CREDENTIAL_KINDS) {
const key = CREDENTIALS[kind].exportKey;
const value = values[key]?.trim();
if (value) normalized[key] = value;
}
return normalized;
}

/** Drop any playlist entry that isn't a plain object with an `episodes` array. */
Expand Down
Loading
Loading