@axiom-lattice/pg-stores 3.3.5 → 3.3.6

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.5",
3
+ "version": "3.3.6",
4
4
  "description": "PG stores implementation for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -25,7 +25,7 @@
25
25
  "@langchain/core": "1.1.30",
26
26
  "pg": "^8.16.3",
27
27
  "uuid": "^9.0.1",
28
- "@axiom-lattice/core": "6.4.1",
28
+ "@axiom-lattice/core": "7.0.0",
29
29
  "@axiom-lattice/protocols": "4.8.0"
30
30
  },
31
31
  "devDependencies": {
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Verification for the model-provider migrations against a real PostgreSQL.
3
+ *
4
+ * Not a jest test: the repo's PG store suites are skipped without a database,
5
+ * and a migration's whole risk is what its SQL does to a real server — a
6
+ * column's nullability, its default, and whether an ALTER leaves a populated
7
+ * table intact.
8
+ *
9
+ * Deliberately named for the feature rather than a version number: the `extra`
10
+ * column was renumbered once already (v185 → v189) when a rebase collided with
11
+ * the distributed-lease migration. The script itself is version-agnostic — it
12
+ * drives the imported migration objects, never a hardcoded number.
13
+ *
14
+ * Usage:
15
+ * VERIFY_PG_URL=postgresql://postgres@localhost:5433/postgres \
16
+ * node_modules/.bin/tsx scripts/verify-model-provider-migrations.ts
17
+ */
18
+
19
+ import { Pool } from "pg";
20
+ import { MigrationManager } from "../src/migrations/migration";
21
+ import {
22
+ createModelProvidersTable,
23
+ createModelProviderModelsTable,
24
+ addModelProviderApiStyle,
25
+ addModelProviderModelStale,
26
+ addModelProviderModelExtra,
27
+ addModelProviderModelDefault,
28
+ } from "../src/migrations/model_provider_migrations";
29
+
30
+ const CONNECTION = process.env.VERIFY_PG_URL;
31
+ if (!CONNECTION) {
32
+ throw new Error("Set VERIFY_PG_URL to a throwaway PostgreSQL.");
33
+ }
34
+
35
+ /** Every model-provider migration, in order. */
36
+ const ALL = [
37
+ createModelProvidersTable,
38
+ createModelProviderModelsTable,
39
+ addModelProviderApiStyle,
40
+ addModelProviderModelStale,
41
+ addModelProviderModelExtra,
42
+ addModelProviderModelDefault,
43
+ ];
44
+
45
+ let failures = 0;
46
+
47
+ function check(label: string, actual: unknown, expected: unknown): void {
48
+ const ok = JSON.stringify(actual) === JSON.stringify(expected);
49
+ if (!ok) {
50
+ failures += 1;
51
+ }
52
+ console.log(
53
+ `${ok ? " ok " : " FAIL"} ${label}${
54
+ ok
55
+ ? ""
56
+ : ` (got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)})`
57
+ }`
58
+ );
59
+ }
60
+
61
+ /** A clean database, connected as a pool. */
62
+ async function freshDatabase(name: string): Promise<Pool> {
63
+ const admin = new Pool({ connectionString: CONNECTION });
64
+ await admin.query(`DROP DATABASE IF EXISTS ${name}`);
65
+ await admin.query(`CREATE DATABASE ${name}`);
66
+ await admin.end();
67
+
68
+ const url = new URL(CONNECTION!);
69
+ url.pathname = `/${name}`;
70
+ return new Pool({ connectionString: url.toString() });
71
+ }
72
+
73
+ /** The columns the model table actually has on this database. */
74
+ async function modelColumns(pool: Pool): Promise<string[]> {
75
+ const result = await pool.query<{ column_name: string }>(
76
+ `SELECT column_name FROM information_schema.columns
77
+ WHERE table_name = 'lattice_model_provider_models'
78
+ ORDER BY column_name`
79
+ );
80
+ return result.rows.map((row) => row.column_name);
81
+ }
82
+
83
+ async function main(): Promise<void> {
84
+ // -------------------------------------------------------------------
85
+ // A. The store's old registration list really did leave columns out.
86
+ // -------------------------------------------------------------------
87
+ console.log("\nA. store list as it was (v181+v182 only)");
88
+ const oldPool = await freshDatabase("v185_old");
89
+ const oldManager = new MigrationManager(oldPool);
90
+ oldManager.register(createModelProvidersTable);
91
+ oldManager.register(createModelProviderModelsTable);
92
+ await oldManager.migrate();
93
+
94
+ const oldColumns = await modelColumns(oldPool);
95
+ check("`stale` absent — the R3 bug was real", oldColumns.includes("stale"), false);
96
+ check("`extra` absent", oldColumns.includes("extra"), false);
97
+ await oldPool.end();
98
+
99
+ // -------------------------------------------------------------------
100
+ // B. The fixed registration list produces a fully-formed table.
101
+ // -------------------------------------------------------------------
102
+ console.log("\nB. store list as it is now (v181..v185)");
103
+ const newPool = await freshDatabase("v185_new");
104
+ const newManager = new MigrationManager(newPool);
105
+ for (const migration of ALL) {
106
+ newManager.register(migration);
107
+ }
108
+ await newManager.migrate();
109
+
110
+ const newColumns = await modelColumns(newPool);
111
+ check("`stale` present", newColumns.includes("stale"), true);
112
+ check("`extra` present", newColumns.includes("extra"), true);
113
+ await newPool.end();
114
+
115
+ // -------------------------------------------------------------------
116
+ // C. Upgrading an already-populated table needs no backfill.
117
+ // -------------------------------------------------------------------
118
+ console.log("\nC. upgrade path over existing rows (creates, then every ALTER)");
119
+ const upgradePool = await freshDatabase("v185_upgrade");
120
+ const before = new MigrationManager(upgradePool);
121
+ for (const migration of ALL.slice(0, 4)) {
122
+ before.register(migration);
123
+ }
124
+ await before.migrate();
125
+
126
+ await upgradePool.query(
127
+ `INSERT INTO lattice_model_provider_models
128
+ (id, tenant_id, provider_id, model_id, enabled, stale)
129
+ VALUES ('p:gpt-4o', 't1', 'p', 'gpt-4o', TRUE, FALSE)`
130
+ );
131
+
132
+ const after = new MigrationManager(upgradePool);
133
+ for (const migration of ALL) {
134
+ after.register(migration);
135
+ }
136
+ await after.migrate();
137
+
138
+ const row = await upgradePool.query<{
139
+ extra: unknown;
140
+ enabled: boolean;
141
+ is_default: boolean;
142
+ }>(
143
+ `SELECT extra, enabled, is_default FROM lattice_model_provider_models
144
+ WHERE tenant_id = 't1' AND provider_id = 'p' AND model_id = 'gpt-4o'`
145
+ );
146
+ check("the pre-existing row survived", row.rows.length, 1);
147
+ check("it defaulted to an empty object", row.rows[0]?.extra, {});
148
+ // The default column arrives FALSE for existing rows, which is what keeps
149
+ // every legacy tenant on the deployment's default with no backfill.
150
+ check("it is not a default", row.rows[0]?.is_default, false);
151
+ // The ALTER must not have disturbed anything else on the table.
152
+ check("its other columns are untouched", row.rows[0]?.enabled, true);
153
+
154
+ // The partial unique index only came into existence with this migration, so
155
+ // assert it here rather than assuming.
156
+ const index = await upgradePool.query<{ indexdef: string }>(
157
+ `SELECT indexdef FROM pg_indexes
158
+ WHERE tablename = 'lattice_model_provider_models'
159
+ AND indexname = 'uk_lattice_model_provider_models_tenant_default'`
160
+ );
161
+ check("the one-default-per-tenant index exists", index.rows.length, 1);
162
+ check(
163
+ "…and is partial",
164
+ index.rows[0]?.indexdef.includes("WHERE is_default"),
165
+ true
166
+ );
167
+
168
+ // -------------------------------------------------------------------
169
+ // D. Re-running is a no-op, and jsonb round-trips.
170
+ // -------------------------------------------------------------------
171
+ console.log("\nD. idempotency and round-trip");
172
+ const rerun = new MigrationManager(upgradePool);
173
+ for (const migration of ALL) {
174
+ rerun.register(migration);
175
+ }
176
+ await rerun.migrate();
177
+
178
+ await upgradePool.query(
179
+ `INSERT INTO lattice_model_provider_models
180
+ (id, tenant_id, provider_id, model_id, enabled, extra)
181
+ VALUES ('p:o3', 't1', 'p', 'o3', TRUE, '{"temperature":0.2}'::jsonb)`
182
+ );
183
+ const roundTrip = await upgradePool.query<{ extra: unknown }>(
184
+ `SELECT extra FROM lattice_model_provider_models WHERE model_id = 'o3'`
185
+ );
186
+ check("jsonb round-trips as an object", roundTrip.rows[0]?.extra, {
187
+ temperature: 0.2,
188
+ });
189
+
190
+ // The NOT NULL constraint is what lets the store's reads skip a null check.
191
+ let rejected = false;
192
+ try {
193
+ await upgradePool.query(
194
+ `INSERT INTO lattice_model_provider_models
195
+ (id, tenant_id, provider_id, model_id, enabled, extra)
196
+ VALUES ('p:null', 't1', 'p', 'null-extra', TRUE, NULL)`
197
+ );
198
+ } catch {
199
+ rejected = true;
200
+ }
201
+ check("extra rejects NULL", rejected, true);
202
+
203
+ await upgradePool.end();
204
+
205
+ console.log(
206
+ failures === 0 ? "\nALL CHECKS PASSED" : `\n${failures} CHECK(S) FAILED`
207
+ );
208
+ process.exit(failures === 0 ? 0 : 1);
209
+ }
210
+
211
+ main().catch((error) => {
212
+ console.error(error);
213
+ process.exit(1);
214
+ });
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Throwaway end-to-end check for PostgreSQLModelProviderStore on a real server.
3
+ *
4
+ * Exercises what a jest run cannot: the store migrating itself (the R3 fix),
5
+ * then writing and reading a model's `extra` through the unnest-based bulk
6
+ * upsert.
7
+ *
8
+ * Usage:
9
+ * VERIFY_PG_URL=postgresql://postgres@localhost:5433/postgres \
10
+ * node_modules/.bin/tsx scripts/verify-store.ts
11
+ */
12
+
13
+ import { Pool } from "pg";
14
+ import { PostgreSQLModelProviderStore } from "../src/stores/PostgreSQLModelProviderStore";
15
+
16
+ const CONNECTION = process.env.VERIFY_PG_URL;
17
+ if (!CONNECTION) {
18
+ throw new Error("Set VERIFY_PG_URL to a throwaway PostgreSQL.");
19
+ }
20
+
21
+ const DB = "v185_store";
22
+
23
+ let failures = 0;
24
+
25
+ /**
26
+ * Compare structurally rather than by key order: jsonb reorders keys, so a
27
+ * JSON string comparison reports spurious differences.
28
+ */
29
+ function stable(value: unknown): string {
30
+ if (value === null || typeof value !== "object") {
31
+ return JSON.stringify(value);
32
+ }
33
+ if (Array.isArray(value)) {
34
+ return `[${value.map(stable).join(",")}]`;
35
+ }
36
+ const entries = Object.entries(value as Record<string, unknown>)
37
+ .sort(([a], [b]) => a.localeCompare(b))
38
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stable(entry)}`);
39
+ return `{${entries.join(",")}}`;
40
+ }
41
+
42
+ function check(label: string, actual: unknown, expected: unknown): void {
43
+ const ok = stable(actual) === stable(expected);
44
+ if (!ok) {
45
+ failures += 1;
46
+ }
47
+ console.log(
48
+ `${ok ? " ok " : " FAIL"} ${label}${
49
+ ok
50
+ ? ""
51
+ : ` (got ${stable(actual)}, want ${stable(expected)})`
52
+ }`
53
+ );
54
+ }
55
+
56
+ async function main(): Promise<void> {
57
+ const admin = new Pool({ connectionString: CONNECTION });
58
+ await admin.query(`DROP DATABASE IF EXISTS ${DB}`);
59
+ await admin.query(`CREATE DATABASE ${DB}`);
60
+ await admin.end();
61
+
62
+ const url = new URL(CONNECTION!);
63
+ url.pathname = `/${DB}`;
64
+
65
+ // Standalone construction: this is the path whose registration list was
66
+ // short by v183/v184, and it migrates itself here.
67
+ const store = new PostgreSQLModelProviderStore({
68
+ poolConfig: url.toString(),
69
+ autoMigrate: true,
70
+ });
71
+
72
+ const provider = await store.createProvider("t1", "prov-1", {
73
+ name: "ollama",
74
+ protocol: "openai-compatible",
75
+ baseURL: "http://localhost:11434/v1",
76
+ // Keyless: the whole point of phase 1.5.
77
+ });
78
+ check("a provider with no key is stored", provider.apiKey, undefined);
79
+
80
+ await store.replaceProviderModels("t1", "prov-1", [
81
+ { id: "llama3", enabled: true, extra: { supportsVision: false, temperature: 0.2 } },
82
+ { id: "Qwen/Qwen2.5-7B-Instruct", enabled: true },
83
+ ]);
84
+
85
+ const models = await store.listModels("t1", "prov-1");
86
+ const byId = new Map(models.map((model) => [model.modelId, model]));
87
+
88
+ check("the row with an upstream slash survives", byId.has("Qwen/Qwen2.5-7B-Instruct"), true);
89
+ check("extra round-trips", byId.get("llama3")?.extra, {
90
+ supportsVision: false,
91
+ temperature: 0.2,
92
+ });
93
+ check("a row with no config reads back empty", byId.get("Qwen/Qwen2.5-7B-Instruct")?.extra, undefined);
94
+
95
+ // A sync omits `extra`; the stored config must survive it.
96
+ await store.replaceProviderModels("t1", "prov-1", [
97
+ { id: "llama3", enabled: true },
98
+ { id: "Qwen/Qwen2.5-7B-Instruct", enabled: true },
99
+ ]);
100
+ const afterSync = await store.listModels("t1", "prov-1");
101
+ check(
102
+ "an omitted extra keeps its stored value",
103
+ afterSync.find((model) => model.modelId === "llama3")?.extra,
104
+ { supportsVision: false, temperature: 0.2 }
105
+ );
106
+
107
+ // An explicit empty object is how the UI clears a config. It reads back as
108
+ // absent, which is what the store's `extra?:` contract means by "no config".
109
+ await store.replaceProviderModels("t1", "prov-1", [
110
+ { id: "llama3", enabled: true, extra: {} },
111
+ { id: "Qwen/Qwen2.5-7B-Instruct", enabled: true },
112
+ ]);
113
+ const afterClear = await store.listModels("t1", "prov-1");
114
+ check(
115
+ "an explicit {} clears the config",
116
+ afterClear.find((model) => model.modelId === "llama3")?.extra,
117
+ undefined
118
+ );
119
+
120
+ // The stale flag must still work — it was the column the old list omitted.
121
+ await store.replaceProviderModels("t1", "prov-1", [
122
+ { id: "llama3", enabled: false, extra: {} },
123
+ ]);
124
+ const afterDrop = await store.listModels("t1", "prov-1");
125
+ check("dropping a model removes its row", afterDrop.length, 1);
126
+
127
+ // ── The tenant's default model ─────────────────────────────────────────
128
+ //
129
+ // The flag is per tenant and enforced by a partial unique index. Three
130
+ // failures a unit test cannot show: the index rejects a legitimate swap
131
+ // depending on row order, does not reject a second default at all, or the
132
+ // wholesale replace takes the default with it.
133
+ console.log("\nE. the tenant's default model");
134
+
135
+ await store.createProvider("t1", "prov-2", {
136
+ name: "openai",
137
+ protocol: "openai-compatible",
138
+ baseURL: "https://93.184.216.34/v1",
139
+ apiKey: "sk-default",
140
+ });
141
+ await store.replaceProviderModels("t1", "prov-2", [
142
+ { id: "a", enabled: true, isDefault: true },
143
+ { id: "b", enabled: true },
144
+ ]);
145
+
146
+ // The swap, with the incoming default listed *second* — the order the index
147
+ // would trip on without the store's clear-first step.
148
+ await store.replaceProviderModels("t1", "prov-2", [
149
+ { id: "a", enabled: true },
150
+ { id: "b", enabled: true, isDefault: true },
151
+ ]);
152
+ const swapped = await store.listModels("t1", "prov-2");
153
+ check(
154
+ "a swap moves the flag",
155
+ swapped.find((model) => model.modelId === "b")?.isDefault,
156
+ true
157
+ );
158
+ check(
159
+ "…and clears the previous holder",
160
+ swapped.find((model) => model.modelId === "a")?.isDefault,
161
+ false
162
+ );
163
+
164
+ // Bypass the store: two defaults for one tenant must be refused by the
165
+ // database, not merely by convention.
166
+ const raw = new Pool({ connectionString: url.toString() });
167
+ let rejectedSecond = false;
168
+ try {
169
+ await raw.query(
170
+ `INSERT INTO lattice_model_provider_models
171
+ (id, tenant_id, provider_id, model_id, enabled, is_default, discovered_at)
172
+ VALUES ('prov-1:second', 't1', 'prov-1', 'second', TRUE, TRUE, NOW())`
173
+ );
174
+ } catch (error) {
175
+ rejectedSecond = (error as { code?: string }).code === "23505";
176
+ }
177
+ check("the unique index refuses a second default", rejectedSecond, true);
178
+
179
+ // Naming one in a *different* provider clears the first: the index spans the
180
+ // tenant, while the store writes one provider at a time.
181
+ await store.replaceProviderModels("t1", "prov-1", [
182
+ { id: "only", enabled: true, isDefault: true },
183
+ ]);
184
+ const otherProvider = await store.listModels("t1", "prov-2");
185
+ check(
186
+ "naming a default in another provider clears the first",
187
+ otherProvider.find((model) => model.modelId === "b")?.isDefault,
188
+ false
189
+ );
190
+
191
+ // A replace that no longer offers the default — exactly what a discovery
192
+ // pass sends — must not take it away.
193
+ await store.replaceProviderModels("t1", "prov-1", [
194
+ { id: "something-else", enabled: true },
195
+ ]);
196
+ const afterDroppedDefault = await store.listModels("t1", "prov-1");
197
+ const keptDefault = afterDroppedDefault.find(
198
+ (model) => model.modelId === "only"
199
+ );
200
+ check("a replace that drops the default keeps the row", Boolean(keptDefault), true);
201
+ check("…flagged as dropped upstream", keptDefault?.stale, true);
202
+ check("…and still the default", keptDefault?.isDefault, true);
203
+
204
+ // Different tenant, same shape: the index must not couple them.
205
+ await store.createProvider("t2", "prov-1", {
206
+ name: "openai-t2",
207
+ protocol: "openai-compatible",
208
+ baseURL: "https://93.184.216.34/v1",
209
+ apiKey: "sk-t2",
210
+ });
211
+ await store.replaceProviderModels("t2", "prov-1", [
212
+ { id: "only", enabled: true, isDefault: true },
213
+ ]);
214
+ check(
215
+ "another tenant has its own default",
216
+ (await store.listModels("t2", "prov-1"))[0]?.isDefault,
217
+ true
218
+ );
219
+
220
+ await raw.end();
221
+ await store.dispose();
222
+
223
+ console.log(
224
+ failures === 0 ? "\nALL CHECKS PASSED" : `\n${failures} CHECK(S) FAILED`
225
+ );
226
+ process.exit(failures === 0 ? 0 : 1);
227
+ }
228
+
229
+ main().catch((error) => {
230
+ console.error(error);
231
+ process.exit(1);
232
+ });
@@ -12,10 +12,23 @@ import type { Pool } from "pg";
12
12
 
13
13
  const mockQuery = jest.fn();
14
14
 
15
- jest.mock("@axiom-lattice/core", () => ({
16
- encrypt: (value: string) => `enc:${value}`,
17
- decrypt: (value: string) => value.replace(/^enc:/, ""),
18
- }));
15
+ jest.mock("@axiom-lattice/core", () => {
16
+ const decrypt = (value: string): string => {
17
+ if (!value.startsWith("enc:")) throw new Error("Failed to decrypt");
18
+ return value.replace(/^enc:/, "");
19
+ };
20
+ return {
21
+ encrypt: (value: string) => `enc:${value}`,
22
+ decrypt,
23
+ safeDecrypt: (value: string) => {
24
+ try {
25
+ return decrypt(value);
26
+ } catch {
27
+ return "";
28
+ }
29
+ },
30
+ };
31
+ });
19
32
 
20
33
  import { PostgreSQLA2AApiKeyStore } from "../stores/PostgreSQLA2AApiKeyStore";
21
34
 
@@ -107,6 +120,30 @@ describe("PostgreSQLA2AApiKeyStore", () => {
107
120
  expect(record?.tenantId).toBe("t1");
108
121
  });
109
122
 
123
+ it("list returns an undecryptable key with an empty value instead of throwing", async () => {
124
+ const store = new PostgreSQLA2AApiKeyStore({ pool });
125
+ mockQuery.mockResolvedValueOnce({
126
+ rows: [{
127
+ id: "k1",
128
+ key_value: "plaintext-legacy-key",
129
+ tenant_id: "t1",
130
+ project_id: "p1",
131
+ assistant_ids: null,
132
+ grants: null,
133
+ label: null,
134
+ enabled: true,
135
+ created_at: new Date("2026-01-01T00:00:00.000Z"),
136
+ updated_at: new Date("2026-01-01T00:00:00.000Z"),
137
+ }],
138
+ });
139
+
140
+ const records = await store.list({ tenantId: "t1" });
141
+
142
+ expect(records).toHaveLength(1);
143
+ expect(records[0].key).toBe("");
144
+ expect(records[0].projectId).toBe("p1");
145
+ });
146
+
110
147
  it("loadIntoMap projects assistantIds and omits workspaceId", async () => {
111
148
  const store = new PostgreSQLA2AApiKeyStore({ pool });
112
149
  mockQuery.mockResolvedValueOnce({
@@ -9,10 +9,23 @@ jest.mock("pg", () => ({
9
9
  })),
10
10
  }));
11
11
 
12
- jest.mock("@axiom-lattice/core", () => ({
13
- encrypt: (value: string) => `enc:${value}`,
14
- decrypt: (value: string) => value.replace(/^enc:/, ""),
15
- }));
12
+ jest.mock("@axiom-lattice/core", () => {
13
+ const decrypt = (value: string): string => {
14
+ if (!value.startsWith("enc:")) throw new Error("Failed to decrypt");
15
+ return value.replace(/^enc:/, "");
16
+ };
17
+ return {
18
+ encrypt: (value: string) => `enc:${value}`,
19
+ decrypt,
20
+ safeDecrypt: (value: string) => {
21
+ try {
22
+ return decrypt(value);
23
+ } catch {
24
+ return "";
25
+ }
26
+ },
27
+ };
28
+ });
16
29
 
17
30
  describe("PostgreSQLChannelInstallationStore", () => {
18
31
  beforeEach(() => {
@@ -68,6 +81,37 @@ describe("PostgreSQLChannelInstallationStore", () => {
68
81
  expect(installation?.config.verificationToken).toBe("token-1");
69
82
  });
70
83
 
84
+ it("empties an undecryptable secret but still returns the installation", async () => {
85
+ const store = new PostgreSQLChannelInstallationStore({
86
+ pool: { query: mockQuery } as unknown as Pool,
87
+ });
88
+
89
+ mockQuery.mockResolvedValueOnce({
90
+ rows: [
91
+ {
92
+ id: "install-2",
93
+ tenant_id: "tenant-a",
94
+ channel: "lark",
95
+ name: "Tenant A Lark",
96
+ config: {
97
+ appId: "cli_app_1",
98
+ appSecret: "plaintext-legacy-secret",
99
+ verificationToken: "enc:token-1",
100
+ assistantId: "assistant-a",
101
+ },
102
+ created_at: new Date("2026-04-10T00:00:00.000Z"),
103
+ updated_at: new Date("2026-04-10T00:00:00.000Z"),
104
+ },
105
+ ],
106
+ });
107
+
108
+ const installation = await store.getInstallationById("install-2");
109
+
110
+ expect(installation?.config.appSecret).toBe("");
111
+ expect(installation?.config.verificationToken).toBe("token-1");
112
+ expect(installation?.config.appId).toBe("cli_app_1");
113
+ });
114
+
71
115
  it("rejects a duplicate id without changing the original installation", async () => {
72
116
  const store = new PostgreSQLChannelInstallationStore({
73
117
  pool: { query: mockQuery } as unknown as Pool,
@@ -11,8 +11,9 @@ import { PostgreSQLConnectionStore } from "../stores/PostgreSQLConnectionStore";
11
11
  import {
12
12
  createConnectionConfigsTable,
13
13
  addConnectionSecretFields,
14
+ reencryptLegacyConnectionSecrets,
14
15
  } from "../migrations/connection_config_migrations";
15
- import { decrypt } from "@axiom-lattice/core";
16
+ import { clearEncryptionKeyCache, decrypt } from "@axiom-lattice/core";
16
17
 
17
18
  const databaseUrl = process.env.TEST_DATABASE_URL;
18
19
  const describeIfDatabase = databaseUrl ? describe : describe.skip;
@@ -103,4 +104,62 @@ describeIfDatabase("PostgreSQLConnectionStore secret field encryption", () => {
103
104
  expect(read?.config.apiKey).toBe("sk-legacy");
104
105
  expect(read?.secretFields).toBeUndefined();
105
106
  });
107
+
108
+ it("empties an undecryptable secret field but still lists the connection", async () => {
109
+ const previousKey = process.env.LATTICE_ENCRYPTION_KEY;
110
+ try {
111
+ process.env.LATTICE_ENCRYPTION_KEY = "conn-key-one";
112
+ clearEncryptionKeyCache();
113
+ await store.create({
114
+ tenantId,
115
+ type,
116
+ key: "rotated",
117
+ name: "Rotated",
118
+ config: { baseUrl: "https://metrics.example/api/v1", apiKey: "sk-one" },
119
+ secretFields: ["apiKey"],
120
+ });
121
+
122
+ process.env.LATTICE_ENCRYPTION_KEY = "conn-key-two";
123
+ clearEncryptionKeyCache();
124
+
125
+ const read = await store.getByKey(tenantId, type, "rotated");
126
+ expect(read?.config.apiKey).toBe("");
127
+ expect(read?.config.baseUrl).toBe("https://metrics.example/api/v1");
128
+
129
+ const listed = await store.listByType(tenantId, type);
130
+ expect(listed.map((c) => c.key)).toContain("rotated");
131
+ } finally {
132
+ if (previousKey === undefined) delete process.env.LATTICE_ENCRYPTION_KEY;
133
+ else process.env.LATTICE_ENCRYPTION_KEY = previousKey;
134
+ clearEncryptionKeyCache();
135
+ }
136
+ });
137
+
138
+ it("re-encrypts a legacy plaintext declared secret field", async () => {
139
+ const id = randomUUID();
140
+ await pool.query(
141
+ `INSERT INTO lattice_connection_configs (id, tenant_id, type, key, name, config, secret_fields, created_at, updated_at)
142
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
143
+ [
144
+ id, tenantId, type, "legacy-secret", "Legacy",
145
+ JSON.stringify({ boStoreKey: "plain-store-key", baseUrl: "https://bo.example" }),
146
+ JSON.stringify(["boStoreKey"]),
147
+ ],
148
+ );
149
+
150
+ const client = await pool.connect();
151
+ try {
152
+ await reencryptLegacyConnectionSecrets.up(client);
153
+ } finally {
154
+ client.release();
155
+ }
156
+
157
+ const raw = await pool.query<{ config: Record<string, unknown> }>(
158
+ "SELECT config FROM lattice_connection_configs WHERE tenant_id = $1 AND id = $2",
159
+ [tenantId, id],
160
+ );
161
+ expect(raw.rows[0].config.boStoreKey).not.toBe("plain-store-key");
162
+ expect(decrypt(raw.rows[0].config.boStoreKey as string)).toBe("plain-store-key");
163
+ expect(raw.rows[0].config.baseUrl).toBe("https://bo.example");
164
+ });
106
165
  });