@axiom-lattice/local-stores 3.2.1 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/local-stores",
3
- "version": "3.2.1",
3
+ "version": "3.2.2",
4
4
  "description": "Local SQLite-based stores for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -25,8 +25,8 @@
25
25
  "@types/sql.js": "^1.4.11",
26
26
  "sql.js": "^1.14.1",
27
27
  "uuid": "^14.0.1",
28
- "@axiom-lattice/core": "6.3.1",
29
- "@axiom-lattice/protocols": "4.7.0"
28
+ "@axiom-lattice/core": "6.3.2",
29
+ "@axiom-lattice/protocols": "4.7.1"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
@@ -0,0 +1,176 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
2
+ import path from "path";
3
+ import { tmpdir } from "os";
4
+ import { closeDatabase, initDatabase, type DatabaseWrapper } from "../database";
5
+ import { LocalConnectionStore } from "../stores/LocalConnectionStore";
6
+ import { decrypt, clearEncryptionKeyCache } from "@axiom-lattice/core";
7
+
8
+ jest.mock("uuid", () => {
9
+ let id = 0;
10
+ return { v4: jest.fn(() => `test-id-${++id}`) };
11
+ });
12
+
13
+ describe("LocalConnectionStore secret field encryption", () => {
14
+ let store: LocalConnectionStore;
15
+ let db: DatabaseWrapper;
16
+
17
+ function rawConfig(key: string): { config: Record<string, unknown>; secretFields: string | null } {
18
+ const row = db
19
+ .prepare("SELECT config, secret_fields FROM connection_configs WHERE tenant_id = ? AND key = ?")
20
+ .get("t", key) as { config: string; secret_fields: string | null };
21
+ return { config: JSON.parse(row.config) as Record<string, unknown>, secretFields: row.secret_fields };
22
+ }
23
+
24
+ beforeEach(async () => {
25
+ const dbPath = path.join(tmpdir(), `lt-conn-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
26
+ db = await initDatabase({ dbPath });
27
+ store = new LocalConnectionStore(db);
28
+ });
29
+
30
+ afterEach(() => closeDatabase());
31
+
32
+ it("encrypts declared secret fields at rest and decrypts them on read", async () => {
33
+ const created = await store.create({
34
+ tenantId: "t",
35
+ type: "semantic-metrics",
36
+ key: "conn",
37
+ name: "Conn",
38
+ config: { baseUrl: "https://metrics.example/api/v1", apiKey: "sk-real-secret" },
39
+ secretFields: ["apiKey"],
40
+ });
41
+
42
+ const raw = rawConfig("conn");
43
+ expect(raw.config.baseUrl).toBe("https://metrics.example/api/v1");
44
+ expect(raw.config.apiKey).not.toBe("sk-real-secret");
45
+ expect(decrypt(raw.config.apiKey as string)).toBe("sk-real-secret");
46
+ expect(raw.secretFields).toBe(JSON.stringify(["apiKey"]));
47
+
48
+ // Create returns the real value to the caller.
49
+ expect(created.config.apiKey).toBe("sk-real-secret");
50
+
51
+ const read = await store.getByKey("t", "semantic-metrics", "conn");
52
+ expect(read?.config.apiKey).toBe("sk-real-secret");
53
+ expect(read?.secretFields).toEqual(["apiKey"]);
54
+
55
+ const [listed] = await store.listByType("t", "semantic-metrics");
56
+ expect(listed.config.apiKey).toBe("sk-real-secret");
57
+ });
58
+
59
+ it("reuses the stored secret fields when an update omits them", async () => {
60
+ await store.create({
61
+ tenantId: "t",
62
+ type: "semantic-metrics",
63
+ key: "conn",
64
+ name: "Conn",
65
+ config: { apiKey: "sk-one" },
66
+ secretFields: ["apiKey"],
67
+ });
68
+
69
+ const updated = await store.update("t", "semantic-metrics", "conn", {
70
+ config: { apiKey: "sk-two" },
71
+ });
72
+
73
+ expect(updated?.config.apiKey).toBe("sk-two");
74
+ expect(decrypt(rawConfig("conn").config.apiKey as string)).toBe("sk-two");
75
+ });
76
+
77
+ it("leaves config untouched when only metadata is updated", async () => {
78
+ await store.create({
79
+ tenantId: "t",
80
+ type: "semantic-metrics",
81
+ key: "conn",
82
+ name: "Conn",
83
+ config: { apiKey: "sk-one" },
84
+ secretFields: ["apiKey"],
85
+ });
86
+ const before = rawConfig("conn").config.apiKey;
87
+
88
+ await store.update("t", "semantic-metrics", "conn", { name: "Renamed" });
89
+
90
+ expect(rawConfig("conn").config.apiKey).toBe(before);
91
+ });
92
+
93
+ it("returns legacy plaintext rows unchanged", async () => {
94
+ db.run(
95
+ "INSERT INTO connection_configs (id, tenant_id, type, key, name, description, config, secret_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
96
+ "legacy-id", "t", "semantic-metrics", "legacy", "Legacy", null,
97
+ JSON.stringify({ apiKey: "sk-legacy" }), null, "2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00.000Z",
98
+ );
99
+
100
+ const read = await store.getByKey("t", "semantic-metrics", "legacy");
101
+ expect(read?.config.apiKey).toBe("sk-legacy");
102
+ expect(read?.secretFields).toBeUndefined();
103
+ });
104
+
105
+ it("adds the secret_fields column to a legacy database and encrypts new writes", async () => {
106
+ // Simulate a database created before secret_fields existed.
107
+ db.exec("DROP TABLE connection_configs");
108
+ db.exec(`
109
+ CREATE TABLE connection_configs (
110
+ id TEXT NOT NULL,
111
+ tenant_id TEXT NOT NULL,
112
+ type TEXT NOT NULL,
113
+ key TEXT NOT NULL,
114
+ name TEXT NOT NULL,
115
+ description TEXT,
116
+ config TEXT NOT NULL,
117
+ created_at TEXT NOT NULL,
118
+ updated_at TEXT NOT NULL,
119
+ PRIMARY KEY (tenant_id, id),
120
+ UNIQUE (tenant_id, type, key)
121
+ )
122
+ `);
123
+ db.run(
124
+ "INSERT INTO connection_configs (id, tenant_id, type, key, name, description, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
125
+ "old-id", "t", "semantic-metrics", "legacy", "Legacy", null,
126
+ JSON.stringify({ apiKey: "sk-legacy" }), "2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00.000Z",
127
+ );
128
+
129
+ const upgraded = new LocalConnectionStore(db);
130
+ const columns = db.prepare("PRAGMA table_info(connection_configs)").all() as Array<{ name: string }>;
131
+ expect(columns.some((c) => c.name === "secret_fields")).toBe(true);
132
+
133
+ // Legacy plaintext row still reads unchanged after the column is added.
134
+ expect((await upgraded.getByKey("t", "semantic-metrics", "legacy"))?.config.apiKey).toBe("sk-legacy");
135
+
136
+ // New writes on the upgraded store are encrypted.
137
+ await upgraded.create({
138
+ tenantId: "t",
139
+ type: "semantic-metrics",
140
+ key: "fresh",
141
+ name: "Fresh",
142
+ config: { apiKey: "sk-fresh" },
143
+ secretFields: ["apiKey"],
144
+ });
145
+ const raw = db
146
+ .prepare("SELECT config FROM connection_configs WHERE tenant_id = ? AND key = ?")
147
+ .get("t", "fresh") as { config: string };
148
+ expect(JSON.parse(raw.config).apiKey).not.toBe("sk-fresh");
149
+ });
150
+
151
+ it("fails closed when stored secrets cannot be decrypted with the current key", async () => {
152
+ const previous = process.env.LATTICE_ENCRYPTION_KEY;
153
+ try {
154
+ process.env.LATTICE_ENCRYPTION_KEY = "test-key-one";
155
+ clearEncryptionKeyCache();
156
+ await store.create({
157
+ tenantId: "t",
158
+ type: "semantic-metrics",
159
+ key: "conn",
160
+ name: "Conn",
161
+ config: { apiKey: "sk-one" },
162
+ secretFields: ["apiKey"],
163
+ });
164
+
165
+ process.env.LATTICE_ENCRYPTION_KEY = "test-key-two";
166
+ clearEncryptionKeyCache();
167
+
168
+ await expect(store.getByKey("t", "semantic-metrics", "conn"))
169
+ .rejects.toThrow("Failed to decrypt connection configuration");
170
+ } finally {
171
+ if (previous === undefined) delete process.env.LATTICE_ENCRYPTION_KEY;
172
+ else process.env.LATTICE_ENCRYPTION_KEY = previous;
173
+ clearEncryptionKeyCache();
174
+ }
175
+ });
176
+ });
@@ -4,6 +4,7 @@
4
4
  import { DatabaseWrapper, ensureTable, nowISO } from "../database";
5
5
  import type { ConnectionEntry, ConnectionStore } from "@axiom-lattice/protocols";
6
6
  import { v4 as uuid } from "uuid";
7
+ import { encryptConfigFields, decryptConfigFields } from "@axiom-lattice/core";
7
8
 
8
9
  const DDL = `
9
10
  CREATE TABLE IF NOT EXISTS connection_configs (
@@ -14,6 +15,7 @@ CREATE TABLE IF NOT EXISTS connection_configs (
14
15
  name TEXT NOT NULL,
15
16
  description TEXT,
16
17
  config TEXT NOT NULL,
18
+ secret_fields TEXT,
17
19
  created_at TEXT NOT NULL,
18
20
  updated_at TEXT NOT NULL,
19
21
  PRIMARY KEY (tenant_id, id),
@@ -30,16 +32,39 @@ interface ConnectionRow {
30
32
  name: string;
31
33
  description: string | null;
32
34
  config: string;
35
+ secret_fields: string | null;
33
36
  created_at: string;
34
37
  updated_at: string;
35
38
  }
36
39
 
40
+ /** Parse a persisted secret-field list; null/empty means "no encrypted fields". */
41
+ function parseSecretFields(raw: string | null): string[] | undefined {
42
+ if (!raw) return undefined;
43
+ try {
44
+ const parsed = JSON.parse(raw) as unknown;
45
+ if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string") && parsed.length > 0) {
46
+ return parsed as string[];
47
+ }
48
+ } catch {
49
+ /* fall through */
50
+ }
51
+ return undefined;
52
+ }
53
+
37
54
  export class LocalConnectionStore implements ConnectionStore {
38
55
  private db: DatabaseWrapper;
39
56
 
40
57
  constructor(db: DatabaseWrapper) {
41
58
  this.db = db;
42
59
  ensureTable(db, DDL);
60
+ this.ensureColumn("connection_configs", "secret_fields", "secret_fields TEXT");
61
+ }
62
+
63
+ private ensureColumn(table: string, column: string, ddl: string): void {
64
+ const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
65
+ if (!cols.some((c) => c.name === column)) {
66
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
67
+ }
43
68
  }
44
69
 
45
70
  async listByTenant(tenantId: string): Promise<ConnectionEntry[]> {
@@ -66,13 +91,19 @@ export class LocalConnectionStore implements ConnectionStore {
66
91
  async create(entry: Omit<ConnectionEntry, "id" | "createdAt" | "updatedAt">): Promise<ConnectionEntry> {
67
92
  const id = uuid();
68
93
  const now = nowISO();
94
+ const secretFields = entry.secretFields?.length ? entry.secretFields : undefined;
69
95
 
70
96
  this.db.prepare(
71
- `INSERT INTO connection_configs (id, tenant_id, type, key, name, description, config, created_at, updated_at)
72
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
73
- ).run(id, entry.tenantId, entry.type, entry.key, entry.name, entry.description || null, JSON.stringify(entry.config), now, now);
74
-
75
- return { id, ...entry, createdAt: now, updatedAt: now };
97
+ `INSERT INTO connection_configs (id, tenant_id, type, key, name, description, config, secret_fields, created_at, updated_at)
98
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
99
+ ).run(
100
+ id, entry.tenantId, entry.type, entry.key, entry.name, entry.description || null,
101
+ JSON.stringify(encryptConfigFields(entry.config, secretFields)),
102
+ secretFields ? JSON.stringify(secretFields) : null,
103
+ now, now,
104
+ );
105
+
106
+ return { id, ...entry, secretFields, createdAt: now, updatedAt: now };
76
107
  }
77
108
 
78
109
  async update(
@@ -93,8 +124,13 @@ export class LocalConnectionStore implements ConnectionStore {
93
124
  values.push(updates.description || null);
94
125
  }
95
126
  if (updates.config !== undefined) {
127
+ const secretFields = updates.secretFields?.length
128
+ ? updates.secretFields
129
+ : (existing.secretFields?.length ? existing.secretFields : undefined);
96
130
  setClauses.push("config = ?");
97
- values.push(JSON.stringify(updates.config));
131
+ values.push(JSON.stringify(encryptConfigFields(updates.config, secretFields)));
132
+ setClauses.push("secret_fields = ?");
133
+ values.push(secretFields ? JSON.stringify(secretFields) : null);
98
134
  }
99
135
 
100
136
  if (setClauses.length === 0) return existing;
@@ -119,6 +155,8 @@ export class LocalConnectionStore implements ConnectionStore {
119
155
  }
120
156
 
121
157
  private mapRow(row: ConnectionRow): ConnectionEntry {
158
+ const secretFields = parseSecretFields(row.secret_fields);
159
+ const storedConfig = JSON.parse(row.config) as Record<string, unknown>;
122
160
  return {
123
161
  id: row.id,
124
162
  tenantId: row.tenant_id,
@@ -126,7 +164,8 @@ export class LocalConnectionStore implements ConnectionStore {
126
164
  key: row.key,
127
165
  name: row.name,
128
166
  description: row.description || undefined,
129
- config: JSON.parse(row.config) as Record<string, unknown>,
167
+ config: decryptConfigFields(storedConfig, secretFields),
168
+ secretFields,
130
169
  createdAt: row.created_at,
131
170
  updatedAt: row.updated_at,
132
171
  };