@axiom-lattice/pg-stores 3.1.6 → 3.1.9
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 +54 -2
- package/dist/index.d.ts +54 -2
- package/dist/index.js +424 -47
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +421 -47
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLA2AApiKeyStore.migrations.test.ts +2 -1
- package/src/__tests__/PostgreSQLA2AApiKeyStore.test.ts +45 -0
- package/src/__tests__/PostgreSQLMcpServerConfigStore.test.ts +79 -0
- package/src/__tests__/PostgreSQLOpenAuditStore.test.ts +100 -0
- package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +14 -0
- package/src/__tests__/PostgreSQLProjectRoomReadStateStore.test.ts +136 -0
- package/src/__tests__/PostgresSharedResourceStore.openGrants.test.ts +62 -0
- package/src/__tests__/create_open_audit_table.test.ts +40 -0
- package/src/createPgStoreConfig.ts +12 -1
- package/src/index.ts +2 -0
- package/src/migrations/add_api_key_grants.ts +25 -0
- package/src/migrations/add_share_open_grants.ts +23 -0
- package/src/migrations/create_open_audit_table.ts +36 -0
- package/src/migrations/project_room_migration.ts +26 -0
- package/src/stores/PostgreSQLA2AApiKeyStore.ts +90 -14
- package/src/stores/PostgreSQLMcpServerConfigStore.ts +23 -0
- package/src/stores/PostgreSQLOpenAuditStore.ts +143 -0
- package/src/stores/PostgreSQLProjectRoomMessageStore.ts +36 -1
- package/src/stores/PostgreSQLProjectRoomReadStateStore.ts +47 -0
- package/src/stores/PostgresSharedResourceStore.ts +5 -2
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Migration } from "./migration";
|
|
2
|
+
import type { PoolClient } from "pg";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Append-only audit log for the Open API surface (one row per invoke).
|
|
6
|
+
*/
|
|
7
|
+
export const createOpenAuditTable: Migration = {
|
|
8
|
+
version: 179,
|
|
9
|
+
name: "create_open_audit_table",
|
|
10
|
+
up: async (client: PoolClient) => {
|
|
11
|
+
await client.query(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS lattice_open_audit (
|
|
13
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
14
|
+
tenant_id VARCHAR(255) NOT NULL,
|
|
15
|
+
credential_id VARCHAR(255) NOT NULL,
|
|
16
|
+
credential_kind VARCHAR(32) NOT NULL,
|
|
17
|
+
project_id VARCHAR(255) NOT NULL,
|
|
18
|
+
domain VARCHAR(255) NOT NULL,
|
|
19
|
+
action VARCHAR(255) NOT NULL,
|
|
20
|
+
args JSONB,
|
|
21
|
+
status VARCHAR(16) NOT NULL,
|
|
22
|
+
error_code VARCHAR(64),
|
|
23
|
+
duration_ms INTEGER NOT NULL,
|
|
24
|
+
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
25
|
+
)
|
|
26
|
+
`);
|
|
27
|
+
|
|
28
|
+
await client.query(`
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_lattice_open_audit_tenant_created
|
|
30
|
+
ON lattice_open_audit(tenant_id, created_at DESC)
|
|
31
|
+
`);
|
|
32
|
+
},
|
|
33
|
+
down: async (client: PoolClient) => {
|
|
34
|
+
await client.query("DROP TABLE IF EXISTS lattice_open_audit");
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Pool } from "pg";
|
|
2
2
|
import type { PoolConfig } from "pg";
|
|
3
3
|
import type {
|
|
4
|
+
UpdateA2AApiKeyInput,
|
|
4
5
|
A2AApiKeyStore,
|
|
5
6
|
A2AApiKeyRecord,
|
|
6
7
|
CreateA2AApiKeyInput,
|
|
@@ -9,6 +10,7 @@ import type {
|
|
|
9
10
|
import { MigrationManager } from "../migrations/migration";
|
|
10
11
|
import { createA2AApiKeysTable } from "../migrations/a2a_api_key_migration";
|
|
11
12
|
import { addA2AKeyAssistantIds } from "../migrations/add_a2a_key_assistant_ids";
|
|
13
|
+
import { addApiKeyGrants } from "../migrations/add_api_key_grants";
|
|
12
14
|
import { encrypt, decrypt } from "@axiom-lattice/core";
|
|
13
15
|
import { randomUUID } from "crypto";
|
|
14
16
|
|
|
@@ -24,6 +26,7 @@ type KeyRow = {
|
|
|
24
26
|
tenant_id: string;
|
|
25
27
|
project_id: string;
|
|
26
28
|
assistant_ids: string[] | null;
|
|
29
|
+
grants: unknown[] | null;
|
|
27
30
|
label: string | null;
|
|
28
31
|
enabled: boolean;
|
|
29
32
|
created_at: Date;
|
|
@@ -41,6 +44,7 @@ function mapRowToRecord(row: KeyRow): A2AApiKeyRecord {
|
|
|
41
44
|
tenantId: row.tenant_id,
|
|
42
45
|
projectId: row.project_id,
|
|
43
46
|
assistantIds: row.assistant_ids ?? undefined,
|
|
47
|
+
grants: (row.grants as A2AApiKeyRecord["grants"]) ?? undefined,
|
|
44
48
|
label: row.label || undefined,
|
|
45
49
|
enabled: row.enabled,
|
|
46
50
|
createdAt: row.created_at,
|
|
@@ -78,6 +82,7 @@ export class PostgreSQLA2AApiKeyStore implements A2AApiKeyStore {
|
|
|
78
82
|
// overlap is safe.
|
|
79
83
|
this.migrationManager.register(createA2AApiKeysTable);
|
|
80
84
|
this.migrationManager.register(addA2AKeyAssistantIds);
|
|
85
|
+
this.migrationManager.register(addApiKeyGrants);
|
|
81
86
|
|
|
82
87
|
if (options.autoMigrate !== false) {
|
|
83
88
|
this.initialize().catch((error) => {
|
|
@@ -111,13 +116,31 @@ export class PostgreSQLA2AApiKeyStore implements A2AApiKeyStore {
|
|
|
111
116
|
if (!this.initialized) await this.initialize();
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
/** Map rows that decrypt cleanly; skip rows under an unknown key. */
|
|
120
|
+
private safeMap(rows: KeyRow[]): A2AApiKeyRecord[] {
|
|
121
|
+
const out: A2AApiKeyRecord[] = [];
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
try {
|
|
124
|
+
out.push(mapRowToRecord(row));
|
|
125
|
+
} catch {
|
|
126
|
+
// Skip undecryptable rows.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
114
132
|
async findByKey(key: string): Promise<A2AApiKeyRecord | null> {
|
|
115
133
|
await this.ensureInitialized();
|
|
116
134
|
const all = await this.pool.query<KeyRow>(
|
|
117
135
|
`SELECT * FROM lattice_a2a_api_keys WHERE enabled = true`,
|
|
118
136
|
);
|
|
119
137
|
for (const row of all.rows) {
|
|
120
|
-
|
|
138
|
+
// A row encrypted under a different key must not break every lookup.
|
|
139
|
+
try {
|
|
140
|
+
if (decrypt(row.key_value) === key) return mapRowToRecord(row);
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
121
144
|
}
|
|
122
145
|
return null;
|
|
123
146
|
}
|
|
@@ -128,7 +151,12 @@ export class PostgreSQLA2AApiKeyStore implements A2AApiKeyStore {
|
|
|
128
151
|
`SELECT * FROM lattice_a2a_api_keys WHERE id = $1`,
|
|
129
152
|
[id],
|
|
130
153
|
);
|
|
131
|
-
|
|
154
|
+
if (!result.rows[0]) return null;
|
|
155
|
+
try {
|
|
156
|
+
return mapRowToRecord(result.rows[0]);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
132
160
|
}
|
|
133
161
|
|
|
134
162
|
async list(params: { tenantId?: string; limit?: number; offset?: number }): Promise<A2AApiKeyRecord[]> {
|
|
@@ -140,28 +168,71 @@ export class PostgreSQLA2AApiKeyStore implements A2AApiKeyStore {
|
|
|
140
168
|
`SELECT * FROM lattice_a2a_api_keys WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
|
|
141
169
|
[params.tenantId, limit, offset],
|
|
142
170
|
);
|
|
143
|
-
return result.rows
|
|
171
|
+
return this.safeMap(result.rows);
|
|
144
172
|
}
|
|
145
173
|
const result = await this.pool.query<KeyRow>(
|
|
146
174
|
`SELECT * FROM lattice_a2a_api_keys ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
|
|
147
175
|
[limit, offset],
|
|
148
176
|
);
|
|
149
|
-
return result.rows
|
|
177
|
+
return this.safeMap(result.rows);
|
|
150
178
|
}
|
|
151
179
|
|
|
152
180
|
async create(input: CreateA2AApiKeyInput): Promise<A2AApiKeyRecord> {
|
|
153
181
|
await this.ensureInitialized();
|
|
154
182
|
const key = generateApiKey();
|
|
155
183
|
const result = await this.pool.query<KeyRow>(
|
|
156
|
-
`INSERT INTO lattice_a2a_api_keys (key_value, tenant_id, project_id, assistant_ids, label)
|
|
157
|
-
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
|
158
|
-
[
|
|
184
|
+
`INSERT INTO lattice_a2a_api_keys (key_value, tenant_id, project_id, assistant_ids, grants, label)
|
|
185
|
+
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
|
186
|
+
[
|
|
187
|
+
encrypt(key),
|
|
188
|
+
input.tenantId,
|
|
189
|
+
input.projectId,
|
|
190
|
+
input.assistantIds ? JSON.stringify(input.assistantIds) : null,
|
|
191
|
+
input.grants ? JSON.stringify(input.grants) : null,
|
|
192
|
+
input.label || null,
|
|
193
|
+
],
|
|
159
194
|
);
|
|
160
195
|
const record = mapRowToRecord(result.rows[0]);
|
|
161
196
|
record.key = key;
|
|
162
197
|
return record;
|
|
163
198
|
}
|
|
164
199
|
|
|
200
|
+
|
|
201
|
+
async update(id: string, input: UpdateA2AApiKeyInput): Promise<A2AApiKeyRecord> {
|
|
202
|
+
await this.ensureInitialized();
|
|
203
|
+
const sets: string[] = [];
|
|
204
|
+
const values: unknown[] = [];
|
|
205
|
+
if (input.label !== undefined) {
|
|
206
|
+
values.push(input.label || null);
|
|
207
|
+
sets.push(`label = $${values.length}`);
|
|
208
|
+
}
|
|
209
|
+
if (input.projectId !== undefined) {
|
|
210
|
+
values.push(input.projectId);
|
|
211
|
+
sets.push(`project_id = $${values.length}`);
|
|
212
|
+
}
|
|
213
|
+
if (input.assistantIds !== undefined) {
|
|
214
|
+
values.push(input.assistantIds ? JSON.stringify(input.assistantIds) : null);
|
|
215
|
+
sets.push(`assistant_ids = $${values.length}`);
|
|
216
|
+
}
|
|
217
|
+
if (input.grants !== undefined) {
|
|
218
|
+
values.push(input.grants ? JSON.stringify(input.grants) : null);
|
|
219
|
+
sets.push(`grants = $${values.length}`);
|
|
220
|
+
}
|
|
221
|
+
if (sets.length === 0) {
|
|
222
|
+
const existing = await this.findById(id);
|
|
223
|
+
if (!existing) throw new Error(`A2A API key not found: ${id}`);
|
|
224
|
+
return existing;
|
|
225
|
+
}
|
|
226
|
+
sets.push("updated_at = NOW()");
|
|
227
|
+
values.push(id);
|
|
228
|
+
const result = await this.pool.query<KeyRow>(
|
|
229
|
+
`UPDATE lattice_a2a_api_keys SET ${sets.join(", ")} WHERE id = $${values.length} RETURNING *`,
|
|
230
|
+
values,
|
|
231
|
+
);
|
|
232
|
+
if (result.rows.length === 0) throw new Error(`A2A API key not found: ${id}`);
|
|
233
|
+
return mapRowToRecord(result.rows[0]);
|
|
234
|
+
}
|
|
235
|
+
|
|
165
236
|
async disable(id: string): Promise<A2AApiKeyRecord> {
|
|
166
237
|
await this.ensureInitialized();
|
|
167
238
|
const result = await this.pool.query<KeyRow>(
|
|
@@ -207,13 +278,18 @@ export class PostgreSQLA2AApiKeyStore implements A2AApiKeyStore {
|
|
|
207
278
|
);
|
|
208
279
|
const map = new Map<string, A2AApiKeyEntry>();
|
|
209
280
|
for (const row of result.rows) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
key,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
281
|
+
try {
|
|
282
|
+
const key = decrypt(row.key_value);
|
|
283
|
+
map.set(key, {
|
|
284
|
+
key,
|
|
285
|
+
tenantId: row.tenant_id,
|
|
286
|
+
projectId: row.project_id,
|
|
287
|
+
assistantIds: row.assistant_ids ?? undefined,
|
|
288
|
+
grants: (row.grants as A2AApiKeyEntry["grants"]) ?? undefined,
|
|
289
|
+
});
|
|
290
|
+
} catch {
|
|
291
|
+
// Skip rows encrypted under an unknown key.
|
|
292
|
+
}
|
|
217
293
|
}
|
|
218
294
|
return map;
|
|
219
295
|
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Pool } from "pg";
|
|
2
|
+
import type { PoolConfig } from "pg";
|
|
3
|
+
import type {
|
|
4
|
+
OpenAuditStore,
|
|
5
|
+
OpenAuditAppendInput,
|
|
6
|
+
OpenAuditQuery,
|
|
7
|
+
OpenAuditRecord,
|
|
8
|
+
} from "@axiom-lattice/protocols";
|
|
9
|
+
import { MigrationManager } from "../migrations/migration";
|
|
10
|
+
import { createOpenAuditTable } from "../migrations/create_open_audit_table";
|
|
11
|
+
|
|
12
|
+
export interface PostgreSQLOpenAuditStoreOptions {
|
|
13
|
+
pool?: Pool;
|
|
14
|
+
poolConfig?: string | PoolConfig;
|
|
15
|
+
autoMigrate?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Append-only PostgreSQL audit store for the Open API surface. */
|
|
19
|
+
export class PostgreSQLOpenAuditStore implements OpenAuditStore {
|
|
20
|
+
private pool: Pool;
|
|
21
|
+
private migrationManager!: MigrationManager;
|
|
22
|
+
private initialized = false;
|
|
23
|
+
private ownsPool = true;
|
|
24
|
+
private initPromise: Promise<void> | null = null;
|
|
25
|
+
|
|
26
|
+
constructor(options: PostgreSQLOpenAuditStoreOptions) {
|
|
27
|
+
if (options.pool) {
|
|
28
|
+
this.pool = options.pool;
|
|
29
|
+
this.ownsPool = false;
|
|
30
|
+
this.initialized = true;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.pool =
|
|
34
|
+
typeof options.poolConfig === "string"
|
|
35
|
+
? new Pool({ connectionString: options.poolConfig })
|
|
36
|
+
: options.poolConfig
|
|
37
|
+
? new Pool(options.poolConfig as PoolConfig)
|
|
38
|
+
: (() => {
|
|
39
|
+
throw new Error("Either pool or poolConfig must be provided");
|
|
40
|
+
})();
|
|
41
|
+
this.migrationManager = new MigrationManager(this.pool);
|
|
42
|
+
this.migrationManager.register(createOpenAuditTable);
|
|
43
|
+
if (options.autoMigrate !== false) {
|
|
44
|
+
this.initialize().catch((error) => {
|
|
45
|
+
console.error("Failed to initialize PostgreSQLOpenAuditStore:", error);
|
|
46
|
+
throw error;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async initialize(): Promise<void> {
|
|
52
|
+
if (this.initialized) return;
|
|
53
|
+
if (this.initPromise) return this.initPromise;
|
|
54
|
+
this.initPromise = (async () => {
|
|
55
|
+
try {
|
|
56
|
+
await this.migrationManager.migrate();
|
|
57
|
+
this.initialized = true;
|
|
58
|
+
} finally {
|
|
59
|
+
this.initPromise = null;
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
return this.initPromise;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async dispose(): Promise<void> {
|
|
66
|
+
if (this.ownsPool && this.pool) await this.pool.end();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async ensureInitialized(): Promise<void> {
|
|
70
|
+
if (!this.initialized) await this.initialize();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async query(params: OpenAuditQuery): Promise<OpenAuditRecord[]> {
|
|
74
|
+
await this.ensureInitialized();
|
|
75
|
+
const clauses = ["tenant_id = $1"];
|
|
76
|
+
const values: unknown[] = [params.tenantId];
|
|
77
|
+
if (params.credentialId) {
|
|
78
|
+
values.push(params.credentialId);
|
|
79
|
+
clauses.push(`credential_id = $${values.length}`);
|
|
80
|
+
}
|
|
81
|
+
if (params.domain) {
|
|
82
|
+
values.push(params.domain);
|
|
83
|
+
clauses.push(`domain = $${values.length}`);
|
|
84
|
+
}
|
|
85
|
+
if (params.status) {
|
|
86
|
+
values.push(params.status);
|
|
87
|
+
clauses.push(`status = $${values.length}`);
|
|
88
|
+
}
|
|
89
|
+
if (params.from) {
|
|
90
|
+
values.push(params.from);
|
|
91
|
+
clauses.push(`created_at >= $${values.length}`);
|
|
92
|
+
}
|
|
93
|
+
if (params.to) {
|
|
94
|
+
values.push(params.to);
|
|
95
|
+
clauses.push(`created_at < $${values.length}`);
|
|
96
|
+
}
|
|
97
|
+
const limit = params.limit ?? 100;
|
|
98
|
+
const offset = params.offset ?? 0;
|
|
99
|
+
values.push(limit, offset);
|
|
100
|
+
const { rows } = await this.pool.query(
|
|
101
|
+
`SELECT * FROM lattice_open_audit
|
|
102
|
+
WHERE ${clauses.join(" AND ")}
|
|
103
|
+
ORDER BY created_at DESC
|
|
104
|
+
LIMIT $${values.length - 1} OFFSET $${values.length}`,
|
|
105
|
+
values,
|
|
106
|
+
);
|
|
107
|
+
return rows.map((row) => ({
|
|
108
|
+
id: row.id,
|
|
109
|
+
tenantId: row.tenant_id,
|
|
110
|
+
credentialId: row.credential_id,
|
|
111
|
+
credentialKind: row.credential_kind,
|
|
112
|
+
projectId: row.project_id,
|
|
113
|
+
domain: row.domain,
|
|
114
|
+
action: row.action,
|
|
115
|
+
args: row.args,
|
|
116
|
+
status: row.status,
|
|
117
|
+
errorCode: row.error_code ?? undefined,
|
|
118
|
+
durationMs: row.duration_ms,
|
|
119
|
+
createdAt: row.created_at,
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async append(record: OpenAuditAppendInput): Promise<void> {
|
|
124
|
+
await this.ensureInitialized();
|
|
125
|
+
await this.pool.query(
|
|
126
|
+
`INSERT INTO lattice_open_audit
|
|
127
|
+
(tenant_id, credential_id, credential_kind, project_id, domain, action, args, status, error_code, duration_ms)
|
|
128
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
|
129
|
+
[
|
|
130
|
+
record.tenantId,
|
|
131
|
+
record.credentialId,
|
|
132
|
+
record.credentialKind,
|
|
133
|
+
record.projectId,
|
|
134
|
+
record.domain,
|
|
135
|
+
record.action,
|
|
136
|
+
record.args === undefined ? null : JSON.stringify(record.args),
|
|
137
|
+
record.status,
|
|
138
|
+
record.errorCode ?? null,
|
|
139
|
+
Math.max(0, Math.round(record.durationMs)),
|
|
140
|
+
],
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -83,6 +83,25 @@ function mapRow(row: MessageRow): ProjectRoomMessage {
|
|
|
83
83
|
...(row.idempotency_key === null ? {} : { idempotencyKey: row.idempotency_key }), createdAt: row.created_at,
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
+
function mapRowsTolerant(
|
|
87
|
+
rows: MessageRow[],
|
|
88
|
+
scope: { tenantId: string; roomId: string },
|
|
89
|
+
): ProjectRoomMessage[] {
|
|
90
|
+
const messages: ProjectRoomMessage[] = [];
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
try {
|
|
93
|
+
messages.push(mapRow(row));
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.warn("[ProjectRoomMessageStore] Skipping invalid message row", {
|
|
96
|
+
tenantId: scope.tenantId,
|
|
97
|
+
roomId: scope.roomId,
|
|
98
|
+
rowId: typeof row.id === "string" ? row.id : undefined,
|
|
99
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return messages;
|
|
104
|
+
}
|
|
86
105
|
function isUniqueViolation(error: unknown): error is PostgreSQLError {
|
|
87
106
|
return typeof error === "object" && error !== null && "code" in error
|
|
88
107
|
&& (error as PostgreSQLError).code === "23505";
|
|
@@ -159,7 +178,7 @@ export class PostgreSQLProjectRoomMessageStore implements ProjectRoomMessageStor
|
|
|
159
178
|
WHERE tenant_id = $1 AND room_id = $2 ORDER BY created_at DESC, id DESC LIMIT $3`,
|
|
160
179
|
[input.tenantId, input.roomId, limit],
|
|
161
180
|
);
|
|
162
|
-
return result.rows.
|
|
181
|
+
return mapRowsTolerant(result.rows, { tenantId: input.tenantId, roomId: input.roomId });
|
|
163
182
|
}
|
|
164
183
|
|
|
165
184
|
/** Finds a message by tenant-scoped ID. */
|
|
@@ -170,6 +189,22 @@ export class PostgreSQLProjectRoomMessageStore implements ProjectRoomMessageStor
|
|
|
170
189
|
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
171
190
|
}
|
|
172
191
|
|
|
192
|
+
/** Counts messages created strictly after a horizon, optionally excluding one human author. */
|
|
193
|
+
async countAfter(input: {
|
|
194
|
+
tenantId: string; roomId: string; after: Date; excludeAuthorUserId?: string;
|
|
195
|
+
}): Promise<number> {
|
|
196
|
+
if (!(input.after instanceof Date) || Number.isNaN(input.after.getTime())) {
|
|
197
|
+
throw new RangeError("Project room message count horizon date is invalid");
|
|
198
|
+
}
|
|
199
|
+
const result = await this.pool.query<{ count: number }>(
|
|
200
|
+
`SELECT COUNT(*)::int AS count FROM lattice_project_room_messages
|
|
201
|
+
WHERE tenant_id = $1 AND room_id = $2 AND created_at > $3
|
|
202
|
+
AND ($4::text IS NULL OR author->>'type' <> 'human' OR author->>'userId' <> $4)`,
|
|
203
|
+
[input.tenantId, input.roomId, input.after, input.excludeAuthorUserId ?? null],
|
|
204
|
+
);
|
|
205
|
+
return result.rows[0]?.count ?? 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
173
208
|
private parameters(input: Omit<ProjectRoomMessage, "createdAt">): unknown[] {
|
|
174
209
|
return [input.id, input.tenantId, input.workspaceId, input.projectId, input.roomId, JSON.stringify(input.author), JSON.stringify(input.content),
|
|
175
210
|
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
|
+
}
|
|
@@ -25,6 +25,7 @@ type ShareRow = {
|
|
|
25
25
|
password_hash: string | null;
|
|
26
26
|
expires_at: string | null;
|
|
27
27
|
max_access: number | null;
|
|
28
|
+
open_grants: unknown[] | null;
|
|
28
29
|
access_count: number;
|
|
29
30
|
revoked: boolean;
|
|
30
31
|
created_at: string;
|
|
@@ -106,8 +107,8 @@ export class PostgresSharedResourceStore implements SharedResourceStore {
|
|
|
106
107
|
`INSERT INTO lattice_shared_resources
|
|
107
108
|
(token, address, title, created_by, tenant_id, workspace_id, project_id,
|
|
108
109
|
assistant_id, vm_isolation, visibility, password_hash,
|
|
109
|
-
expires_at, max_access, revoked)
|
|
110
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
|
110
|
+
expires_at, max_access, open_grants, revoked)
|
|
111
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
|
111
112
|
RETURNING *`,
|
|
112
113
|
[
|
|
113
114
|
record.token,
|
|
@@ -123,6 +124,7 @@ export class PostgresSharedResourceStore implements SharedResourceStore {
|
|
|
123
124
|
record.passwordHash,
|
|
124
125
|
record.expiresAt ? record.expiresAt.toISOString() : null,
|
|
125
126
|
record.maxAccess,
|
|
127
|
+
record.openGrants ? JSON.stringify(record.openGrants) : null,
|
|
126
128
|
record.revoked,
|
|
127
129
|
],
|
|
128
130
|
);
|
|
@@ -229,6 +231,7 @@ export class PostgresSharedResourceStore implements SharedResourceStore {
|
|
|
229
231
|
passwordHash: row.password_hash,
|
|
230
232
|
expiresAt: row.expires_at ? new Date(row.expires_at) : null,
|
|
231
233
|
maxAccess: row.max_access,
|
|
234
|
+
openGrants: (row.open_grants as ShareRecord["openGrants"]) ?? undefined,
|
|
232
235
|
accessCount: row.access_count ?? 0,
|
|
233
236
|
revoked: row.revoked ?? false,
|
|
234
237
|
createdAt: new Date(row.created_at),
|