@axiom-lattice/pg-stores 5.1.2 → 5.2.0
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 +69 -0
- package/dist/index.d.mts +87 -3
- package/dist/index.d.ts +87 -3
- package/dist/index.js +534 -68
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +477 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLMcpServerConfigStore.integration.test.ts +122 -0
- package/src/__tests__/PostgreSQLMcpServerConfigStore.test.ts +81 -0
- package/src/__tests__/PostgreSQLPlatformAuditStore.test.ts +446 -0
- package/src/__tests__/PostgreSQLUserTenantLinkStore.test.ts +62 -0
- package/src/createPgStoreConfig.ts +6 -1
- package/src/index.ts +2 -0
- package/src/migrations/mcp_server_config_migrations.ts +47 -0
- package/src/migrations/platform_audit_migration.ts +45 -0
- package/src/stores/PostgreSQLMcpServerConfigStore.ts +343 -15
- package/src/stores/PostgreSQLPlatformAuditStore.ts +209 -0
- package/src/stores/PostgreSQLUserTenantLinkStore.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/pg-stores",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
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.3.2",
|
|
26
26
|
"pg": "^8.16.3",
|
|
27
27
|
"uuid": "^9.0.1",
|
|
28
|
-
"@axiom-lattice/core": "12.
|
|
29
|
-
"@axiom-lattice/protocols": "7.
|
|
28
|
+
"@axiom-lattice/core": "12.3.0",
|
|
29
|
+
"@axiom-lattice/protocols": "7.3.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/jest": "^29.5.14",
|
|
@@ -37,6 +37,10 @@ describeIfDatabase("PostgreSQLMcpServerConfigStore integration", () => {
|
|
|
37
37
|
"DELETE FROM lattice_mcp_server_configs WHERE tenant_id = ANY($1::text[])",
|
|
38
38
|
[[...tenants]],
|
|
39
39
|
);
|
|
40
|
+
await pool.query(
|
|
41
|
+
"DELETE FROM lattice_personal_mcp_server_configs WHERE tenant_id = ANY($1::text[])",
|
|
42
|
+
[[...tenants]],
|
|
43
|
+
);
|
|
40
44
|
tenants.clear();
|
|
41
45
|
});
|
|
42
46
|
|
|
@@ -255,4 +259,122 @@ describeIfDatabase("PostgreSQLMcpServerConfigStore integration", () => {
|
|
|
255
259
|
expect(await store.hasConfig(tenant, missing)).toBe(false);
|
|
256
260
|
expect(await store.deleteConfig(tenant, missing)).toBe(false);
|
|
257
261
|
});
|
|
262
|
+
|
|
263
|
+
describe("personal data domain", () => {
|
|
264
|
+
it("creates a personal config without exposing it to tenant queries", async () => {
|
|
265
|
+
const tenant = tenantId();
|
|
266
|
+
const owner = `pa-${randomUUID()}`;
|
|
267
|
+
const id = randomUUID();
|
|
268
|
+
const key = `personal-${randomUUID()}`;
|
|
269
|
+
|
|
270
|
+
const created = await store.createConfigForAssistant(tenant, owner, id, {
|
|
271
|
+
key,
|
|
272
|
+
config: config(),
|
|
273
|
+
selectedTools: ["read_file"],
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(created.ownerAssistantId).toBe(owner);
|
|
277
|
+
expect(await store.getConfigByIdForAssistant(tenant, owner, id)).toMatchObject({ id, ownerAssistantId: owner });
|
|
278
|
+
expect(await store.getConfigByKeyForAssistant(tenant, owner, key)).toMatchObject({ id });
|
|
279
|
+
expect(await store.hasConfigForAssistant(tenant, owner, id)).toBe(true);
|
|
280
|
+
|
|
281
|
+
expect(await store.getConfigById(tenant, id)).toBeNull();
|
|
282
|
+
expect(await store.getConfigByKey(tenant, key)).toBeNull();
|
|
283
|
+
expect(await store.getAllConfigs(tenant)).toEqual([]);
|
|
284
|
+
expect(
|
|
285
|
+
(await store.getAllConfigsWithoutTenant()).filter((entry) => entry.tenantId === tenant),
|
|
286
|
+
).toEqual([]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("allows the same key in the tenant and personal domains", async () => {
|
|
290
|
+
const tenant = tenantId();
|
|
291
|
+
const owner = `pa-${randomUUID()}`;
|
|
292
|
+
const key = `shared-${randomUUID()}`;
|
|
293
|
+
const tenantIdValue = randomUUID();
|
|
294
|
+
const personalId = randomUUID();
|
|
295
|
+
|
|
296
|
+
await store.createConfig(tenant, tenantIdValue, { key, config: config() });
|
|
297
|
+
await store.createConfigForAssistant(tenant, owner, personalId, {
|
|
298
|
+
key,
|
|
299
|
+
config: config({ url: "https://personal.example.com/mcp" }),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect((await store.getConfigByKey(tenant, key))?.id).toBe(tenantIdValue);
|
|
303
|
+
expect((await store.getConfigByKeyForAssistant(tenant, owner, key))?.id).toBe(personalId);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("scopes personal reads and writes to the owning assistant", async () => {
|
|
307
|
+
const tenant = tenantId();
|
|
308
|
+
const ownerA = `pa-${randomUUID()}`;
|
|
309
|
+
const ownerB = `pa-${randomUUID()}`;
|
|
310
|
+
const id = randomUUID();
|
|
311
|
+
const key = `scoped-${randomUUID()}`;
|
|
312
|
+
|
|
313
|
+
await store.createConfigForAssistant(tenant, ownerA, id, { key, config: config() });
|
|
314
|
+
|
|
315
|
+
expect(await store.getConfigByIdForAssistant(tenant, ownerB, id)).toBeNull();
|
|
316
|
+
expect(await store.getConfigByKeyForAssistant(tenant, ownerB, key)).toBeNull();
|
|
317
|
+
expect(await store.hasConfigForAssistant(tenant, ownerB, id)).toBe(false);
|
|
318
|
+
expect(await store.updateConfigForAssistant(tenant, ownerB, id, { name: "hijack" })).toBeNull();
|
|
319
|
+
expect(await store.deleteConfigForAssistant(tenant, ownerB, id)).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("enforces the (tenant, owner, key) unique index", async () => {
|
|
323
|
+
const tenant = tenantId();
|
|
324
|
+
const owner = `pa-${randomUUID()}`;
|
|
325
|
+
const key = `dup-${randomUUID()}`;
|
|
326
|
+
|
|
327
|
+
await store.createConfigForAssistant(tenant, owner, randomUUID(), { key, config: config() });
|
|
328
|
+
|
|
329
|
+
await expect(
|
|
330
|
+
store.createConfigForAssistant(tenant, owner, randomUUID(), { key, config: config() }),
|
|
331
|
+
).rejects.toThrow();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("deletes every personal config for one assistant only", async () => {
|
|
335
|
+
const tenant = tenantId();
|
|
336
|
+
const ownerA = `pa-${randomUUID()}`;
|
|
337
|
+
const ownerB = `pa-${randomUUID()}`;
|
|
338
|
+
|
|
339
|
+
await store.createConfigForAssistant(tenant, ownerA, randomUUID(), {
|
|
340
|
+
key: `a-${randomUUID()}`,
|
|
341
|
+
config: config(),
|
|
342
|
+
});
|
|
343
|
+
await store.createConfigForAssistant(tenant, ownerA, randomUUID(), {
|
|
344
|
+
key: `b-${randomUUID()}`,
|
|
345
|
+
config: config(),
|
|
346
|
+
});
|
|
347
|
+
await store.createConfigForAssistant(tenant, ownerB, randomUUID(), {
|
|
348
|
+
key: `c-${randomUUID()}`,
|
|
349
|
+
config: config(),
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(await store.deleteConfigsForAssistant(tenant, ownerA)).toBe(2);
|
|
353
|
+
expect(await store.getAllConfigsForAssistant(tenant, ownerA)).toEqual([]);
|
|
354
|
+
expect(await store.getAllConfigsForAssistant(tenant, ownerB)).toHaveLength(1);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("encrypts personal env and header secrets at rest", async () => {
|
|
358
|
+
const tenant = tenantId();
|
|
359
|
+
const owner = `pa-${randomUUID()}`;
|
|
360
|
+
const id = randomUUID();
|
|
361
|
+
|
|
362
|
+
await store.createConfigForAssistant(tenant, owner, id, {
|
|
363
|
+
key: `secret-${randomUUID()}`,
|
|
364
|
+
config: config(),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const row = await pool.query(
|
|
368
|
+
"SELECT config, is_env_encrypted FROM lattice_personal_mcp_server_configs WHERE id = $1",
|
|
369
|
+
[id],
|
|
370
|
+
);
|
|
371
|
+
expect(row.rows[0].is_env_encrypted).toBe(true);
|
|
372
|
+
expect(row.rows[0].config.env.API_TOKEN).not.toBe("env-secret");
|
|
373
|
+
expect(row.rows[0].config.headers.Authorization).not.toBe("Bearer header-secret");
|
|
374
|
+
|
|
375
|
+
const read = await store.getConfigByIdForAssistant(tenant, owner, id);
|
|
376
|
+
expect(read?.config.env?.API_TOKEN).toBe("env-secret");
|
|
377
|
+
expect(read?.config.headers?.Authorization).toBe("Bearer header-secret");
|
|
378
|
+
});
|
|
379
|
+
});
|
|
258
380
|
});
|
|
@@ -120,3 +120,84 @@ describe("PostgreSQLMcpServerConfigStore header encryption", () => {
|
|
|
120
120
|
expect(read?.config.command).toBe("npx");
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
|
+
|
|
124
|
+
describe("PostgreSQLMcpServerConfigStore personal domain SQL", () => {
|
|
125
|
+
const personalRow = (configJson: string) => ({
|
|
126
|
+
id: "cfg-personal",
|
|
127
|
+
tenant_id: "tenant-1",
|
|
128
|
+
owner_assistant_id: "pa-1",
|
|
129
|
+
key: "remote",
|
|
130
|
+
name: "Remote",
|
|
131
|
+
description: null,
|
|
132
|
+
config: configJson,
|
|
133
|
+
selected_tools: ["echo"],
|
|
134
|
+
is_env_encrypted: false,
|
|
135
|
+
status: "connected",
|
|
136
|
+
created_at: new Date("2026-01-01T00:00:00Z"),
|
|
137
|
+
updated_at: new Date("2026-01-02T00:00:00Z"),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("writes the owner column and reads it back as the entry owner", async () => {
|
|
141
|
+
const calls: Array<{ sql: string; values: unknown[] }> = [];
|
|
142
|
+
const configJson = JSON.stringify({
|
|
143
|
+
name: "remote",
|
|
144
|
+
version: "1.0.0",
|
|
145
|
+
transport: "streamable_http",
|
|
146
|
+
url: "https://mcp.example.com/mcp",
|
|
147
|
+
});
|
|
148
|
+
const query = jest.fn(async (sql: string, values: unknown[] = []) => {
|
|
149
|
+
calls.push({ sql, values });
|
|
150
|
+
if (sql.includes("SELECT id, tenant_id, owner_assistant_id")) {
|
|
151
|
+
return { rows: [personalRow(configJson)] };
|
|
152
|
+
}
|
|
153
|
+
return { rows: [], rowCount: 0 };
|
|
154
|
+
});
|
|
155
|
+
const store = new PostgreSQLMcpServerConfigStore({ pool: { query } as unknown as Pool });
|
|
156
|
+
|
|
157
|
+
await store.createConfigForAssistant("tenant-1", "pa-1", "cfg-personal", {
|
|
158
|
+
key: "remote",
|
|
159
|
+
config: {
|
|
160
|
+
name: "remote",
|
|
161
|
+
version: "1.0.0",
|
|
162
|
+
transport: "streamable_http",
|
|
163
|
+
url: "https://mcp.example.com/mcp",
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const insert = calls.find((call) => call.sql.includes("INSERT INTO lattice_personal_mcp_server_configs"));
|
|
168
|
+
expect(insert).toBeDefined();
|
|
169
|
+
expect(insert!.values[0]).toBe("cfg-personal");
|
|
170
|
+
expect(insert!.values[1]).toBe("tenant-1");
|
|
171
|
+
expect(insert!.values[2]).toBe("pa-1");
|
|
172
|
+
expect(insert!.sql).toContain("ON CONFLICT (tenant_id, owner_assistant_id, id)");
|
|
173
|
+
|
|
174
|
+
const read = await store.getConfigByKeyForAssistant("tenant-1", "pa-1", "remote");
|
|
175
|
+
expect(read).toMatchObject({
|
|
176
|
+
id: "cfg-personal",
|
|
177
|
+
tenantId: "tenant-1",
|
|
178
|
+
ownerAssistantId: "pa-1",
|
|
179
|
+
key: "remote",
|
|
180
|
+
selectedTools: ["echo"],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const select = calls.find((call) =>
|
|
184
|
+
call.sql.includes("FROM lattice_personal_mcp_server_configs") && call.sql.includes("key = $3"),
|
|
185
|
+
);
|
|
186
|
+
expect(select!.values).toEqual(["tenant-1", "pa-1", "remote"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("scopes personal deletes to tenant and owner", async () => {
|
|
190
|
+
const calls: Array<{ sql: string; values: unknown[] }> = [];
|
|
191
|
+
const query = jest.fn(async (sql: string, values: unknown[] = []) => {
|
|
192
|
+
calls.push({ sql, values });
|
|
193
|
+
return { rows: [], rowCount: 2 };
|
|
194
|
+
});
|
|
195
|
+
const store = new PostgreSQLMcpServerConfigStore({ pool: { query } as unknown as Pool });
|
|
196
|
+
|
|
197
|
+
expect(await store.deleteConfigsForAssistant("tenant-1", "pa-1")).toBe(2);
|
|
198
|
+
|
|
199
|
+
const del = calls.find((call) => call.sql.includes("DELETE FROM lattice_personal_mcp_server_configs"));
|
|
200
|
+
expect(del!.sql).toContain("owner_assistant_id = $2");
|
|
201
|
+
expect(del!.values).toEqual(["tenant-1", "pa-1"]);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import type {
|
|
4
|
+
PlatformAuditAppendInput,
|
|
5
|
+
PlatformAuditInitiator,
|
|
6
|
+
PlatformAuditRecord,
|
|
7
|
+
} from "@axiom-lattice/protocols";
|
|
8
|
+
import { createPlatformAuditTable } from "../migrations/platform_audit_migration";
|
|
9
|
+
import { PostgreSQLPlatformAuditStore } from "../stores/PostgreSQLPlatformAuditStore";
|
|
10
|
+
|
|
11
|
+
const databaseUrl = process.env.TEST_DATABASE_URL;
|
|
12
|
+
const describeIfDatabase = databaseUrl ? describe : describe.skip;
|
|
13
|
+
|
|
14
|
+
const TABLE = "lattice_platform_audit";
|
|
15
|
+
|
|
16
|
+
const USER_1 = "11111111-1111-4111-8111-111111111111";
|
|
17
|
+
const USER_2 = "22222222-2222-4222-8222-222222222222";
|
|
18
|
+
const SYSTEM = "33333333-3333-4333-8333-333333333333";
|
|
19
|
+
const BOOTSTRAP = "44444444-4444-4444-8444-444444444444";
|
|
20
|
+
|
|
21
|
+
const humanInput = (
|
|
22
|
+
overrides: Partial<PlatformAuditAppendInput> = {},
|
|
23
|
+
): PlatformAuditAppendInput => ({
|
|
24
|
+
initiator: { type: "human", userId: "user-1", role: "admin" },
|
|
25
|
+
source: "admin_center",
|
|
26
|
+
executor: { type: "admin_center", route: "PATCH /api/platform/settings" },
|
|
27
|
+
action: "write",
|
|
28
|
+
status: "ok",
|
|
29
|
+
summary: { changed: ["invite.defaultMaxUses"] },
|
|
30
|
+
durationMs: 12,
|
|
31
|
+
...overrides,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
interface RawRow {
|
|
35
|
+
id: string;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
initiator?: PlatformAuditInitiator;
|
|
38
|
+
source?: PlatformAuditRecord["source"];
|
|
39
|
+
executor?: PlatformAuditRecord["executor"];
|
|
40
|
+
tenantId?: string;
|
|
41
|
+
action?: PlatformAuditRecord["action"];
|
|
42
|
+
status?: PlatformAuditRecord["status"];
|
|
43
|
+
summary?: Record<string, unknown>;
|
|
44
|
+
before?: Record<string, unknown>;
|
|
45
|
+
after?: Record<string, unknown>;
|
|
46
|
+
errorCode?: string;
|
|
47
|
+
durationMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describeIfDatabase("PostgreSQLPlatformAuditStore", () => {
|
|
51
|
+
let pool: Pool;
|
|
52
|
+
let store: PostgreSQLPlatformAuditStore;
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
pool = new Pool({ connectionString: databaseUrl });
|
|
56
|
+
const client = await pool.connect();
|
|
57
|
+
try {
|
|
58
|
+
await createPlatformAuditTable.up(client);
|
|
59
|
+
} finally {
|
|
60
|
+
client.release();
|
|
61
|
+
}
|
|
62
|
+
store = new PostgreSQLPlatformAuditStore({ pool });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(async () => {
|
|
66
|
+
const client = await pool.connect();
|
|
67
|
+
try {
|
|
68
|
+
await createPlatformAuditTable.down(client);
|
|
69
|
+
} finally {
|
|
70
|
+
client.release();
|
|
71
|
+
}
|
|
72
|
+
await pool.end();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
beforeEach(async () => {
|
|
76
|
+
await pool.query(`DELETE FROM ${TABLE}`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
async function insertRow(row: RawRow): Promise<void> {
|
|
80
|
+
const initiator: PlatformAuditInitiator =
|
|
81
|
+
row.initiator ?? { type: "system", component: "test" };
|
|
82
|
+
const userId = initiator.type === "human" ? initiator.userId : null;
|
|
83
|
+
const role = initiator.type === "human" ? initiator.role : null;
|
|
84
|
+
await pool.query(
|
|
85
|
+
`INSERT INTO ${TABLE}
|
|
86
|
+
(id, initiator_user_id, initiator_role, initiator, source,
|
|
87
|
+
executor, tenant_id, action, status, summary, before,
|
|
88
|
+
after, error_code, duration_ms, created_at)
|
|
89
|
+
VALUES ($1,$2,$3,$4::jsonb,$5,$6::jsonb,$7,$8,$9,$10::jsonb,$11::jsonb,
|
|
90
|
+
$12::jsonb,$13,$14,$15)`,
|
|
91
|
+
[
|
|
92
|
+
row.id,
|
|
93
|
+
userId,
|
|
94
|
+
role,
|
|
95
|
+
JSON.stringify(initiator),
|
|
96
|
+
row.source ?? "system",
|
|
97
|
+
JSON.stringify(row.executor ?? { type: "system", component: "test" }),
|
|
98
|
+
row.tenantId ?? null,
|
|
99
|
+
row.action ?? "write",
|
|
100
|
+
row.status ?? "ok",
|
|
101
|
+
JSON.stringify(row.summary ?? {}),
|
|
102
|
+
row.before === undefined ? null : JSON.stringify(row.before),
|
|
103
|
+
row.after === undefined ? null : JSON.stringify(row.after),
|
|
104
|
+
row.errorCode ?? null,
|
|
105
|
+
row.durationMs ?? 1,
|
|
106
|
+
row.createdAt,
|
|
107
|
+
],
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe("migration", () => {
|
|
112
|
+
it("applies and reverses idempotently", async () => {
|
|
113
|
+
const client = await pool.connect();
|
|
114
|
+
try {
|
|
115
|
+
await expect(createPlatformAuditTable.up(client)).resolves.toBeUndefined();
|
|
116
|
+
const exists = await client.query(
|
|
117
|
+
"SELECT to_regclass('public.lattice_platform_audit') AS name",
|
|
118
|
+
);
|
|
119
|
+
expect(exists.rows[0].name).not.toBeNull();
|
|
120
|
+
|
|
121
|
+
await createPlatformAuditTable.down(client);
|
|
122
|
+
const dropped = await client.query(
|
|
123
|
+
"SELECT to_regclass('public.lattice_platform_audit') AS name",
|
|
124
|
+
);
|
|
125
|
+
expect(dropped.rows[0].name).toBeNull();
|
|
126
|
+
|
|
127
|
+
await createPlatformAuditTable.up(client);
|
|
128
|
+
} finally {
|
|
129
|
+
client.release();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("append and get", () => {
|
|
135
|
+
it("returns the inserted row with a generated id and createdAt", async () => {
|
|
136
|
+
const input = humanInput({ tenantId: "tenant-1", errorCode: "NOPE" });
|
|
137
|
+
const record = await store.append(input);
|
|
138
|
+
|
|
139
|
+
expect(record.id).toEqual(expect.any(String));
|
|
140
|
+
expect(record.createdAt).toBeInstanceOf(Date);
|
|
141
|
+
expect(record.initiator).toEqual(input.initiator);
|
|
142
|
+
expect(record.source).toBe(input.source);
|
|
143
|
+
expect(record.executor).toEqual(input.executor);
|
|
144
|
+
expect(record.tenantId).toBe("tenant-1");
|
|
145
|
+
expect(record.action).toBe(input.action);
|
|
146
|
+
expect(record.status).toBe(input.status);
|
|
147
|
+
expect(record.summary).toEqual(input.summary);
|
|
148
|
+
expect(record.errorCode).toBe("NOPE");
|
|
149
|
+
expect(record.durationMs).toBe(12);
|
|
150
|
+
|
|
151
|
+
await expect(store.get(record.id)).resolves.toEqual(record);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns null for a missing id", async () => {
|
|
155
|
+
await expect(
|
|
156
|
+
store.get("00000000-0000-0000-0000-000000000000"),
|
|
157
|
+
).resolves.toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("round-trips nested JSON in initiator, executor, summary, before and after", async () => {
|
|
161
|
+
const input = humanInput({
|
|
162
|
+
initiator: { type: "human", userId: "user-7", role: "owner" },
|
|
163
|
+
executor: {
|
|
164
|
+
type: "ops_agent",
|
|
165
|
+
agentId: "platform-ops",
|
|
166
|
+
threadId: "thread-1",
|
|
167
|
+
runId: "run-1",
|
|
168
|
+
toolName: "update_platform_settings",
|
|
169
|
+
},
|
|
170
|
+
summary: { nested: { list: [1, "two", { three: true }] } },
|
|
171
|
+
before: { enabled: false, tags: ["a", "b"] },
|
|
172
|
+
after: { enabled: true, tags: [] },
|
|
173
|
+
});
|
|
174
|
+
const record = await store.append(input);
|
|
175
|
+
const reloaded = await store.get(record.id);
|
|
176
|
+
|
|
177
|
+
expect(reloaded).not.toBeNull();
|
|
178
|
+
expect(reloaded?.initiator).toEqual(input.initiator);
|
|
179
|
+
expect(reloaded?.executor).toEqual(input.executor);
|
|
180
|
+
expect(reloaded?.summary).toEqual(input.summary);
|
|
181
|
+
expect(reloaded?.before).toEqual(input.before);
|
|
182
|
+
expect(reloaded?.after).toEqual(input.after);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("omits optional fields that were not provided", async () => {
|
|
186
|
+
const record = await store.append(humanInput());
|
|
187
|
+
expect(record).not.toHaveProperty("tenantId");
|
|
188
|
+
expect(record).not.toHaveProperty("before");
|
|
189
|
+
expect(record).not.toHaveProperty("after");
|
|
190
|
+
expect(record).not.toHaveProperty("errorCode");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("normalizes durationMs to a non-negative integer", async () => {
|
|
194
|
+
const rounded = await store.append(humanInput({ durationMs: 4.6 }));
|
|
195
|
+
expect(rounded.durationMs).toBe(5);
|
|
196
|
+
|
|
197
|
+
const clamped = await store.append(humanInput({ durationMs: -3 }));
|
|
198
|
+
expect(clamped.durationMs).toBe(0);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("query filters", () => {
|
|
203
|
+
const ids = (rows: PlatformAuditRecord[]) => rows.map((row) => row.id);
|
|
204
|
+
|
|
205
|
+
beforeEach(async () => {
|
|
206
|
+
await insertRow({
|
|
207
|
+
id: USER_1,
|
|
208
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
209
|
+
initiator: { type: "human", userId: "user-1", role: "admin" },
|
|
210
|
+
source: "admin_center",
|
|
211
|
+
tenantId: "tenant-1",
|
|
212
|
+
action: "write",
|
|
213
|
+
status: "ok",
|
|
214
|
+
});
|
|
215
|
+
await insertRow({
|
|
216
|
+
id: USER_2,
|
|
217
|
+
createdAt: "2024-01-01T00:00:01.000Z",
|
|
218
|
+
initiator: { type: "human", userId: "user-2", role: "owner" },
|
|
219
|
+
source: "admin_center",
|
|
220
|
+
tenantId: "tenant-2",
|
|
221
|
+
action: "playbook",
|
|
222
|
+
status: "error",
|
|
223
|
+
});
|
|
224
|
+
await insertRow({
|
|
225
|
+
id: SYSTEM,
|
|
226
|
+
createdAt: "2024-01-01T00:00:02.000Z",
|
|
227
|
+
initiator: { type: "system", component: "scheduler" },
|
|
228
|
+
source: "system",
|
|
229
|
+
tenantId: "tenant-1",
|
|
230
|
+
action: "write",
|
|
231
|
+
status: "ok",
|
|
232
|
+
});
|
|
233
|
+
await insertRow({
|
|
234
|
+
id: BOOTSTRAP,
|
|
235
|
+
createdAt: "2024-01-01T00:00:03.000Z",
|
|
236
|
+
initiator: { type: "bootstrap" },
|
|
237
|
+
source: "bootstrap",
|
|
238
|
+
action: "playbook",
|
|
239
|
+
status: "cancelled",
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("returns everything without filters", async () => {
|
|
244
|
+
expect(ids(await store.query({}))).toEqual([
|
|
245
|
+
BOOTSTRAP,
|
|
246
|
+
SYSTEM,
|
|
247
|
+
USER_2,
|
|
248
|
+
USER_1,
|
|
249
|
+
]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("filters by human initiator user id only", async () => {
|
|
253
|
+
expect(ids(await store.query({ initiatorUserId: "user-1" }))).toEqual([
|
|
254
|
+
USER_1,
|
|
255
|
+
]);
|
|
256
|
+
expect(await store.query({ initiatorUserId: "nobody" })).toEqual([]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("keeps non-human records visible when initiatorUserId is absent", async () => {
|
|
260
|
+
const rows = await store.query({});
|
|
261
|
+
expect(rows.some((row) => row.initiator.type === "system")).toBe(true);
|
|
262
|
+
expect(rows.some((row) => row.initiator.type === "bootstrap")).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("does not match non-human rows by component under initiatorUserId", async () => {
|
|
266
|
+
await expect(
|
|
267
|
+
store.query({ initiatorUserId: "scheduler" }),
|
|
268
|
+
).resolves.toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("filters by source", async () => {
|
|
272
|
+
expect(ids(await store.query({ source: "system" }))).toEqual([SYSTEM]);
|
|
273
|
+
expect(ids(await store.query({ source: "admin_center" }))).toEqual([
|
|
274
|
+
USER_2,
|
|
275
|
+
USER_1,
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("filters by tenantId", async () => {
|
|
280
|
+
expect(ids(await store.query({ tenantId: "tenant-1" }))).toEqual([
|
|
281
|
+
SYSTEM,
|
|
282
|
+
USER_1,
|
|
283
|
+
]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("filters by action", async () => {
|
|
287
|
+
expect(ids(await store.query({ action: "playbook" }))).toEqual([
|
|
288
|
+
BOOTSTRAP,
|
|
289
|
+
USER_2,
|
|
290
|
+
]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("filters by status", async () => {
|
|
294
|
+
expect(ids(await store.query({ status: "error" }))).toEqual([USER_2]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("combines filters", async () => {
|
|
298
|
+
expect(
|
|
299
|
+
ids(await store.query({ tenantId: "tenant-1", action: "write" })),
|
|
300
|
+
).toEqual([SYSTEM, USER_1]);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("query ordering and pagination", () => {
|
|
305
|
+
const ids = (rows: PlatformAuditRecord[]) => rows.map((row) => row.id);
|
|
306
|
+
|
|
307
|
+
it("orders by createdAt descending then id descending", async () => {
|
|
308
|
+
const a = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
309
|
+
const b = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
310
|
+
const c = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
|
311
|
+
const z = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
|
312
|
+
|
|
313
|
+
await insertRow({ id: b, createdAt: "2024-01-01T00:00:00.000Z" });
|
|
314
|
+
await insertRow({ id: a, createdAt: "2024-01-01T00:00:00.000Z" });
|
|
315
|
+
await insertRow({ id: c, createdAt: "2024-01-01T00:00:00.000Z" });
|
|
316
|
+
await insertRow({ id: z, createdAt: "2024-01-02T00:00:00.000Z" });
|
|
317
|
+
|
|
318
|
+
expect(ids(await store.query({}))).toEqual([z, c, b, a]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("treats from as inclusive and to as exclusive on createdAt", async () => {
|
|
322
|
+
const early = "00000000-0000-4000-8000-000000000001";
|
|
323
|
+
const boundary = "00000000-0000-4000-8000-000000000002";
|
|
324
|
+
const late = "00000000-0000-4000-8000-000000000003";
|
|
325
|
+
|
|
326
|
+
await insertRow({ id: early, createdAt: "2024-01-01T00:00:00.000Z" });
|
|
327
|
+
await insertRow({ id: boundary, createdAt: "2024-01-01T00:00:01.000Z" });
|
|
328
|
+
await insertRow({ id: late, createdAt: "2024-01-01T00:00:02.000Z" });
|
|
329
|
+
|
|
330
|
+
expect(
|
|
331
|
+
ids(await store.query({ from: "2024-01-01T00:00:01.000Z" })),
|
|
332
|
+
).toEqual([late, boundary]);
|
|
333
|
+
expect(
|
|
334
|
+
ids(await store.query({ to: "2024-01-01T00:00:01.000Z" })),
|
|
335
|
+
).toEqual([early]);
|
|
336
|
+
expect(
|
|
337
|
+
ids(
|
|
338
|
+
await store.query({
|
|
339
|
+
from: "2024-01-01T00:00:00.000Z",
|
|
340
|
+
to: "2024-01-01T00:00:02.000Z",
|
|
341
|
+
}),
|
|
342
|
+
),
|
|
343
|
+
).toEqual([boundary, early]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("normalizes equivalent non-canonical and date-only boundaries", async () => {
|
|
347
|
+
const early = "00000000-0000-4000-8000-000000000001";
|
|
348
|
+
const boundary = "00000000-0000-4000-8000-000000000002";
|
|
349
|
+
const late = "00000000-0000-4000-8000-000000000003";
|
|
350
|
+
|
|
351
|
+
await insertRow({ id: early, createdAt: "2024-01-01T00:00:00.000Z" });
|
|
352
|
+
await insertRow({ id: boundary, createdAt: "2024-01-01T00:00:01.000Z" });
|
|
353
|
+
await insertRow({ id: late, createdAt: "2024-01-01T00:00:02.000Z" });
|
|
354
|
+
|
|
355
|
+
expect(
|
|
356
|
+
ids(await store.query({ from: "2024-01-01T08:00:01+08:00" })),
|
|
357
|
+
).toEqual([late, boundary]);
|
|
358
|
+
expect(
|
|
359
|
+
ids(await store.query({ to: "2024-01-01T08:00:02+08:00" })),
|
|
360
|
+
).toEqual([boundary, early]);
|
|
361
|
+
expect(ids(await store.query({ from: "2024-01-01" }))).toEqual([
|
|
362
|
+
late,
|
|
363
|
+
boundary,
|
|
364
|
+
early,
|
|
365
|
+
]);
|
|
366
|
+
expect(await store.query({ to: "2024-01-01" })).toEqual([]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("ignores invalid or empty boundaries", async () => {
|
|
370
|
+
const a = "00000000-0000-4000-8000-000000000001";
|
|
371
|
+
const b = "00000000-0000-4000-8000-000000000002";
|
|
372
|
+
|
|
373
|
+
await insertRow({ id: a, createdAt: "2024-01-01T00:00:00.000Z" });
|
|
374
|
+
await insertRow({ id: b, createdAt: "2024-01-01T00:00:01.000Z" });
|
|
375
|
+
|
|
376
|
+
expect(ids(await store.query({ from: "not-a-date" }))).toEqual([b, a]);
|
|
377
|
+
expect(ids(await store.query({ from: "" }))).toEqual([b, a]);
|
|
378
|
+
expect(ids(await store.query({ to: "" }))).toEqual([b, a]);
|
|
379
|
+
expect(ids(await store.query({ to: "also-not-a-date" }))).toEqual([b, a]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("defaults to a limit of 100", async () => {
|
|
383
|
+
for (let i = 0; i < 101; i += 1) {
|
|
384
|
+
const hex = i.toString(16).padStart(12, "0");
|
|
385
|
+
await insertRow({
|
|
386
|
+
id: `00000000-0000-4000-8000-${hex}`,
|
|
387
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const rows = await store.query({});
|
|
392
|
+
expect(rows).toHaveLength(100);
|
|
393
|
+
expect(rows[0].id).toBe("00000000-0000-4000-8000-000000000064");
|
|
394
|
+
expect(rows[99].id).toBe("00000000-0000-4000-8000-000000000001");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("honors explicit limit and offset", async () => {
|
|
398
|
+
for (let i = 0; i < 5; i += 1) {
|
|
399
|
+
const hex = i.toString(16).padStart(12, "0");
|
|
400
|
+
await insertRow({
|
|
401
|
+
id: `00000000-0000-4000-8000-${hex}`,
|
|
402
|
+
createdAt: `2024-01-01T00:00:0${i}.000Z`,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
expect(
|
|
407
|
+
ids(await store.query({ limit: 2, offset: 1 })),
|
|
408
|
+
).toEqual([
|
|
409
|
+
"00000000-0000-4000-8000-000000000003",
|
|
410
|
+
"00000000-0000-4000-8000-000000000002",
|
|
411
|
+
]);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("clamps negative offset and limit to zero", async () => {
|
|
415
|
+
const a = "00000000-0000-4000-8000-000000000001";
|
|
416
|
+
const b = "00000000-0000-4000-8000-000000000002";
|
|
417
|
+
|
|
418
|
+
await insertRow({ id: a, createdAt: "2024-01-01T00:00:00.000Z" });
|
|
419
|
+
await insertRow({ id: b, createdAt: "2024-01-01T00:00:01.000Z" });
|
|
420
|
+
|
|
421
|
+
expect(await store.query({ limit: -1 })).toEqual([]);
|
|
422
|
+
expect(ids(await store.query({ offset: -5 }))).toEqual([b, a]);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("falls back to defaults for non-finite and truncates fractional pagination", async () => {
|
|
426
|
+
for (let i = 0; i < 5; i += 1) {
|
|
427
|
+
const hex = i.toString(16).padStart(12, "0");
|
|
428
|
+
await insertRow({
|
|
429
|
+
id: `00000000-0000-4000-8000-${hex}`,
|
|
430
|
+
createdAt: `2024-01-01T00:00:0${i}.000Z`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
expect(await store.query({ limit: Number.NaN })).toHaveLength(5);
|
|
435
|
+
expect(
|
|
436
|
+
await store.query({ limit: Number.POSITIVE_INFINITY }),
|
|
437
|
+
).toHaveLength(5);
|
|
438
|
+
expect(await store.query({ offset: Number.NaN })).toHaveLength(5);
|
|
439
|
+
|
|
440
|
+
expect(ids(await store.query({ limit: 2.9, offset: 1.9 }))).toEqual([
|
|
441
|
+
"00000000-0000-4000-8000-000000000003",
|
|
442
|
+
"00000000-0000-4000-8000-000000000002",
|
|
443
|
+
]);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
});
|