@axiom-lattice/local-stores 3.2.0 → 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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +24 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +59 -21
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +44 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/LocalConnectionStore.test.ts +176 -0
- package/src/stores/LocalConnectionStore.ts +46 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/local-stores",
|
|
3
|
-
"version": "3.2.
|
|
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.
|
|
29
|
-
"@axiom-lattice/protocols": "4.7.
|
|
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(
|
|
74
|
-
|
|
75
|
-
|
|
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:
|
|
167
|
+
config: decryptConfigFields(storedConfig, secretFields),
|
|
168
|
+
secretFields,
|
|
130
169
|
createdAt: row.created_at,
|
|
131
170
|
updatedAt: row.updated_at,
|
|
132
171
|
};
|