@axiom-lattice/pg-stores 3.1.5 → 3.1.8
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 +29 -0
- package/dist/index.d.mts +30 -2
- package/dist/index.d.ts +30 -2
- package/dist/index.js +137 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +135 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLAgentWebAppStore.migrations.test.ts +2 -1
- package/src/__tests__/PostgreSQLAgentWebAppStore.test.ts +1 -0
- package/src/__tests__/PostgreSQLMcpServerConfigStore.test.ts +79 -0
- package/src/__tests__/PostgreSQLProjectRoomReadStateStore.test.ts +136 -0
- package/src/createPgStoreConfig.ts +6 -1
- package/src/index.ts +1 -0
- package/src/migrations/add_agent_web_apps_identity_migration.ts +18 -0
- package/src/migrations/project_room_migration.ts +26 -0
- package/src/stores/PostgreSQLAgentWebAppStore.ts +25 -3
- package/src/stores/PostgreSQLMcpServerConfigStore.ts +23 -0
- package/src/stores/PostgreSQLProjectRoomMessageStore.ts +16 -0
- package/src/stores/PostgreSQLProjectRoomReadStateStore.ts +47 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/pg-stores",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.8",
|
|
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": "4.2.
|
|
29
|
-
"@axiom-lattice/protocols": "4.
|
|
28
|
+
"@axiom-lattice/core": "4.2.8",
|
|
29
|
+
"@axiom-lattice/protocols": "4.2.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/jest": "^29.5.14",
|
|
@@ -45,8 +45,9 @@ describe("PostgreSQLAgentWebAppStore standalone migrations", () => {
|
|
|
45
45
|
|
|
46
46
|
await store.initialize();
|
|
47
47
|
|
|
48
|
-
expect(recordedMigrationNames).toEqual(["create_agent_web_apps_table"]);
|
|
48
|
+
expect(recordedMigrationNames).toEqual(["create_agent_web_apps_table", "add_agent_web_apps_identity"]);
|
|
49
49
|
expect(clientSql.some((sql) => sql.includes("CREATE TABLE IF NOT EXISTS lattice_agent_web_apps"))).toBe(true);
|
|
50
|
+
expect(clientSql.some((sql) => sql.includes("ADD COLUMN IF NOT EXISTS identity"))).toBe(true);
|
|
50
51
|
expect(clientSql.some((sql) => sql.includes("status VARCHAR(32) NOT NULL DEFAULT 'draft'"))).toBe(true);
|
|
51
52
|
expect(clientSql.some((sql) => sql.includes(
|
|
52
53
|
"CONSTRAINT chk_lattice_agent_web_apps_status CHECK (status IN ('draft', 'active', 'disabled'))",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import type { 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 { PostgreSQLMcpServerConfigStore } from "../stores/PostgreSQLMcpServerConfigStore";
|
|
11
|
+
|
|
12
|
+
const makeRow = (configJson: string, isEnvEncrypted: boolean) => ({
|
|
13
|
+
id: "cfg-1",
|
|
14
|
+
tenant_id: "tenant-1",
|
|
15
|
+
key: "remote",
|
|
16
|
+
name: null,
|
|
17
|
+
description: null,
|
|
18
|
+
config: configJson,
|
|
19
|
+
selected_tools: [],
|
|
20
|
+
is_env_encrypted: isEnvEncrypted,
|
|
21
|
+
status: "disconnected",
|
|
22
|
+
created_at: new Date(),
|
|
23
|
+
updated_at: new Date(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("PostgreSQLMcpServerConfigStore header encryption", () => {
|
|
27
|
+
it("encrypts header values at rest and decrypts on read", async () => {
|
|
28
|
+
let storedConfig = "";
|
|
29
|
+
let storedFlag = false;
|
|
30
|
+
const query = jest.fn(async (sql: string, values: unknown[]) => {
|
|
31
|
+
if (sql.includes("INSERT INTO lattice_mcp_server_configs")) {
|
|
32
|
+
storedConfig = values[5] as string;
|
|
33
|
+
storedFlag = values[7] as boolean;
|
|
34
|
+
return { rowCount: 1 };
|
|
35
|
+
}
|
|
36
|
+
if (sql.includes("SELECT id, tenant_id, key")) {
|
|
37
|
+
return { rows: [makeRow(storedConfig, storedFlag)] };
|
|
38
|
+
}
|
|
39
|
+
return { rows: [] };
|
|
40
|
+
});
|
|
41
|
+
const store = new PostgreSQLMcpServerConfigStore({ pool: { query } as unknown as Pool });
|
|
42
|
+
|
|
43
|
+
await store.createConfig("tenant-1", "cfg-1", {
|
|
44
|
+
key: "remote",
|
|
45
|
+
config: {
|
|
46
|
+
name: "remote",
|
|
47
|
+
version: "1.0.0",
|
|
48
|
+
transport: "streamable_http",
|
|
49
|
+
url: "https://mcp.example.com/mcp",
|
|
50
|
+
headers: { Authorization: "Bearer sk-secret-token" },
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(storedFlag).toBe(true);
|
|
55
|
+
const atRest = JSON.parse(storedConfig);
|
|
56
|
+
expect(atRest.headers.Authorization).not.toBe("Bearer sk-secret-token");
|
|
57
|
+
|
|
58
|
+
const read = await store.getConfigByKey("tenant-1", "remote");
|
|
59
|
+
expect(read?.config.headers?.Authorization).toBe("Bearer sk-secret-token");
|
|
60
|
+
expect(read?.config.headers?.Authorization).not.toBe(atRest.headers.Authorization);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("leaves legacy rows without headers untouched", async () => {
|
|
64
|
+
const legacyConfig = JSON.stringify({
|
|
65
|
+
name: "legacy", version: "1.0.0", transport: "stdio", command: "npx",
|
|
66
|
+
});
|
|
67
|
+
const query = jest.fn(async (sql: string) => {
|
|
68
|
+
if (sql.includes("SELECT id, tenant_id, key")) {
|
|
69
|
+
return { rows: [makeRow(legacyConfig, false)] };
|
|
70
|
+
}
|
|
71
|
+
return { rows: [] };
|
|
72
|
+
});
|
|
73
|
+
const store = new PostgreSQLMcpServerConfigStore({ pool: { query } as unknown as Pool });
|
|
74
|
+
|
|
75
|
+
const read = await store.getConfigByKey("tenant-1", "remote");
|
|
76
|
+
expect(read?.config.headers).toBeUndefined();
|
|
77
|
+
expect(read?.config.command).toBe("npx");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
|
3
|
+
import { Pool } from "pg";
|
|
4
|
+
import type { ProjectRoomMessage } from "@axiom-lattice/protocols";
|
|
5
|
+
|
|
6
|
+
import { MigrationManager } from "../migrations/migration";
|
|
7
|
+
import { createProjectRoomReadStates, createProjectRoomTables } from "../migrations/project_room_migration";
|
|
8
|
+
import { PostgreSQLProjectRoomMessageStore } from "../stores/PostgreSQLProjectRoomMessageStore";
|
|
9
|
+
import { PostgreSQLProjectRoomReadStateStore } from "../stores/PostgreSQLProjectRoomReadStateStore";
|
|
10
|
+
|
|
11
|
+
const query = jest.fn();
|
|
12
|
+
const mockPool = { query } as unknown as Pool;
|
|
13
|
+
const readStateRow = {
|
|
14
|
+
tenant_id: "tenant-1", room_id: "room-1", user_id: "user-1",
|
|
15
|
+
last_read_at: new Date("2026-01-02T00:00:00.000Z"), updated_at: new Date("2026-01-02T00:00:01.000Z"),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("PostgreSQLProjectRoomReadStateStore SQL contract", () => {
|
|
19
|
+
beforeEach(() => query.mockReset());
|
|
20
|
+
|
|
21
|
+
it("returns null when no marker exists and selects by the full primary key", async () => {
|
|
22
|
+
query.mockResolvedValueOnce({ rows: [] });
|
|
23
|
+
const store = new PostgreSQLProjectRoomReadStateStore({ pool: mockPool });
|
|
24
|
+
await expect(store.get("tenant-1", "room-1", "user-1")).resolves.toBeNull();
|
|
25
|
+
expect(query.mock.calls[0][0]).toContain("FROM lattice_project_room_read_states");
|
|
26
|
+
expect(query.mock.calls[0][0]).toContain("WHERE tenant_id = $1 AND room_id = $2 AND user_id = $3");
|
|
27
|
+
expect(query.mock.calls[0][1]).toEqual(["tenant-1", "room-1", "user-1"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("upserts monotonically with GREATEST and returns the stored row", async () => {
|
|
31
|
+
query.mockResolvedValueOnce({ rows: [readStateRow] });
|
|
32
|
+
const store = new PostgreSQLProjectRoomReadStateStore({ pool: mockPool });
|
|
33
|
+
const lastReadAt = new Date("2026-01-02T00:00:00.000Z");
|
|
34
|
+
await expect(store.markRead({ tenantId: "tenant-1", roomId: "room-1", userId: "user-1", lastReadAt }))
|
|
35
|
+
.resolves.toEqual({
|
|
36
|
+
tenantId: "tenant-1", roomId: "room-1", userId: "user-1",
|
|
37
|
+
lastReadAt, updatedAt: new Date("2026-01-02T00:00:01.000Z"),
|
|
38
|
+
});
|
|
39
|
+
const [sql, params] = query.mock.calls[0] as [string, unknown[]];
|
|
40
|
+
expect(sql).toContain("ON CONFLICT (tenant_id, room_id, user_id) DO UPDATE");
|
|
41
|
+
expect(sql).toContain("GREATEST(lattice_project_room_read_states.last_read_at, EXCLUDED.last_read_at)");
|
|
42
|
+
expect(params).toEqual(["tenant-1", "room-1", "user-1", lastReadAt]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects an invalid markRead horizon before querying", async () => {
|
|
46
|
+
const store = new PostgreSQLProjectRoomReadStateStore({ pool: mockPool });
|
|
47
|
+
await expect(store.markRead({ tenantId: "tenant-1", roomId: "room-1", userId: "user-1", lastReadAt: new Date("bad") }))
|
|
48
|
+
.rejects.toThrow(RangeError);
|
|
49
|
+
expect(query).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rejects malformed read state rows", async () => {
|
|
53
|
+
query.mockResolvedValueOnce({ rows: [{ ...readStateRow, last_read_at: "2026-01-02" }] });
|
|
54
|
+
const store = new PostgreSQLProjectRoomReadStateStore({ pool: mockPool });
|
|
55
|
+
await expect(store.get("tenant-1", "room-1", "user-1")).rejects.toThrow("Invalid project room read state row");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("PostgreSQLProjectRoomMessageStore countAfter SQL contract", () => {
|
|
60
|
+
beforeEach(() => query.mockReset());
|
|
61
|
+
|
|
62
|
+
it("counts strictly after the horizon and excludes one human author", async () => {
|
|
63
|
+
query.mockResolvedValueOnce({ rows: [{ count: 2 }] });
|
|
64
|
+
const store = new PostgreSQLProjectRoomMessageStore({ pool: mockPool });
|
|
65
|
+
const after = new Date("2026-01-01T00:00:00.000Z");
|
|
66
|
+
await expect(store.countAfter({ tenantId: "tenant-1", roomId: "room-1", after, excludeAuthorUserId: "user-1" }))
|
|
67
|
+
.resolves.toBe(2);
|
|
68
|
+
const [sql, params] = query.mock.calls[0] as [string, unknown[]];
|
|
69
|
+
expect(sql).toContain("created_at > $3");
|
|
70
|
+
expect(sql).toContain("($4::text IS NULL OR author->>'type' <> 'human' OR author->>'userId' <> $4)");
|
|
71
|
+
expect(params).toEqual(["tenant-1", "room-1", after, "user-1"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("passes null when no author is excluded and rejects invalid horizons", async () => {
|
|
75
|
+
query.mockResolvedValueOnce({ rows: [{ count: 0 }] });
|
|
76
|
+
const store = new PostgreSQLProjectRoomMessageStore({ pool: mockPool });
|
|
77
|
+
const after = new Date("2026-01-01T00:00:00.000Z");
|
|
78
|
+
await expect(store.countAfter({ tenantId: "tenant-1", roomId: "room-1", after })).resolves.toBe(0);
|
|
79
|
+
expect(query.mock.calls[0][1]).toEqual(["tenant-1", "room-1", after, null]);
|
|
80
|
+
await expect(store.countAfter({ tenantId: "tenant-1", roomId: "room-1", after: new Date(Number.NaN) }))
|
|
81
|
+
.rejects.toThrow(RangeError);
|
|
82
|
+
expect(query).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const databaseUrl = process.env.TEST_DATABASE_URL;
|
|
87
|
+
const describeIfDatabase = databaseUrl ? describe : describe.skip;
|
|
88
|
+
|
|
89
|
+
describeIfDatabase("PostgreSQLProjectRoomReadStateStore", () => {
|
|
90
|
+
let pool: Pool;
|
|
91
|
+
let store: PostgreSQLProjectRoomReadStateStore;
|
|
92
|
+
let messages: PostgreSQLProjectRoomMessageStore;
|
|
93
|
+
const tenantId = `read-state-${randomUUID()}`;
|
|
94
|
+
|
|
95
|
+
beforeAll(async () => {
|
|
96
|
+
pool = new Pool({ connectionString: databaseUrl });
|
|
97
|
+
const mm = new MigrationManager(pool);
|
|
98
|
+
mm.register(createProjectRoomTables);
|
|
99
|
+
mm.register(createProjectRoomReadStates);
|
|
100
|
+
await mm.migrate();
|
|
101
|
+
store = new PostgreSQLProjectRoomReadStateStore({ pool });
|
|
102
|
+
messages = new PostgreSQLProjectRoomMessageStore({ pool });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterAll(async () => {
|
|
106
|
+
await pool.query("DELETE FROM lattice_project_room_messages WHERE tenant_id = $1", [tenantId]);
|
|
107
|
+
await pool.query("DELETE FROM lattice_project_room_read_states WHERE tenant_id = $1", [tenantId]);
|
|
108
|
+
await pool.end();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns null before any report and upserts monotonically", async () => {
|
|
112
|
+
await expect(store.get(tenantId, "room-1", "u1")).resolves.toBeNull();
|
|
113
|
+
await store.markRead({ tenantId, roomId: "room-1", userId: "u1", lastReadAt: new Date("2026-01-02T00:00:00Z") });
|
|
114
|
+
await store.markRead({ tenantId, roomId: "room-1", userId: "u1", lastReadAt: new Date("2026-01-01T00:00:00Z") });
|
|
115
|
+
const state = await store.get(tenantId, "room-1", "u1");
|
|
116
|
+
expect(state?.lastReadAt.toISOString()).toBe("2026-01-02T00:00:00.000Z");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("counts messages after a horizon excluding one human author", async () => {
|
|
120
|
+
const roomId = `room-${randomUUID()}`;
|
|
121
|
+
const base = (id: string, author: ProjectRoomMessage["author"]): Omit<ProjectRoomMessage, "createdAt"> => ({
|
|
122
|
+
id, tenantId, workspaceId: "w1", projectId: "p1", roomId, author,
|
|
123
|
+
content: { type: "text", text: id }, mentions: [], source: "user",
|
|
124
|
+
});
|
|
125
|
+
await messages.create({ ...base(`m0-${roomId}`, { type: "human", userId: "u2" }) });
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
127
|
+
await messages.create({ ...base(`m1-${roomId}`, { type: "human", userId: "u1" }) });
|
|
128
|
+
await messages.create({ ...base(`m2-${roomId}`, { type: "human", userId: "u2" }) });
|
|
129
|
+
await messages.create({ ...base(`m3-${roomId}`, { type: "bot", membershipId: "b1", assistantId: "a1" }) });
|
|
130
|
+
const first = await messages.findById(tenantId, `m0-${roomId}`);
|
|
131
|
+
const count = await messages.countAfter({
|
|
132
|
+
tenantId, roomId, after: first!.createdAt, excludeAuthorUserId: "u1",
|
|
133
|
+
});
|
|
134
|
+
expect(count).toBe(2);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -80,13 +80,15 @@ import {
|
|
|
80
80
|
addProjectLifecycleEventIndex,
|
|
81
81
|
} from "./migrations/task_work_items_migration";
|
|
82
82
|
import { createAgentWebAppsTable } from "./migrations/agent_web_apps_migration";
|
|
83
|
+
import { addAgentWebAppsIdentity } from "./migrations/add_agent_web_apps_identity_migration";
|
|
83
84
|
import { createCapabilityBundlesTable } from "./migrations/capability_bundle_migration";
|
|
84
|
-
import { createProjectRoomTables } from "./migrations/project_room_migration";
|
|
85
|
+
import { createProjectRoomReadStates, createProjectRoomTables } from "./migrations/project_room_migration";
|
|
85
86
|
import { PostgreSQLCapabilityBundleStore } from "./stores/PostgreSQLCapabilityBundleStore";
|
|
86
87
|
import { PostgreSQLProjectRoomStore } from "./stores/PostgreSQLProjectRoomStore";
|
|
87
88
|
import { PostgreSQLProjectMembershipStore } from "./stores/PostgreSQLProjectMembershipStore";
|
|
88
89
|
import { PostgreSQLProjectBotMembershipStore } from "./stores/PostgreSQLProjectBotMembershipStore";
|
|
89
90
|
import { PostgreSQLProjectRoomMessageStore } from "./stores/PostgreSQLProjectRoomMessageStore";
|
|
91
|
+
import { PostgreSQLProjectRoomReadStateStore } from "./stores/PostgreSQLProjectRoomReadStateStore";
|
|
90
92
|
|
|
91
93
|
export async function createPgStoreConfig(connectionString: string) {
|
|
92
94
|
const pool = new Pool({ connectionString });
|
|
@@ -153,6 +155,8 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
153
155
|
mm.register(createProjectRoomTables); // v172
|
|
154
156
|
mm.register(addTrustedRunContextColumn); // v173
|
|
155
157
|
mm.register(addProjectLifecycleEventIndex); // v175
|
|
158
|
+
mm.register(addAgentWebAppsIdentity); // v176
|
|
159
|
+
mm.register(createProjectRoomReadStates); // v177
|
|
156
160
|
|
|
157
161
|
await mm.migrate();
|
|
158
162
|
|
|
@@ -195,6 +199,7 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
195
199
|
projectMembership: new PostgreSQLProjectMembershipStore(opts),
|
|
196
200
|
projectBotMembership: new PostgreSQLProjectBotMembershipStore(opts),
|
|
197
201
|
projectRoomMessage: new PostgreSQLProjectRoomMessageStore(opts),
|
|
202
|
+
projectRoomReadState: new PostgreSQLProjectRoomReadStateStore(opts),
|
|
198
203
|
vectorStoreProvider: new PGVectorStoreProvider(pool, connectionString),
|
|
199
204
|
checkpoint,
|
|
200
205
|
};
|
package/src/index.ts
CHANGED
|
@@ -75,6 +75,7 @@ export * from "./stores/PostgreSQLProjectRoomStore";
|
|
|
75
75
|
export * from "./stores/PostgreSQLProjectMembershipStore";
|
|
76
76
|
export * from "./stores/PostgreSQLProjectBotMembershipStore";
|
|
77
77
|
export * from "./stores/PostgreSQLProjectRoomMessageStore";
|
|
78
|
+
export * from "./stores/PostgreSQLProjectRoomReadStateStore";
|
|
78
79
|
export * from "./migrations/project_room_migration";
|
|
79
80
|
|
|
80
81
|
export * from "./PGVectorStoreProvider";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PoolClient } from "pg";
|
|
2
|
+
|
|
3
|
+
import type { Migration } from "./migration";
|
|
4
|
+
|
|
5
|
+
export const addAgentWebAppsIdentity: Migration = {
|
|
6
|
+
version: 176,
|
|
7
|
+
name: "add_agent_web_apps_identity",
|
|
8
|
+
up: async (client: PoolClient) => {
|
|
9
|
+
await client.query(
|
|
10
|
+
"ALTER TABLE lattice_agent_web_apps ADD COLUMN IF NOT EXISTS identity JSONB",
|
|
11
|
+
);
|
|
12
|
+
},
|
|
13
|
+
down: async (client: PoolClient) => {
|
|
14
|
+
await client.query(
|
|
15
|
+
"ALTER TABLE lattice_agent_web_apps DROP COLUMN IF EXISTS identity",
|
|
16
|
+
);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -126,3 +126,29 @@ export const createProjectRoomTables: Migration = {
|
|
|
126
126
|
await client.query("DROP TABLE IF EXISTS lattice_project_rooms");
|
|
127
127
|
},
|
|
128
128
|
};
|
|
129
|
+
|
|
130
|
+
/** Adds per-user, per-room read markers for unread counts. */
|
|
131
|
+
export const createProjectRoomReadStates: Migration = {
|
|
132
|
+
version: 177,
|
|
133
|
+
name: "create_project_room_read_states",
|
|
134
|
+
up: async (client) => {
|
|
135
|
+
await client.query(`
|
|
136
|
+
CREATE TABLE IF NOT EXISTS lattice_project_room_read_states (
|
|
137
|
+
tenant_id VARCHAR(255) NOT NULL,
|
|
138
|
+
room_id VARCHAR(255) NOT NULL,
|
|
139
|
+
user_id VARCHAR(255) NOT NULL,
|
|
140
|
+
last_read_at TIMESTAMPTZ NOT NULL,
|
|
141
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
142
|
+
PRIMARY KEY (tenant_id, room_id, user_id)
|
|
143
|
+
)
|
|
144
|
+
`);
|
|
145
|
+
await client.query(`
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_lattice_project_room_read_states_user
|
|
147
|
+
ON lattice_project_room_read_states (tenant_id, user_id)
|
|
148
|
+
`);
|
|
149
|
+
},
|
|
150
|
+
down: async (client) => {
|
|
151
|
+
await client.query("DROP INDEX IF EXISTS idx_lattice_project_room_read_states_user");
|
|
152
|
+
await client.query("DROP TABLE IF EXISTS lattice_project_room_read_states");
|
|
153
|
+
},
|
|
154
|
+
};
|
|
@@ -14,6 +14,7 @@ import { Pool } from "pg";
|
|
|
14
14
|
import type { PoolConfig } from "pg";
|
|
15
15
|
|
|
16
16
|
import { createAgentWebAppsTable } from "../migrations/agent_web_apps_migration";
|
|
17
|
+
import { addAgentWebAppsIdentity } from "../migrations/add_agent_web_apps_identity_migration";
|
|
17
18
|
import { MigrationManager } from "../migrations/migration";
|
|
18
19
|
|
|
19
20
|
export interface PostgreSQLAgentWebAppStoreOptions {
|
|
@@ -33,6 +34,7 @@ type AgentWebAppRow = {
|
|
|
33
34
|
scope: unknown;
|
|
34
35
|
features: unknown;
|
|
35
36
|
appearance: unknown;
|
|
37
|
+
identity: unknown;
|
|
36
38
|
created_at: Date;
|
|
37
39
|
updated_at: Date;
|
|
38
40
|
};
|
|
@@ -64,7 +66,8 @@ function isFeatures(value: unknown): value is AgentWebAppFeatures {
|
|
|
64
66
|
&& typeof value.threadManagement === "boolean"
|
|
65
67
|
&& typeof value.attachments === "boolean"
|
|
66
68
|
&& typeof value.hitl === "boolean"
|
|
67
|
-
&& typeof value.genUI === "boolean"
|
|
69
|
+
&& typeof value.genUI === "boolean"
|
|
70
|
+
&& (value.files === undefined || typeof value.files === "boolean");
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
function isAppearance(value: unknown): value is AgentWebAppAppearance {
|
|
@@ -74,6 +77,18 @@ function isAppearance(value: unknown): value is AgentWebAppAppearance {
|
|
|
74
77
|
&& (value.primaryColor === undefined || typeof value.primaryColor === "string");
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
function isIdentity(value: unknown): boolean {
|
|
81
|
+
if (value === null || value === undefined) return true;
|
|
82
|
+
if (!isRecord(value)) return false;
|
|
83
|
+
const policyOk = value.policy === "unverified" || value.policy === "verified" || value.policy === "both";
|
|
84
|
+
const issuersOk = Array.isArray(value.issuers)
|
|
85
|
+
&& value.issuers.every((issuer) => isRecord(issuer)
|
|
86
|
+
&& typeof issuer.iss === "string"
|
|
87
|
+
&& typeof issuer.aud === "string"
|
|
88
|
+
&& (issuer.alg === "HS256" || issuer.alg === "RS256"));
|
|
89
|
+
return policyOk && issuersOk;
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
function isStatus(value: unknown): value is AgentWebAppStatus {
|
|
78
93
|
return value === "draft" || value === "active" || value === "disabled";
|
|
79
94
|
}
|
|
@@ -85,6 +100,7 @@ function mapRow(row: AgentWebAppRow): AgentWebApp {
|
|
|
85
100
|
|| !isScope(row.scope)
|
|
86
101
|
|| !isFeatures(row.features)
|
|
87
102
|
|| !isAppearance(row.appearance)
|
|
103
|
+
|| !isIdentity(row.identity)
|
|
88
104
|
) {
|
|
89
105
|
throw new Error(`Invalid agent web app row: ${row.id}`);
|
|
90
106
|
}
|
|
@@ -100,6 +116,7 @@ function mapRow(row: AgentWebAppRow): AgentWebApp {
|
|
|
100
116
|
scope: row.scope,
|
|
101
117
|
features: row.features,
|
|
102
118
|
appearance: row.appearance,
|
|
119
|
+
identity: row.identity === null ? undefined : (row.identity as AgentWebApp["identity"]),
|
|
103
120
|
createdAt: row.created_at,
|
|
104
121
|
updatedAt: row.updated_at,
|
|
105
122
|
};
|
|
@@ -128,6 +145,7 @@ export class PostgreSQLAgentWebAppStore implements AgentWebAppStore {
|
|
|
128
145
|
|
|
129
146
|
this.migrationManager = new MigrationManager(this.pool);
|
|
130
147
|
this.migrationManager.register(createAgentWebAppsTable);
|
|
148
|
+
this.migrationManager.register(addAgentWebAppsIdentity);
|
|
131
149
|
|
|
132
150
|
if (options.autoMigrate !== false) {
|
|
133
151
|
this.startInitialization();
|
|
@@ -200,8 +218,8 @@ export class PostgreSQLAgentWebAppStore implements AgentWebAppStore {
|
|
|
200
218
|
const id = `webapp_${randomUUID().replace(/-/g, "")}`;
|
|
201
219
|
const result = await this.pool.query<AgentWebAppRow>(
|
|
202
220
|
`INSERT INTO lattice_agent_web_apps
|
|
203
|
-
(id, tenant_id, assistant_id, name, description, status, integration, scope, features, appearance)
|
|
204
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb)
|
|
221
|
+
(id, tenant_id, assistant_id, name, description, status, integration, scope, features, appearance, identity)
|
|
222
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb)
|
|
205
223
|
RETURNING *`,
|
|
206
224
|
[
|
|
207
225
|
id,
|
|
@@ -214,6 +232,7 @@ export class PostgreSQLAgentWebAppStore implements AgentWebAppStore {
|
|
|
214
232
|
JSON.stringify(input.scope),
|
|
215
233
|
JSON.stringify(input.features),
|
|
216
234
|
JSON.stringify(input.appearance),
|
|
235
|
+
input.identity ? JSON.stringify(input.identity) : null,
|
|
217
236
|
],
|
|
218
237
|
);
|
|
219
238
|
return mapRow(result.rows[0]);
|
|
@@ -246,6 +265,9 @@ export class PostgreSQLAgentWebAppStore implements AgentWebAppStore {
|
|
|
246
265
|
if (patch.appearance !== undefined) {
|
|
247
266
|
add("appearance", JSON.stringify(patch.appearance));
|
|
248
267
|
}
|
|
268
|
+
if (patch.identity !== undefined) {
|
|
269
|
+
add("identity", JSON.stringify(patch.identity));
|
|
270
|
+
}
|
|
249
271
|
if (patch.status !== undefined) add("status", patch.status);
|
|
250
272
|
|
|
251
273
|
if (assignments.length === 0) return this.getById(tenantId, webAppId);
|
|
@@ -464,6 +464,20 @@ export class PostgreSQLMcpServerConfigStore implements McpServerConfigStore {
|
|
|
464
464
|
}
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
+
// Decrypt header values if present and encrypted
|
|
468
|
+
if (config.headers && row.is_env_encrypted) {
|
|
469
|
+
try {
|
|
470
|
+
const decryptedHeaders: Record<string, string> = {};
|
|
471
|
+
for (const [key, value] of Object.entries(config.headers)) {
|
|
472
|
+
decryptedHeaders[key] = decrypt(value);
|
|
473
|
+
}
|
|
474
|
+
config.headers = decryptedHeaders;
|
|
475
|
+
} catch (error) {
|
|
476
|
+
console.error('Failed to decrypt MCP server headers:', error);
|
|
477
|
+
throw new Error('Failed to decrypt MCP server configuration');
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
467
481
|
return {
|
|
468
482
|
id: row.id,
|
|
469
483
|
tenantId: row.tenant_id,
|
|
@@ -495,6 +509,15 @@ export class PostgreSQLMcpServerConfigStore implements McpServerConfigStore {
|
|
|
495
509
|
isEnvEncrypted = true;
|
|
496
510
|
}
|
|
497
511
|
|
|
512
|
+
if (configCopy.headers && Object.keys(configCopy.headers).length > 0) {
|
|
513
|
+
const encryptedHeaders: Record<string, string> = {};
|
|
514
|
+
for (const [key, value] of Object.entries(configCopy.headers)) {
|
|
515
|
+
encryptedHeaders[key] = encrypt(value);
|
|
516
|
+
}
|
|
517
|
+
configCopy.headers = encryptedHeaders;
|
|
518
|
+
isEnvEncrypted = true;
|
|
519
|
+
}
|
|
520
|
+
|
|
498
521
|
return { config: configCopy, isEnvEncrypted };
|
|
499
522
|
}
|
|
500
523
|
}
|
|
@@ -170,6 +170,22 @@ export class PostgreSQLProjectRoomMessageStore implements ProjectRoomMessageStor
|
|
|
170
170
|
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/** Counts messages created strictly after a horizon, optionally excluding one human author. */
|
|
174
|
+
async countAfter(input: {
|
|
175
|
+
tenantId: string; roomId: string; after: Date; excludeAuthorUserId?: string;
|
|
176
|
+
}): Promise<number> {
|
|
177
|
+
if (!(input.after instanceof Date) || Number.isNaN(input.after.getTime())) {
|
|
178
|
+
throw new RangeError("Project room message count horizon date is invalid");
|
|
179
|
+
}
|
|
180
|
+
const result = await this.pool.query<{ count: number }>(
|
|
181
|
+
`SELECT COUNT(*)::int AS count FROM lattice_project_room_messages
|
|
182
|
+
WHERE tenant_id = $1 AND room_id = $2 AND created_at > $3
|
|
183
|
+
AND ($4::text IS NULL OR author->>'type' <> 'human' OR author->>'userId' <> $4)`,
|
|
184
|
+
[input.tenantId, input.roomId, input.after, input.excludeAuthorUserId ?? null],
|
|
185
|
+
);
|
|
186
|
+
return result.rows[0]?.count ?? 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
173
189
|
private parameters(input: Omit<ProjectRoomMessage, "createdAt">): unknown[] {
|
|
174
190
|
return [input.id, input.tenantId, input.workspaceId, input.projectId, input.roomId, JSON.stringify(input.author), JSON.stringify(input.content),
|
|
175
191
|
JSON.stringify(input.mentions), input.replyToMessageId ?? null, input.source, input.sourceId ?? null, input.idempotencyKey ?? null];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ProjectRoomReadState, ProjectRoomReadStateStore } from "@axiom-lattice/protocols";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
|
|
4
|
+
type ReadStateRow = { tenant_id: unknown; room_id: unknown; user_id: unknown; last_read_at: unknown; updated_at: unknown };
|
|
5
|
+
|
|
6
|
+
function mapRow(row: ReadStateRow): ProjectRoomReadState {
|
|
7
|
+
if (typeof row.tenant_id !== "string" || typeof row.room_id !== "string" || typeof row.user_id !== "string"
|
|
8
|
+
|| !(row.last_read_at instanceof Date) || Number.isNaN(row.last_read_at.getTime())
|
|
9
|
+
|| !(row.updated_at instanceof Date) || Number.isNaN(row.updated_at.getTime())) {
|
|
10
|
+
throw new Error("Invalid project room read state row");
|
|
11
|
+
}
|
|
12
|
+
return { tenantId: row.tenant_id, roomId: row.room_id, userId: row.user_id, lastReadAt: row.last_read_at, updatedAt: row.updated_at };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Persists tenant-isolated per-user project room read markers in PostgreSQL. */
|
|
16
|
+
export class PostgreSQLProjectRoomReadStateStore implements ProjectRoomReadStateStore {
|
|
17
|
+
/** Creates a store using an externally managed shared pool. */
|
|
18
|
+
constructor(options: { pool: Pool }) { this.pool = options.pool; }
|
|
19
|
+
private readonly pool: Pool;
|
|
20
|
+
|
|
21
|
+
/** Returns the user's read marker for a room, or null when never reported. */
|
|
22
|
+
async get(tenantId: string, roomId: string, userId: string): Promise<ProjectRoomReadState | null> {
|
|
23
|
+
const result = await this.pool.query<ReadStateRow>(
|
|
24
|
+
`SELECT tenant_id, room_id, user_id, last_read_at, updated_at
|
|
25
|
+
FROM lattice_project_room_read_states WHERE tenant_id = $1 AND room_id = $2 AND user_id = $3`,
|
|
26
|
+
[tenantId, roomId, userId],
|
|
27
|
+
);
|
|
28
|
+
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Upserts the marker monotonically using GREATEST so older reports never regress it. */
|
|
32
|
+
async markRead(input: { tenantId: string; roomId: string; userId: string; lastReadAt: Date }): Promise<ProjectRoomReadState> {
|
|
33
|
+
if (!(input.lastReadAt instanceof Date) || Number.isNaN(input.lastReadAt.getTime())) {
|
|
34
|
+
throw new RangeError("Project room read state lastReadAt is invalid");
|
|
35
|
+
}
|
|
36
|
+
const result = await this.pool.query<ReadStateRow>(
|
|
37
|
+
`INSERT INTO lattice_project_room_read_states (tenant_id, room_id, user_id, last_read_at, updated_at)
|
|
38
|
+
VALUES ($1, $2, $3, $4, NOW())
|
|
39
|
+
ON CONFLICT (tenant_id, room_id, user_id) DO UPDATE
|
|
40
|
+
SET last_read_at = GREATEST(lattice_project_room_read_states.last_read_at, EXCLUDED.last_read_at),
|
|
41
|
+
updated_at = NOW()
|
|
42
|
+
RETURNING tenant_id, room_id, user_id, last_read_at, updated_at`,
|
|
43
|
+
[input.tenantId, input.roomId, input.userId, input.lastReadAt],
|
|
44
|
+
);
|
|
45
|
+
return mapRow(result.rows[0]);
|
|
46
|
+
}
|
|
47
|
+
}
|