From 3cb13110921d0d3afad727012c1bf778d763ef56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:14:44 +0000 Subject: [PATCH 1/3] Initial plan From b6e05e14f74e58e8461e06697f1001128f3bf66f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:23:48 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=94=A7=20update:=20add=20node=20sqlit?= =?UTF-8?q?e=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: warengonzaga <15052701+warengonzaga@users.noreply.github.com> --- README.md | 5 ++- package.json | 7 ++-- src/engine.ts | 11 +++-- src/store.ts | 99 ++++++++++++++++++++++++++++++++++++++++---- tests/engine.test.ts | 52 ++++++++++++++++++++--- 5 files changed, 155 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1c63803..1e6c2fa 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,17 @@ Keep your secrets truly secret. With encrypted names and values, zero friction, - **Machine-bound** — Encryption keys are derived from machine identity + random keyfile via scrypt. - **Defense in depth** — Filesystem permission verification, HMAC integrity checks, per-entry unique IVs. - **Actionable recovery** — Static reset/destroy APIs recover unreadable stores without a successful `open()`. -- **Bun-native** — Built on `bun:sqlite` and Node crypto. Zero external runtime dependencies. +- **Bun-first, Node-compatible** — Prefers `bun:sqlite` on Bun and falls back to built-in `node:sqlite` on supported Node runtimes. Zero external runtime dependencies. ## Installation ```bash bun add @wgtechlabs/secrets-engine +npm install @wgtechlabs/secrets-engine ``` +On Node, use a runtime with built-in `node:sqlite` support (Node 22.5+; some Node 22 releases may require enabling SQLite support explicitly). Bun remains the primary runtime and uses `bun:sqlite`. + ## Quick Start ```typescript diff --git a/package.json b/package.json index dda2fe3..11fba79 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@wgtechlabs/secrets-engine", "version": "2.0.0", - "description": "Bun-first TypeScript SDK for securely storing and managing secrets with zero-friction, machine-bound AES-256-GCM encryption.", + "description": "Bun-first, Node-compatible TypeScript SDK for securely storing and managing secrets with zero-friction, machine-bound AES-256-GCM encryption.", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -17,7 +17,7 @@ "README.md" ], "scripts": { - "build": "bun build ./src/index.ts --outdir ./dist --target bun", + "build": "bun build ./src/index.ts --outdir ./dist --target node", "build:types": "bun x tsc --emitDeclarationOnly --declaration --outDir dist", "prepublishOnly": "bun run build && bun run build:types", "test": "bun test", @@ -46,7 +46,8 @@ "url": "https://github.com/wgtechlabs/secrets-engine.git" }, "engines": { - "bun": ">=1.0.0" + "bun": ">=1.0.0", + "node": ">=22.5.0" }, "publishConfig": { "access": "public" diff --git a/src/engine.ts b/src/engine.ts index ce5a75e..f9fe317 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -11,7 +11,12 @@ import { access, readdir, rm, unlink } from "node:fs/promises"; import { join } from "node:path"; import { decrypt, deriveMasterKey, encrypt, generateSalt, hmac } from "./crypto.ts"; -import { DecryptionError, InitializationError, IntegrityError, KeyNotFoundError } from "./errors.ts"; +import { + DecryptionError, + InitializationError, + IntegrityError, + KeyNotFoundError, +} from "./errors.ts"; import { filterKeys } from "./glob.ts"; import { readStoreMeta, updateIntegrity, verifyIntegrity } from "./integrity.ts"; import { @@ -106,7 +111,7 @@ export class SecretsEngine { const machineIdentity = getMachineIdentityProfile(); // 4. Open SQLite database - const store = SecretStore.open(dirPath); + const store = await SecretStore.open(dirPath); try { const { masterKey, machineBinding } = storeState.isNewStore @@ -526,7 +531,7 @@ async function releaseDetachedStore(dirPath: string): Promise { let store: SecretStore; try { - store = SecretStore.open(dirPath); + store = await SecretStore.open(dirPath); } catch { return; } diff --git a/src/store.ts b/src/store.ts index d879d47..18f12d6 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,16 +1,29 @@ /** - * SQLite storage layer — wraps `bun:sqlite` with the encrypted secrets schema. + * SQLite storage layer — prefers `bun:sqlite` on Bun and falls back to + * `node:sqlite` on Node with the encrypted secrets schema. * * This module owns all database I/O. No SQL escapes this file. */ -import { Database } from "bun:sqlite"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { InitializationError } from "./errors.ts"; import { CONSTANTS } from "./types.ts"; import type { EncryptedEntry } from "./types.ts"; +interface SQLiteStatement { + run(...params: unknown[]): { changes: number }; + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown[]; +} + +interface SQLiteDatabase { + readonly filename: string; + exec(sql: string): void; + prepare(sql: string): SQLiteStatement; + close(): void; +} + // --------------------------------------------------------------------------- // Schema DDL // --------------------------------------------------------------------------- @@ -40,13 +53,14 @@ const CREATE_META_TABLE = ` /** * Low-level SQLite store for encrypted secret entries. * - * All methods are synchronous because `bun:sqlite` is synchronous. + * All methods are synchronous because both supported SQLite runtimes expose + * synchronous database APIs. * The higher-level SecretsEngine wraps these with async semantics where needed. */ export class SecretStore { - private readonly db: Database; + private readonly db: SQLiteDatabase; - private constructor(db: Database) { + private constructor(db: SQLiteDatabase) { this.db = db; } @@ -54,11 +68,11 @@ export class SecretStore { * Open (or create) the SQLite database at the given directory. * Enables WAL mode and initializes the schema. */ - static open(dirPath: string): SecretStore { + static async open(dirPath: string): Promise { const dbPath = join(dirPath, CONSTANTS.DB_NAME); try { - const db = new Database(dbPath, { create: true }); + const db = await openDatabase(dbPath); db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); @@ -170,3 +184,74 @@ export class SecretStore { this.db.close(); } } + +async function openDatabase(dbPath: string): Promise { + if (isBunRuntime()) { + return await openBunDatabase(dbPath); + } + + return await openNodeDatabase(dbPath); +} + +function isBunRuntime(): boolean { + return typeof Bun !== "undefined" || Boolean(process.versions?.bun); +} + +async function openBunDatabase(dbPath: string): Promise { + const { Database } = (await import(getBunSqliteSpecifier())) as { + Database: new ( + filename: string, + options?: { + create?: boolean; + }, + ) => SQLiteDatabase; + }; + + return new Database(dbPath, { create: true }); +} + +async function openNodeDatabase(dbPath: string): Promise { + try { + const { DatabaseSync } = (await import(getNodeSqliteSpecifier())) as { + DatabaseSync: new ( + filename: string, + options?: { + open?: boolean; + }, + ) => { + exec(sql: string): void; + prepare(sql: string): SQLiteStatement; + close(): void; + location(): string | null; + }; + }; + + const db = new DatabaseSync(dbPath, { open: true }); + + return { + filename: db.location() ?? dbPath, + exec(sql: string): void { + db.exec(sql); + }, + prepare(sql: string): SQLiteStatement { + return db.prepare(sql); + }, + close(): void { + db.close(); + }, + }; + } catch (error) { + throw new Error( + "Node runtime requires built-in node:sqlite support (Node >= 22.5, or a newer Node release with sqlite enabled).", + { cause: error }, + ); + } +} + +function getBunSqliteSpecifier(): string { + return ["bun", "sqlite"].join(":"); +} + +function getNodeSqliteSpecifier(): string { + return ["node", "sqlite"].join(":"); +} diff --git a/tests/engine.test.ts b/tests/engine.test.ts index c4bccaa..772055e 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -6,11 +6,17 @@ import { Database } from "bun:sqlite"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { InitializationError, IntegrityError, KeyNotFoundError, SecretsEngine } from "../src/index.ts"; +import { join, relative } from "node:path"; +import { + InitializationError, + type IntegrityError, + KeyNotFoundError, + SecretsEngine, +} from "../src/index.ts"; import { CONSTANTS } from "../src/types.ts"; import type { StoreMeta } from "../src/types.ts"; @@ -68,6 +74,44 @@ describe("SecretsEngine.open", () => { await engine2.close(); }); + test("supports a Node-targeted bundle without loading bun:sqlite on Node", async () => { + const consumerEntry = join(testDir, "consumer.ts"); + const bundlePath = join(testDir, "dist", "consumer.js"); + const nodeStorePath = join(testDir, "node-store"); + const sourceImport = relative(testDir, join(process.cwd(), "src/index.ts")).replaceAll( + "\\", + "/", + ); + + await writeFile( + consumerEntry, + ` + import { SecretsEngine } from ${JSON.stringify(sourceImport)}; + + const secrets = await SecretsEngine.open({ path: ${JSON.stringify(nodeStorePath)} }); + await secrets.set("cli.version", "1.0.0"); + console.log(await secrets.get("cli.version")); + await secrets.close(); + `, + ); + + const buildResult = await Bun.build({ + entrypoints: [consumerEntry], + outfile: bundlePath, + target: "node", + }); + + expect(buildResult.success).toBe(true); + + const result = spawnSync("node", [bundlePath], { + encoding: "utf-8", + }); + + expect(result.status).toBe(0); + expect(result.stderr).not.toContain("ERR_UNSUPPORTED_ESM_URL_SCHEME"); + expect(result.stdout.trim()).toBe("1.0.0"); + }); + test("preserves secrets across reopens", async () => { const engine1 = await SecretsEngine.open({ path: testDir }); await engine1.set("key.a", "value-a"); @@ -410,9 +454,7 @@ describe("recovery APIs", () => { const unrelatedFile = join(testDir, "notes.txt"); await writeFile(unrelatedFile, "keep me"); - await expect(SecretsEngine.resetAtPath({ path: testDir })).rejects.toThrow( - InitializationError, - ); + await expect(SecretsEngine.resetAtPath({ path: testDir })).rejects.toThrow(InitializationError); expect(existsSync(unrelatedFile)).toBe(true); }); From 7b8f336d7eb37312dc83ea9e8f7ffe98454bf862 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:25:29 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=A7=20update:=20harden=20runtime?= =?UTF-8?q?=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: warengonzaga <15052701+warengonzaga@users.noreply.github.com> --- src/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store.ts b/src/store.ts index 18f12d6..fcfd78a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -194,7 +194,7 @@ async function openDatabase(dbPath: string): Promise { } function isBunRuntime(): boolean { - return typeof Bun !== "undefined" || Boolean(process.versions?.bun); + return typeof globalThis.Bun !== "undefined" || Boolean(process.versions?.bun); } async function openBunDatabase(dbPath: string): Promise {