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
2 changes: 2 additions & 0 deletions packages/storage/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,5 @@ export * from "./text/index";

export * from "./credentials/EncryptedKvCredentialStore";
export * from "./credentials/LazyEncryptedCredentialStore";
export * from "./credentials/SecretVault";
export * from "./credentials/ServerCredentialStore";
20 changes: 20 additions & 0 deletions packages/storage/src/credentials/SecretVault.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Stores and retrieves raw secret bytes. The only shell-specific surface of
* the desktop credential stack: Electron implements it with `safeStorage`
* (ciphertext persisted in SQLite), Electrobun shells out to the OS keychain.
*
* Implementations MUST NOT log or expose secret values in errors.
* `id` is an opaque, caller-supplied identifier (the credential store derives
* it as `${user_id}/${project_id}/${key}`).
*/
export interface SecretVault {
setSecret(id: string, plaintext: string): Promise<void>;
getSecret(id: string): Promise<string | undefined>;
deleteSecret(id: string): Promise<void>;
}
110 changes: 110 additions & 0 deletions packages/storage/src/credentials/ServerCredentialStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from "vitest";
import { InMemoryKvStorage } from "../kv/InMemoryKvStorage";
import type { IKvStorage } from "../kv/IKvStorage";
import { ServerCredentialStore, type CredentialMetadataRow } from "./ServerCredentialStore";
import type { SecretVault } from "./SecretVault";

function makeVault(): SecretVault {
const map = new Map<string, string>();
return {
async setSecret(id, v) { map.set(id, v); },
async getSecret(id) { return map.get(id); },
async deleteSecret(id) { map.delete(id); },
};
}

function makeStore() {
const meta: IKvStorage<string, CredentialMetadataRow> = new InMemoryKvStorage();
const vault = makeVault();
const store = new ServerCredentialStore({ vault, metadata: meta, userId: "u1", projectId: "p1" });
return { store, meta, vault };
}

describe("ServerCredentialStore", () => {
it("put then get round-trips the secret", async () => {
const { store } = makeStore();
await store.put("openai", "sk-123", { provider: "openai", label: "OpenAI" });
expect(await store.get("openai")).toBe("sk-123");
});

it("has reflects presence; keys lists keys without values", async () => {
const { store } = makeStore();
await store.put("openai", "sk-123");
expect(await store.has("openai")).toBe(true);
expect(await store.has("missing")).toBe(false);
expect(await store.keys()).toEqual(["openai"]);
});

it("delete removes both secret and metadata", async () => {
const { store, vault } = makeStore();
await store.put("openai", "sk-123");
expect(await store.delete("openai")).toBe(true);
expect(await store.get("openai")).toBeUndefined();
expect(await vault.getSecret("u1/p1/openai")).toBeUndefined();
expect(await store.delete("openai")).toBe(false);
});

it("expired credentials are not returned and are evicted", async () => {
const { store } = makeStore();
await store.put("temp", "v", { expiresAt: new Date(Date.now() - 1000) });
expect(await store.get("temp")).toBeUndefined();
expect(await store.has("temp")).toBe(false);
});

it("listMetadata returns metadata only, never values", async () => {
const { store } = makeStore();
await store.put("openai", "sk-123", { provider: "openai", label: "OpenAI" });
const list = await store.listMetadata();
expect(list).toHaveLength(1);
expect(list[0]).toMatchObject({ key: "openai", provider: "openai", label: "OpenAI" });
expect(JSON.stringify(list)).not.toContain("sk-123");
});

it("deleteAll clears the project scope", async () => {
const { store } = makeStore();
await store.put("a", "1");
await store.put("b", "2");
await store.deleteAll();
expect(await store.keys()).toEqual([]);
});

it("isolates by project scope", async () => {
const meta: IKvStorage<string, CredentialMetadataRow> = new InMemoryKvStorage();
const vault = makeVault();
const p1 = new ServerCredentialStore({ vault, metadata: meta, userId: "u1", projectId: "p1" });
const p2 = new ServerCredentialStore({ vault, metadata: meta, userId: "u1", projectId: "p2" });
await p1.put("k", "v1");
await p2.put("k", "v2");
expect(await p1.get("k")).toBe("v1");
expect(await p2.get("k")).toBe("v2");
expect(await p1.keys()).toEqual(["k"]);
});

it("isolates by user scope", async () => {
const meta: IKvStorage<string, CredentialMetadataRow> = new InMemoryKvStorage();
const vault = makeVault();
const u1 = new ServerCredentialStore({ vault, metadata: meta, userId: "u1", projectId: "p1" });
const u2 = new ServerCredentialStore({ vault, metadata: meta, userId: "u2", projectId: "p1" });
await u1.put("k", "v1");
await u2.put("k", "v2");
expect(await u1.get("k")).toBe("v1");
expect(await u2.get("k")).toBe("v2");
expect(await u1.keys()).toEqual(["k"]);
expect(await u1.listMetadata()).toHaveLength(1);
});

it("deleteAll clears expired entries too", async () => {
const { store, meta } = makeStore();
await store.put("live", "v");
await store.put("dead", "v", { expiresAt: new Date(Date.now() - 1000) });
await store.deleteAll();
const remaining = (await meta.getAll()) ?? [];
expect(remaining).toHaveLength(0);
});
});
172 changes: 172 additions & 0 deletions packages/storage/src/credentials/ServerCredentialStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import type { CredentialPutOptions, ICredentialStore } from "@workglow/util";
import type { IKvStorage } from "../kv/IKvStorage";
import type { SecretVault } from "./SecretVault";

/** Persisted metadata row (NO secret value). */
export interface CredentialMetadataRow {
readonly userId: string;
readonly projectId: string;
readonly key: string;
readonly label: string | undefined;
readonly provider: string | undefined;
readonly createdAt: string;
readonly updatedAt: string;
readonly expiresAt: string | undefined;
}

/** Metadata shape exposed to the API list route (no value). */
export interface CredentialMetadataInfo {
readonly key: string;
readonly label: string | undefined;
readonly provider: string | undefined;
readonly createdAt: string;
readonly updatedAt: string;
readonly expiresAt: string | undefined;
}

export interface ServerCredentialStoreOptions {
readonly vault: SecretVault;
readonly metadata: IKvStorage<string, CredentialMetadataRow>;
readonly userId: string;
readonly projectId: string;
}

/**
* Project-scoped credential store. Secret bytes live in a {@link SecretVault};
* metadata lives in an {@link IKvStorage}. Decryption happens only here, in
* the process that owns the vault — never in the renderer.
*/
export class ServerCredentialStore implements ICredentialStore {
private readonly vault: SecretVault;
private readonly metadata: IKvStorage<string, CredentialMetadataRow>;
private readonly userId: string;
private readonly projectId: string;
private readonly prefix: string;

constructor(opts: ServerCredentialStoreOptions) {
this.vault = opts.vault;
this.metadata = opts.metadata;
this.userId = opts.userId;
this.projectId = opts.projectId;
this.prefix = `${this.userId}/${this.projectId}/`;
}

private vaultId(key: string): string {
return `${this.prefix}${key}`;
}

private isExpired(row: CredentialMetadataRow): boolean {
return row.expiresAt !== undefined && new Date(row.expiresAt) <= new Date();
}

async get(key: string): Promise<string | undefined> {
const id = this.vaultId(key);
const row = await this.metadata.get(id);
if (!row) return undefined;
if (this.isExpired(row)) {
await this.delete(key);
return undefined;
}
return this.vault.getSecret(id);
}

async put(key: string, value: string, options?: CredentialPutOptions): Promise<void> {
const id = this.vaultId(key);
const now = new Date().toISOString();
const existing = await this.metadata.get(id);
await this.vault.setSecret(id, value);
const row: CredentialMetadataRow = {
userId: this.userId,
projectId: this.projectId,
key,
label: options?.label ?? existing?.label,
provider: options?.provider ?? existing?.provider,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
expiresAt: options?.expiresAt ? options.expiresAt.toISOString() : existing?.expiresAt,
};
try {
await this.metadata.put(id, row);
} catch (err) {
// get()/has() gate on the metadata row, so a written secret with no
// metadata is unreachable and orphaned. For a brand-new entry, roll the
// vault back; for an update, leave the prior secret (metadata still
// points at it).
if (!existing) {
await this.vault.deleteSecret(id).catch(() => undefined);
}
throw err;
}
}

async delete(key: string): Promise<boolean> {
const id = this.vaultId(key);
const existed = (await this.metadata.get(id)) !== undefined;
if (existed) {
await this.vault.deleteSecret(id);
await this.metadata.delete(id);
}
return existed;
}

async has(key: string): Promise<boolean> {
const row = await this.metadata.get(this.vaultId(key));
if (!row) return false;
if (this.isExpired(row)) {
await this.delete(key);
return false;
}
return true;
}

async keys(): Promise<readonly string[]> {
return (await this.listMetadata()).map((m) => m.key);
}

/** Vault ids of every row in this scope, ignoring expiry. */
private async scopedIds(): Promise<string[]> {
const all = await this.metadata.getAll();
if (!all) return [];
const ids: string[] = [];
for (const entry of all) {
if (entry.value.userId === this.userId && entry.value.projectId === this.projectId) {
ids.push(entry.key);
}
}
return ids;
}

async deleteAll(): Promise<void> {
for (const id of await this.scopedIds()) {
await this.vault.deleteSecret(id);
await this.metadata.delete(id);
}
}

/** Metadata for every non-expired credential in this project scope. */
async listMetadata(): Promise<CredentialMetadataInfo[]> {
const all = await this.metadata.getAll();
if (!all) return [];
const out: CredentialMetadataInfo[] = [];
for (const entry of all) {
const row = entry.value;
if (row.userId !== this.userId || row.projectId !== this.projectId) continue;
if (this.isExpired(row)) continue;
out.push({
key: row.key,
label: row.label,
provider: row.provider,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
expiresAt: row.expiresAt,
});
}
return out;
}
}