@axiom-lattice/pg-stores 5.0.0 → 5.0.1

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.
@@ -0,0 +1,258 @@
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 type { McpServerConfig } from "@axiom-lattice/protocols";
11
+ import { decrypt } from "@axiom-lattice/core";
12
+ import { PostgreSQLMcpServerConfigStore } from "../stores/PostgreSQLMcpServerConfigStore";
13
+
14
+ const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
15
+ const describeIfDatabase = TEST_DATABASE_URL ? describe : describe.skip;
16
+
17
+ describeIfDatabase("PostgreSQLMcpServerConfigStore integration", () => {
18
+ let store: PostgreSQLMcpServerConfigStore;
19
+ let pool: Pool;
20
+
21
+ beforeAll(async () => {
22
+ store = new PostgreSQLMcpServerConfigStore({
23
+ poolConfig: TEST_DATABASE_URL!,
24
+ autoMigrate: true,
25
+ });
26
+ await store.initialize();
27
+ pool = new Pool({ connectionString: TEST_DATABASE_URL });
28
+ });
29
+
30
+ const tenants = new Set<string>();
31
+
32
+ afterEach(async () => {
33
+ if (tenants.size === 0) {
34
+ return;
35
+ }
36
+ await pool.query(
37
+ "DELETE FROM lattice_mcp_server_configs WHERE tenant_id = ANY($1::text[])",
38
+ [[...tenants]],
39
+ );
40
+ tenants.clear();
41
+ });
42
+
43
+ afterAll(async () => {
44
+ await pool.end();
45
+ await store.dispose();
46
+ });
47
+
48
+ function tenantId(): string {
49
+ const id = `mcp-config-${randomUUID()}`;
50
+ tenants.add(id);
51
+ return id;
52
+ }
53
+
54
+ function config(overrides: Partial<McpServerConfig> = {}): McpServerConfig {
55
+ return {
56
+ name: "filesystem",
57
+ version: "1.0.0",
58
+ transport: "streamable_http",
59
+ url: "https://mcp.example.com/mcp",
60
+ env: { API_TOKEN: "env-secret" },
61
+ headers: { Authorization: "Bearer header-secret" },
62
+ timeout: 5000,
63
+ ...overrides,
64
+ };
65
+ }
66
+
67
+ async function createConfig(tenant: string) {
68
+ const id = randomUUID();
69
+ const key = `key-${randomUUID()}`;
70
+ const entry = await store.createConfig(tenant, id, {
71
+ key,
72
+ name: "Filesystem MCP",
73
+ description: "Filesystem server",
74
+ config: config(),
75
+ selectedTools: ["read_file", "write_file"],
76
+ });
77
+ return { id, key, entry };
78
+ }
79
+
80
+ it("creates and reads a config by id and by key", async () => {
81
+ const tenant = tenantId();
82
+ const { id, key, entry } = await createConfig(tenant);
83
+
84
+ expect(entry).toMatchObject({
85
+ id,
86
+ tenantId: tenant,
87
+ key,
88
+ name: "Filesystem MCP",
89
+ description: "Filesystem server",
90
+ selectedTools: ["read_file", "write_file"],
91
+ isEnvEncrypted: true,
92
+ status: "disconnected",
93
+ });
94
+ expect(entry.config.transport).toBe("streamable_http");
95
+
96
+ const byId = await store.getConfigById(tenant, id);
97
+ expect(byId?.id).toBe(id);
98
+ expect(byId?.config.timeout).toBe(5000);
99
+
100
+ const byKey = await store.getConfigByKey(tenant, key);
101
+ expect(byKey?.id).toBe(id);
102
+ });
103
+
104
+ it("encrypts env and header values at rest and decrypts them on read", async () => {
105
+ const tenant = tenantId();
106
+ const { id } = await createConfig(tenant);
107
+
108
+ const raw = await pool.query<{
109
+ config: { env: Record<string, string>; headers: Record<string, string> };
110
+ is_env_encrypted: boolean;
111
+ }>(
112
+ "SELECT config, is_env_encrypted FROM lattice_mcp_server_configs WHERE tenant_id = $1 AND id = $2",
113
+ [tenant, id],
114
+ );
115
+
116
+ expect(raw.rows[0].is_env_encrypted).toBe(true);
117
+ expect(raw.rows[0].config.env.API_TOKEN).not.toBe("env-secret");
118
+ expect(raw.rows[0].config.headers.Authorization).not.toBe("Bearer header-secret");
119
+ expect(decrypt(raw.rows[0].config.env.API_TOKEN)).toBe("env-secret");
120
+ expect(decrypt(raw.rows[0].config.headers.Authorization)).toBe(
121
+ "Bearer header-secret",
122
+ );
123
+
124
+ const read = await store.getConfigById(tenant, id);
125
+ expect(read?.config.env).toEqual({ API_TOKEN: "env-secret" });
126
+ expect(read?.config.headers).toEqual({
127
+ Authorization: "Bearer header-secret",
128
+ });
129
+ });
130
+
131
+ it("lists a tenant's configs and includes them in the cross-tenant listing", async () => {
132
+ const tenant = tenantId();
133
+ const first = await createConfig(tenant);
134
+ const second = await createConfig(tenant);
135
+
136
+ const listed = await store.getAllConfigs(tenant);
137
+ expect(listed.map((entry) => entry.id).sort()).toEqual([first.id, second.id].sort());
138
+
139
+ const all = await store.getAllConfigsWithoutTenant();
140
+ const ids = all.map((entry) => entry.id);
141
+ expect(ids).toContain(first.id);
142
+ expect(ids).toContain(second.id);
143
+ expect(all.find((entry) => entry.id === first.id)?.config.env).toEqual({
144
+ API_TOKEN: "env-secret",
145
+ });
146
+ });
147
+
148
+ it("updates fields, selected tools, status, and config secrets", async () => {
149
+ const tenant = tenantId();
150
+ const { id, key } = await createConfig(tenant);
151
+
152
+ const updated = await store.updateConfig(tenant, id, {
153
+ name: "Renamed",
154
+ description: "Updated description",
155
+ selectedTools: ["read_file"],
156
+ status: "connected",
157
+ config: config({
158
+ env: { API_TOKEN: "rotated-env-secret" },
159
+ headers: { Authorization: "Bearer rotated-header-secret" },
160
+ }),
161
+ });
162
+
163
+ expect(updated).toMatchObject({
164
+ id,
165
+ key,
166
+ name: "Renamed",
167
+ description: "Updated description",
168
+ selectedTools: ["read_file"],
169
+ status: "connected",
170
+ });
171
+ expect(updated?.config.env).toEqual({ API_TOKEN: "rotated-env-secret" });
172
+ expect(updated?.config.headers).toEqual({
173
+ Authorization: "Bearer rotated-header-secret",
174
+ });
175
+
176
+ // The rewritten secrets are re-encrypted at rest, not stored as plaintext.
177
+ const raw = await pool.query<{
178
+ config: { env: Record<string, string>; headers: Record<string, string> };
179
+ is_env_encrypted: boolean;
180
+ }>(
181
+ "SELECT config, is_env_encrypted FROM lattice_mcp_server_configs WHERE tenant_id = $1 AND id = $2",
182
+ [tenant, id],
183
+ );
184
+ expect(raw.rows[0].is_env_encrypted).toBe(true);
185
+ expect(raw.rows[0].config.env.API_TOKEN).not.toBe("rotated-env-secret");
186
+ expect(raw.rows[0].config.headers.Authorization).not.toBe(
187
+ "Bearer rotated-header-secret",
188
+ );
189
+ expect(decrypt(raw.rows[0].config.env.API_TOKEN)).toBe("rotated-env-secret");
190
+ expect(decrypt(raw.rows[0].config.headers.Authorization)).toBe(
191
+ "Bearer rotated-header-secret",
192
+ );
193
+ });
194
+
195
+ it("keeps encrypted env and headers when a partial update omits config", async () => {
196
+ const tenant = tenantId();
197
+ const { id } = await createConfig(tenant);
198
+
199
+ const updated = await store.updateConfig(tenant, id, { name: "Only renamed" });
200
+
201
+ expect(updated?.name).toBe("Only renamed");
202
+ expect(updated?.config.env).toEqual({ API_TOKEN: "env-secret" });
203
+ expect(updated?.config.headers).toEqual({
204
+ Authorization: "Bearer header-secret",
205
+ });
206
+
207
+ const raw = await pool.query<{
208
+ config: { env: Record<string, string>; headers: Record<string, string> };
209
+ is_env_encrypted: boolean;
210
+ }>(
211
+ "SELECT config, is_env_encrypted FROM lattice_mcp_server_configs WHERE tenant_id = $1 AND id = $2",
212
+ [tenant, id],
213
+ );
214
+ expect(raw.rows[0].is_env_encrypted).toBe(true);
215
+ expect(raw.rows[0].config.env.API_TOKEN).not.toBe("env-secret");
216
+ expect(decrypt(raw.rows[0].config.env.API_TOKEN)).toBe("env-secret");
217
+ expect(decrypt(raw.rows[0].config.headers.Authorization)).toBe(
218
+ "Bearer header-secret",
219
+ );
220
+ });
221
+
222
+ it("reports has and deletes a config", async () => {
223
+ const tenant = tenantId();
224
+ const { id } = await createConfig(tenant);
225
+
226
+ expect(await store.hasConfig(tenant, id)).toBe(true);
227
+ expect(await store.deleteConfig(tenant, id)).toBe(true);
228
+ expect(await store.hasConfig(tenant, id)).toBe(false);
229
+ expect(await store.getConfigById(tenant, id)).toBeNull();
230
+ expect(await store.deleteConfig(tenant, id)).toBe(false);
231
+ });
232
+
233
+ it("isolates configs by tenant", async () => {
234
+ const tenantA = tenantId();
235
+ const tenantB = tenantId();
236
+ const key = `shared-${randomUUID()}`;
237
+ const idA = randomUUID();
238
+ const idB = randomUUID();
239
+
240
+ await store.createConfig(tenantA, idA, { key, config: config() });
241
+ await store.createConfig(tenantB, idB, { key, config: config() });
242
+
243
+ expect(await store.getConfigById(tenantA, idB)).toBeNull();
244
+ expect((await store.getConfigByKey(tenantA, key))?.id).toBe(idA);
245
+ expect((await store.getAllConfigs(tenantA)).map((entry) => entry.id)).toEqual([idA]);
246
+ });
247
+
248
+ it("returns absent results for an unknown config", async () => {
249
+ const tenant = tenantId();
250
+ const missing = randomUUID();
251
+
252
+ expect(await store.getConfigById(tenant, missing)).toBeNull();
253
+ expect(await store.getConfigByKey(tenant, "missing")).toBeNull();
254
+ expect(await store.updateConfig(tenant, missing, { name: "x" })).toBeNull();
255
+ expect(await store.hasConfig(tenant, missing)).toBe(false);
256
+ expect(await store.deleteConfig(tenant, missing)).toBe(false);
257
+ });
258
+ });
@@ -0,0 +1,216 @@
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 type { MetricsServerConfig } from "@axiom-lattice/protocols";
11
+ import { decrypt } from "@axiom-lattice/core";
12
+ import { PostgreSQLMetricsServerConfigStore } from "../stores/PostgreSQLMetricsServerConfigStore";
13
+
14
+ const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
15
+ const describeIfDatabase = TEST_DATABASE_URL ? describe : describe.skip;
16
+
17
+ describeIfDatabase("PostgreSQLMetricsServerConfigStore integration", () => {
18
+ let store: PostgreSQLMetricsServerConfigStore;
19
+ let pool: Pool;
20
+
21
+ beforeAll(async () => {
22
+ store = new PostgreSQLMetricsServerConfigStore({
23
+ poolConfig: TEST_DATABASE_URL!,
24
+ autoMigrate: true,
25
+ });
26
+ await store.initialize();
27
+ pool = new Pool({ connectionString: TEST_DATABASE_URL });
28
+ });
29
+
30
+ const tenants = new Set<string>();
31
+
32
+ afterEach(async () => {
33
+ if (tenants.size === 0) {
34
+ return;
35
+ }
36
+ await pool.query(
37
+ "DELETE FROM lattice_metrics_configs WHERE tenant_id = ANY($1::text[])",
38
+ [[...tenants]],
39
+ );
40
+ tenants.clear();
41
+ });
42
+
43
+ afterAll(async () => {
44
+ await pool.end();
45
+ await store.dispose();
46
+ });
47
+
48
+ function tenantId(): string {
49
+ const id = `metrics-config-${randomUUID()}`;
50
+ tenants.add(id);
51
+ return id;
52
+ }
53
+
54
+ function config(overrides: Partial<MetricsServerConfig> = {}): MetricsServerConfig {
55
+ return {
56
+ type: "prometheus",
57
+ serverUrl: "https://metrics.example.com",
58
+ apiKey: "metrics-api-key",
59
+ username: "metrics-user",
60
+ password: "metrics-password",
61
+ timeout: 15000,
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ async function createConfig(tenant: string) {
67
+ const id = randomUUID();
68
+ const key = `key-${randomUUID()}`;
69
+ const entry = await store.createConfig(tenant, id, {
70
+ key,
71
+ name: "Prometheus",
72
+ description: "Primary metrics server",
73
+ config: config(),
74
+ });
75
+ return { id, key, entry };
76
+ }
77
+
78
+ it("creates and reads a config by id and by key", async () => {
79
+ const tenant = tenantId();
80
+ const { id, key, entry } = await createConfig(tenant);
81
+
82
+ expect(entry).toMatchObject({
83
+ id,
84
+ tenantId: tenant,
85
+ key,
86
+ name: "Prometheus",
87
+ description: "Primary metrics server",
88
+ });
89
+ expect(entry.config.serverUrl).toBe("https://metrics.example.com");
90
+
91
+ const byId = await store.getConfigById(tenant, id);
92
+ expect(byId?.id).toBe(id);
93
+ expect(byId?.config.timeout).toBe(15000);
94
+
95
+ const byKey = await store.getConfigByKey(tenant, key);
96
+ expect(byKey?.id).toBe(id);
97
+ });
98
+
99
+ it("encrypts apiKey and password at rest and decrypts them on read", async () => {
100
+ const tenant = tenantId();
101
+ const { id } = await createConfig(tenant);
102
+
103
+ const raw = await pool.query<{ config: { apiKey: string; password: string } }>(
104
+ "SELECT config FROM lattice_metrics_configs WHERE tenant_id = $1 AND id = $2",
105
+ [tenant, id],
106
+ );
107
+
108
+ expect(raw.rows[0].config.apiKey).not.toBe("metrics-api-key");
109
+ expect(raw.rows[0].config.password).not.toBe("metrics-password");
110
+ expect(decrypt(raw.rows[0].config.apiKey)).toBe("metrics-api-key");
111
+ expect(decrypt(raw.rows[0].config.password)).toBe("metrics-password");
112
+
113
+ const read = await store.getConfigById(tenant, id);
114
+ expect(read?.config.apiKey).toBe("metrics-api-key");
115
+ expect(read?.config.password).toBe("metrics-password");
116
+ });
117
+
118
+ it("lists a tenant's configs and includes them in the cross-tenant listing", async () => {
119
+ const tenant = tenantId();
120
+ const first = await createConfig(tenant);
121
+ const second = await createConfig(tenant);
122
+
123
+ const listed = await store.getAllConfigs(tenant);
124
+ expect(listed.map((entry) => entry.id).sort()).toEqual([first.id, second.id].sort());
125
+
126
+ const all = await store.getAllConfigsWithoutTenant();
127
+ const ids = all.map((entry) => entry.id);
128
+ expect(ids).toContain(first.id);
129
+ expect(ids).toContain(second.id);
130
+ expect(all.find((entry) => entry.id === first.id)?.config.apiKey).toBe("metrics-api-key");
131
+ });
132
+
133
+ it("updates fields and re-encrypts rotated secrets", async () => {
134
+ const tenant = tenantId();
135
+ const { id, key } = await createConfig(tenant);
136
+
137
+ const updated = await store.updateConfig(tenant, id, {
138
+ name: "Renamed",
139
+ description: "Updated description",
140
+ config: config({ apiKey: "rotated-api-key", password: "rotated-password" }),
141
+ });
142
+
143
+ expect(updated).toMatchObject({
144
+ id,
145
+ key,
146
+ name: "Renamed",
147
+ description: "Updated description",
148
+ });
149
+ expect(updated?.config.apiKey).toBe("rotated-api-key");
150
+ expect(updated?.config.password).toBe("rotated-password");
151
+
152
+ const raw = await pool.query<{ config: { apiKey: string; password: string } }>(
153
+ "SELECT config FROM lattice_metrics_configs WHERE tenant_id = $1 AND id = $2",
154
+ [tenant, id],
155
+ );
156
+ expect(decrypt(raw.rows[0].config.apiKey)).toBe("rotated-api-key");
157
+ expect(decrypt(raw.rows[0].config.password)).toBe("rotated-password");
158
+ });
159
+
160
+ it("keeps encrypted secrets when a partial update omits config", async () => {
161
+ const tenant = tenantId();
162
+ const { id } = await createConfig(tenant);
163
+
164
+ const updated = await store.updateConfig(tenant, id, { name: "Only renamed" });
165
+
166
+ expect(updated?.name).toBe("Only renamed");
167
+ expect(updated?.config.apiKey).toBe("metrics-api-key");
168
+ expect(updated?.config.password).toBe("metrics-password");
169
+
170
+ const raw = await pool.query<{ config: { apiKey: string; password: string } }>(
171
+ "SELECT config FROM lattice_metrics_configs WHERE tenant_id = $1 AND id = $2",
172
+ [tenant, id],
173
+ );
174
+ expect(raw.rows[0].config.apiKey).not.toBe("metrics-api-key");
175
+ expect(raw.rows[0].config.password).not.toBe("metrics-password");
176
+ expect(decrypt(raw.rows[0].config.apiKey)).toBe("metrics-api-key");
177
+ expect(decrypt(raw.rows[0].config.password)).toBe("metrics-password");
178
+ });
179
+
180
+ it("reports has and deletes a config", async () => {
181
+ const tenant = tenantId();
182
+ const { id } = await createConfig(tenant);
183
+
184
+ expect(await store.hasConfig(tenant, id)).toBe(true);
185
+ expect(await store.deleteConfig(tenant, id)).toBe(true);
186
+ expect(await store.hasConfig(tenant, id)).toBe(false);
187
+ expect(await store.getConfigById(tenant, id)).toBeNull();
188
+ expect(await store.deleteConfig(tenant, id)).toBe(false);
189
+ });
190
+
191
+ it("isolates configs by tenant", async () => {
192
+ const tenantA = tenantId();
193
+ const tenantB = tenantId();
194
+ const key = `shared-${randomUUID()}`;
195
+ const idA = randomUUID();
196
+ const idB = randomUUID();
197
+
198
+ await store.createConfig(tenantA, idA, { key, config: config() });
199
+ await store.createConfig(tenantB, idB, { key, config: config() });
200
+
201
+ expect(await store.getConfigById(tenantA, idB)).toBeNull();
202
+ expect((await store.getConfigByKey(tenantA, key))?.id).toBe(idA);
203
+ expect((await store.getAllConfigs(tenantA)).map((entry) => entry.id)).toEqual([idA]);
204
+ });
205
+
206
+ it("returns absent results for an unknown config", async () => {
207
+ const tenant = tenantId();
208
+ const missing = randomUUID();
209
+
210
+ expect(await store.getConfigById(tenant, missing)).toBeNull();
211
+ expect(await store.getConfigByKey(tenant, "missing")).toBeNull();
212
+ expect(await store.updateConfig(tenant, missing, { name: "x" })).toBeNull();
213
+ expect(await store.hasConfig(tenant, missing)).toBe(false);
214
+ expect(await store.deleteConfig(tenant, missing)).toBe(false);
215
+ });
216
+ });