@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/pg-stores",
3
- "version": "3.1.5",
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.5",
29
- "@axiom-lattice/protocols": "4.1.3"
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'))",
@@ -76,6 +76,7 @@ describe("PostgreSQLAgentWebAppStore", () => {
76
76
  JSON.stringify(input.scope),
77
77
  JSON.stringify(input.features),
78
78
  JSON.stringify(input.appearance),
79
+ null,
79
80
  ]);
80
81
  expect(result).toEqual({
81
82
  id: "webapp_123",
@@ -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
+ }