@axiom-lattice/pg-stores 3.3.1 → 3.3.3
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 +8 -8
- package/CHANGELOG.md +24 -0
- package/dist/index.js +63 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +33 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLConnectionStore.integration.test.ts +106 -0
- package/src/createPgStoreConfig.ts +2 -1
- package/src/migrations/connection_config_migrations.ts +25 -0
- package/src/stores/PostgreSQLConnectionStore.ts +25 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/pg-stores",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.3",
|
|
4
4
|
"description": "PG stores implementation for Axiom Lattice framework",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"@langchain/core": "1.1.30",
|
|
26
26
|
"pg": "^8.16.3",
|
|
27
27
|
"uuid": "^9.0.1",
|
|
28
|
-
"@axiom-lattice/core": "6.3.
|
|
29
|
-
"@axiom-lattice/protocols": "4.7.
|
|
28
|
+
"@axiom-lattice/core": "6.3.3",
|
|
29
|
+
"@axiom-lattice/protocols": "4.7.1"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/jest": "^29.5.14",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
|
|
4
|
+
// The @axiom-lattice/core barrel cannot load under jest in this package (ESM-only
|
|
5
|
+
// transitive deps), so mock it with the real encryption module — node:crypto only.
|
|
6
|
+
jest.mock("@axiom-lattice/core", () => ({
|
|
7
|
+
...jest.requireActual("../../../core/src/util/encryption"),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { PostgreSQLConnectionStore } from "../stores/PostgreSQLConnectionStore";
|
|
11
|
+
import {
|
|
12
|
+
createConnectionConfigsTable,
|
|
13
|
+
addConnectionSecretFields,
|
|
14
|
+
} from "../migrations/connection_config_migrations";
|
|
15
|
+
import { decrypt } from "@axiom-lattice/core";
|
|
16
|
+
|
|
17
|
+
const databaseUrl = process.env.TEST_DATABASE_URL;
|
|
18
|
+
const describeIfDatabase = databaseUrl ? describe : describe.skip;
|
|
19
|
+
|
|
20
|
+
describeIfDatabase("PostgreSQLConnectionStore secret field encryption", () => {
|
|
21
|
+
let pool: Pool;
|
|
22
|
+
let store: PostgreSQLConnectionStore;
|
|
23
|
+
const tenantId = `conn-secrets-${randomUUID()}`;
|
|
24
|
+
const type = "semantic-metrics";
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
pool = new Pool({ connectionString: databaseUrl });
|
|
28
|
+
const client = await pool.connect();
|
|
29
|
+
try {
|
|
30
|
+
await createConnectionConfigsTable.up(client);
|
|
31
|
+
await addConnectionSecretFields.up(client);
|
|
32
|
+
} finally {
|
|
33
|
+
client.release();
|
|
34
|
+
}
|
|
35
|
+
store = new PostgreSQLConnectionStore(pool);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
await pool.query("DELETE FROM lattice_connection_configs WHERE tenant_id = $1", [tenantId]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
await pool.end();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("encrypts declared secret fields at rest and decrypts them on read", async () => {
|
|
47
|
+
const created = await store.create({
|
|
48
|
+
tenantId,
|
|
49
|
+
type,
|
|
50
|
+
key: "conn",
|
|
51
|
+
name: "Conn",
|
|
52
|
+
config: { baseUrl: "https://metrics.example/api/v1", apiKey: "sk-real-secret" },
|
|
53
|
+
secretFields: ["apiKey"],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const raw = await pool.query<{ config: Record<string, unknown>; secret_fields: string[] }>(
|
|
57
|
+
"SELECT config, secret_fields FROM lattice_connection_configs WHERE tenant_id = $1 AND key = $2",
|
|
58
|
+
[tenantId, "conn"],
|
|
59
|
+
);
|
|
60
|
+
expect(raw.rows[0].config.baseUrl).toBe("https://metrics.example/api/v1");
|
|
61
|
+
expect(raw.rows[0].config.apiKey).not.toBe("sk-real-secret");
|
|
62
|
+
expect(decrypt(raw.rows[0].config.apiKey as string)).toBe("sk-real-secret");
|
|
63
|
+
expect(raw.rows[0].secret_fields).toEqual(["apiKey"]);
|
|
64
|
+
|
|
65
|
+
expect(created.config.apiKey).toBe("sk-real-secret");
|
|
66
|
+
|
|
67
|
+
const read = await store.getByKey(tenantId, type, "conn");
|
|
68
|
+
expect(read?.config.apiKey).toBe("sk-real-secret");
|
|
69
|
+
expect(read?.secretFields).toEqual(["apiKey"]);
|
|
70
|
+
|
|
71
|
+
const [listed] = await store.listByType(tenantId, type);
|
|
72
|
+
expect(listed.config.apiKey).toBe("sk-real-secret");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("reuses stored secret fields when an update omits them", async () => {
|
|
76
|
+
await store.create({
|
|
77
|
+
tenantId,
|
|
78
|
+
type,
|
|
79
|
+
key: "conn",
|
|
80
|
+
name: "Conn",
|
|
81
|
+
config: { apiKey: "sk-one" },
|
|
82
|
+
secretFields: ["apiKey"],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const updated = await store.update(tenantId, type, "conn", { config: { apiKey: "sk-two" } });
|
|
86
|
+
expect(updated?.config.apiKey).toBe("sk-two");
|
|
87
|
+
|
|
88
|
+
const raw = await pool.query<{ config: Record<string, unknown> }>(
|
|
89
|
+
"SELECT config FROM lattice_connection_configs WHERE tenant_id = $1 AND key = $2",
|
|
90
|
+
[tenantId, "conn"],
|
|
91
|
+
);
|
|
92
|
+
expect(decrypt(raw.rows[0].config.apiKey as string)).toBe("sk-two");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns legacy rows without secret fields unchanged", async () => {
|
|
96
|
+
await pool.query(
|
|
97
|
+
`INSERT INTO lattice_connection_configs (id, tenant_id, type, key, name, config, created_at, updated_at)
|
|
98
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
|
99
|
+
[randomUUID(), tenantId, type, "legacy", "Legacy", JSON.stringify({ apiKey: "sk-legacy" })],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const read = await store.getByKey(tenantId, type, "legacy");
|
|
103
|
+
expect(read?.config.apiKey).toBe("sk-legacy");
|
|
104
|
+
expect(read?.secretFields).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -55,7 +55,7 @@ import { createChannelInstallationsTable } from "./migrations/channel_installati
|
|
|
55
55
|
import { alterChannelInstallationsTable } from "./migrations/channel_installations_alter_migration";
|
|
56
56
|
import { createChannelBindingsTable } from "./migrations/channel_bindings_migration";
|
|
57
57
|
import { createDatabaseConfigsTable } from "./migrations/database_config_migrations";
|
|
58
|
-
import { createConnectionConfigsTable } from "./migrations/connection_config_migrations";
|
|
58
|
+
import { createConnectionConfigsTable, addConnectionSecretFields } from "./migrations/connection_config_migrations";
|
|
59
59
|
import { createMetricsConfigsTable } from "./migrations/metrics_config_migrations";
|
|
60
60
|
import { createMcpServerConfigsTable } from "./migrations/mcp_server_config_migrations";
|
|
61
61
|
import { registerThreadMessageQueueMigrations } from "./migrations/queue_migrations";
|
|
@@ -178,6 +178,7 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
178
178
|
mm.register(addModelProviderModelStale); // v184
|
|
179
179
|
mm.register(createDistributedLeases); // v185
|
|
180
180
|
mm.register(createOutboxTable); // v187
|
|
181
|
+
mm.register(addConnectionSecretFields); // v188
|
|
181
182
|
|
|
182
183
|
await mm.migrate();
|
|
183
184
|
|
|
@@ -48,3 +48,28 @@ export const createConnectionConfigsTable: Migration = {
|
|
|
48
48
|
await client.query("DROP TABLE IF EXISTS lattice_connection_configs");
|
|
49
49
|
},
|
|
50
50
|
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Tracks which config fields a connection holds secrets in.
|
|
54
|
+
*
|
|
55
|
+
* The gateway derives this list from a plugin's connection schema (password
|
|
56
|
+
* fields) and passes it on write; the store then encrypts those fields at rest
|
|
57
|
+
* and decrypts them on read, so the API always returns real values. Existing
|
|
58
|
+
* rows keep NULL (plaintext) and are converted on their next save.
|
|
59
|
+
*/
|
|
60
|
+
export const addConnectionSecretFields: Migration = {
|
|
61
|
+
version: 188,
|
|
62
|
+
name: "add_connection_secret_fields",
|
|
63
|
+
up: async (client: PoolClient) => {
|
|
64
|
+
await client.query(`
|
|
65
|
+
ALTER TABLE lattice_connection_configs
|
|
66
|
+
ADD COLUMN IF NOT EXISTS secret_fields JSONB
|
|
67
|
+
`);
|
|
68
|
+
},
|
|
69
|
+
down: async (client: PoolClient) => {
|
|
70
|
+
await client.query(`
|
|
71
|
+
ALTER TABLE lattice_connection_configs
|
|
72
|
+
DROP COLUMN IF EXISTS secret_fields
|
|
73
|
+
`);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { Pool } from "pg";
|
|
6
6
|
import type { ConnectionEntry, ConnectionStore } from "@axiom-lattice/protocols";
|
|
7
7
|
import { v4 as uuid } from "uuid";
|
|
8
|
+
import { encryptConfigFields, decryptConfigFields } from "@axiom-lattice/core";
|
|
8
9
|
|
|
9
10
|
interface ConnectionRow {
|
|
10
11
|
id: string;
|
|
@@ -14,6 +15,7 @@ interface ConnectionRow {
|
|
|
14
15
|
name: string;
|
|
15
16
|
description: string | null;
|
|
16
17
|
config: unknown;
|
|
18
|
+
secret_fields: string[] | string | null;
|
|
17
19
|
status: string;
|
|
18
20
|
last_test_result: unknown;
|
|
19
21
|
metadata: unknown;
|
|
@@ -58,17 +60,21 @@ export class PostgreSQLConnectionStore implements ConnectionStore {
|
|
|
58
60
|
async create(entry: Omit<ConnectionEntry, "id" | "createdAt" | "updatedAt">): Promise<ConnectionEntry> {
|
|
59
61
|
const id = uuid();
|
|
60
62
|
const now = new Date().toISOString();
|
|
63
|
+
const secretFields = entry.secretFields?.length ? entry.secretFields : undefined;
|
|
61
64
|
|
|
62
65
|
await this.db.query(
|
|
63
|
-
`INSERT INTO ${TABLE} (id, tenant_id, type, key, name, description, config, status, created_at, updated_at)
|
|
64
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $
|
|
66
|
+
`INSERT INTO ${TABLE} (id, tenant_id, type, key, name, description, config, secret_fields, status, created_at, updated_at)
|
|
67
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'active', $9::timestamp, $10::timestamp)`,
|
|
65
68
|
[
|
|
66
69
|
id, entry.tenantId, entry.type, entry.key, entry.name,
|
|
67
|
-
entry.description || null,
|
|
70
|
+
entry.description || null,
|
|
71
|
+
JSON.stringify(encryptConfigFields(entry.config, secretFields)),
|
|
72
|
+
secretFields ? JSON.stringify(secretFields) : null,
|
|
73
|
+
now, now,
|
|
68
74
|
],
|
|
69
75
|
);
|
|
70
76
|
|
|
71
|
-
return { id, ...entry, createdAt: now, updatedAt: now };
|
|
77
|
+
return { id, ...entry, secretFields, createdAt: now, updatedAt: now };
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
async update(
|
|
@@ -90,8 +96,13 @@ export class PostgreSQLConnectionStore implements ConnectionStore {
|
|
|
90
96
|
values.push(updates.description || null);
|
|
91
97
|
}
|
|
92
98
|
if (updates.config !== undefined) {
|
|
99
|
+
const secretFields = updates.secretFields?.length
|
|
100
|
+
? updates.secretFields
|
|
101
|
+
: (existing.secretFields?.length ? existing.secretFields : undefined);
|
|
93
102
|
setClauses.push(`config = $${paramIndex++}`);
|
|
94
|
-
values.push(JSON.stringify(updates.config));
|
|
103
|
+
values.push(JSON.stringify(encryptConfigFields(updates.config, secretFields)));
|
|
104
|
+
setClauses.push(`secret_fields = $${paramIndex++}::jsonb`);
|
|
105
|
+
values.push(secretFields ? JSON.stringify(secretFields) : null);
|
|
95
106
|
}
|
|
96
107
|
|
|
97
108
|
if (setClauses.length === 0) return existing;
|
|
@@ -121,6 +132,13 @@ export class PostgreSQLConnectionStore implements ConnectionStore {
|
|
|
121
132
|
}
|
|
122
133
|
|
|
123
134
|
private mapRow(row: ConnectionRow): ConnectionEntry {
|
|
135
|
+
const parsedSecretFields = typeof row.secret_fields === "string"
|
|
136
|
+
? (JSON.parse(row.secret_fields) as unknown)
|
|
137
|
+
: row.secret_fields;
|
|
138
|
+
const secretFields = Array.isArray(parsedSecretFields) && parsedSecretFields.length > 0
|
|
139
|
+
? parsedSecretFields.filter((field): field is string => typeof field === "string")
|
|
140
|
+
: undefined;
|
|
141
|
+
const storedConfig = (typeof row.config === "string" ? JSON.parse(row.config) : row.config) as Record<string, unknown>;
|
|
124
142
|
return {
|
|
125
143
|
id: row.id,
|
|
126
144
|
tenantId: row.tenant_id,
|
|
@@ -128,7 +146,8 @@ export class PostgreSQLConnectionStore implements ConnectionStore {
|
|
|
128
146
|
key: row.key,
|
|
129
147
|
name: row.name,
|
|
130
148
|
description: row.description || undefined,
|
|
131
|
-
config: (
|
|
149
|
+
config: decryptConfigFields(storedConfig, secretFields),
|
|
150
|
+
secretFields,
|
|
132
151
|
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
|
|
133
152
|
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
|
|
134
153
|
};
|