@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/pg-stores",
3
- "version": "3.3.1",
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.1",
29
- "@axiom-lattice/protocols": "4.7.0"
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', $8::timestamp, $9::timestamp)`,
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, JSON.stringify(entry.config), now, now,
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: (typeof row.config === "string" ? JSON.parse(row.config) : row.config) as Record<string, unknown>,
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
  };