@axiom-lattice/local-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/local-stores",
3
- "version": "3.1.6",
3
+ "version": "3.1.9",
4
4
  "description": "Local SQLite-based stores for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -25,8 +25,8 @@
25
25
  "@types/sql.js": "^1.4.11",
26
26
  "sql.js": "^1.14.1",
27
27
  "uuid": "^14.0.1",
28
- "@axiom-lattice/core": "4.2.6",
29
- "@axiom-lattice/protocols": "4.1.4"
28
+ "@axiom-lattice/core": "4.2.9",
29
+ "@axiom-lattice/protocols": "4.2.1"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
@@ -116,4 +116,13 @@ describe("LocalA2AApiKeyStore", () => {
116
116
  expect(entry.assistantIds).toEqual(["a1"]);
117
117
  expect("workspaceId" in entry).toBe(false);
118
118
  });
119
+
120
+ it("create persists grants and findById projects them back", async () => {
121
+ const grants = [{ domain: "kb", items: ["search"] }];
122
+ const created = await store.create({
123
+ tenantId: "t1", projectId: "p1", grants,
124
+ });
125
+ const record = await store.findById(created.id);
126
+ expect(record?.grants).toEqual(grants);
127
+ });
119
128
  });
@@ -0,0 +1,77 @@
1
+ // The @axiom-lattice/core barrel cannot load under jest in this package (ESM-only
2
+ // transitive deps), so mock it with the real encryption module — node:crypto only.
3
+ jest.mock("@axiom-lattice/core", () => ({
4
+ ...jest.requireActual("../../../core/src/util/encryption"),
5
+ }));
6
+
7
+ import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
8
+ import { tmpdir } from "os";
9
+ import path from "path";
10
+
11
+ import { closeDatabase, initDatabase } from "../database";
12
+ import { LocalMcpServerConfigStore } from "../stores/LocalMcpServerConfigStore";
13
+
14
+ const nextDbPath = (): string => path.join(
15
+ tmpdir(), `lt-mcp-config-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
16
+ );
17
+
18
+ const httpConfig = {
19
+ name: "remote",
20
+ version: "1.0.0",
21
+ transport: "streamable_http" as const,
22
+ url: "https://mcp.example.com/mcp",
23
+ headers: { Authorization: "Bearer sk-secret-token" },
24
+ };
25
+
26
+ describe("LocalMcpServerConfigStore secret handling", () => {
27
+ let store: LocalMcpServerConfigStore;
28
+ let db: Awaited<ReturnType<typeof initDatabase>>;
29
+
30
+ beforeEach(async () => {
31
+ db = await initDatabase({ dbPath: nextDbPath() });
32
+ store = new LocalMcpServerConfigStore(db);
33
+ });
34
+ afterEach(() => closeDatabase());
35
+
36
+ it("encrypts env and headers at rest and decrypts on read", async () => {
37
+ await store.createConfig("tenant-1", "cfg-1", {
38
+ key: "remote",
39
+ config: { ...httpConfig, env: { MCP_TOKEN: "env-secret" } },
40
+ });
41
+
42
+ const row = db.prepare(
43
+ "SELECT config, is_env_encrypted FROM lt_mcp_configs WHERE id = ?",
44
+ ).get("cfg-1") as unknown as { config: string; is_env_encrypted: number };
45
+ expect(row.is_env_encrypted).toBe(1);
46
+ const atRest = JSON.parse(row.config);
47
+ expect(atRest.env.MCP_TOKEN).not.toBe("env-secret");
48
+ expect(atRest.headers.Authorization).not.toBe("Bearer sk-secret-token");
49
+
50
+ const read = await store.getConfigByKey("tenant-1", "remote");
51
+ expect(read?.config.env?.MCP_TOKEN).toBe("env-secret");
52
+ expect(read?.config.headers?.Authorization).toBe("Bearer sk-secret-token");
53
+ });
54
+
55
+ it("round-trips configs without secrets and leaves no headers key", async () => {
56
+ await store.createConfig("tenant-1", "cfg-2", {
57
+ key: "plain",
58
+ config: {
59
+ name: "plain", version: "1.0.0", transport: "stdio",
60
+ command: "npx", args: ["-y", "@modelcontextprotocol/server-everything"],
61
+ },
62
+ });
63
+ const read = await store.getConfigByKey("tenant-1", "plain");
64
+ expect(read?.config.headers).toBeUndefined();
65
+ expect(read?.config.command).toBe("npx");
66
+ expect(read?.isEnvEncrypted).toBe(false);
67
+ });
68
+
69
+ it("re-encrypts rotated header secrets on update", async () => {
70
+ await store.createConfig("tenant-1", "cfg-3", { key: "remote", config: httpConfig });
71
+ await store.updateConfig("tenant-1", "cfg-3", {
72
+ config: { ...httpConfig, headers: { Authorization: "Bearer rotated-token" } },
73
+ });
74
+ const read = await store.getConfigByKey("tenant-1", "remote");
75
+ expect(read?.config.headers?.Authorization).toBe("Bearer rotated-token");
76
+ });
77
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
2
+ import { tmpdir } from "os";
3
+ import path from "path";
4
+ import { initDatabase, closeDatabase, type DatabaseWrapper } from "../database";
5
+ import { LocalOpenAuditStore } from "../stores/LocalOpenAuditStore";
6
+
7
+ const nextDbPath = () =>
8
+ path.join(tmpdir(), `lt-open-audit-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
9
+
10
+ describe("LocalOpenAuditStore", () => {
11
+ let store: LocalOpenAuditStore;
12
+ let db: DatabaseWrapper;
13
+
14
+ beforeEach(async () => {
15
+ db = await initDatabase({ dbPath: nextDbPath() });
16
+ store = new LocalOpenAuditStore(db);
17
+ });
18
+
19
+ afterEach(async () => {
20
+ closeDatabase();
21
+ });
22
+
23
+ it("appends rows with redactable args and rounded duration", async () => {
24
+ await store.append({
25
+ tenantId: "t1",
26
+ credentialId: "k1",
27
+ credentialKind: "api_key",
28
+ projectId: "p1",
29
+ domain: "kb",
30
+ action: "search",
31
+ args: { query: "退款" },
32
+ status: "ok",
33
+ durationMs: 4.6,
34
+ });
35
+
36
+ const row = db
37
+ .prepare(`SELECT * FROM lt_open_audit`)
38
+ .get() as Record<string, unknown>;
39
+ expect(row.tenant_id).toBe("t1");
40
+ expect(row.domain).toBe("kb");
41
+ expect(row.status).toBe("ok");
42
+ expect(row.duration_ms).toBe(5);
43
+ expect(JSON.parse(row.args as string)).toEqual({ query: "退款" });
44
+ });
45
+
46
+ it("stores null args and an error code", async () => {
47
+ await store.append({
48
+ tenantId: "t1",
49
+ credentialId: "c",
50
+ credentialKind: "browser_token",
51
+ projectId: "p1",
52
+ domain: "sandbox",
53
+ action: "shell_exec",
54
+ args: undefined,
55
+ status: "error",
56
+ errorCode: "GRANT_DENIED",
57
+ durationMs: 2,
58
+ });
59
+ const row = db
60
+ .prepare(`SELECT * FROM lt_open_audit`)
61
+ .get() as Record<string, unknown>;
62
+ expect(row.args).toBeNull();
63
+ expect(row.error_code).toBe("GRANT_DENIED");
64
+ });
65
+ });
66
+
67
+ describe("LocalOpenAuditStore.query", () => {
68
+ let store: LocalOpenAuditStore;
69
+ let db: DatabaseWrapper;
70
+
71
+ beforeEach(async () => {
72
+ db = await initDatabase({ dbPath: nextDbPath() });
73
+ store = new LocalOpenAuditStore(db);
74
+ for (const [domain, status] of [["kb", "ok"], ["sandbox", "error"], ["kb", "ok"]] as const) {
75
+ await store.append({
76
+ tenantId: "t1",
77
+ credentialId: "k1",
78
+ credentialKind: "api_key",
79
+ projectId: "p1",
80
+ domain,
81
+ action: "x",
82
+ args: {},
83
+ status,
84
+ durationMs: 1,
85
+ });
86
+ }
87
+ // A different tenant must not leak.
88
+ await store.append({
89
+ tenantId: "t2",
90
+ credentialId: "k2",
91
+ credentialKind: "api_key",
92
+ projectId: "p9",
93
+ domain: "kb",
94
+ action: "x",
95
+ args: {},
96
+ status: "ok",
97
+ durationMs: 1,
98
+ });
99
+ });
100
+
101
+ afterEach(() => closeDatabase());
102
+
103
+ it("filters by tenant and domain", async () => {
104
+ const rows = await store.query({ tenantId: "t1", domain: "kb" });
105
+ expect(rows).toHaveLength(2);
106
+ expect(rows.every((r) => r.domain === "kb")).toBe(true);
107
+ });
108
+
109
+ it("filters by status and paginates newest-first", async () => {
110
+ const errors = await store.query({ tenantId: "t1", status: "error" });
111
+ expect(errors).toHaveLength(1);
112
+ expect(errors[0].domain).toBe("sandbox");
113
+ const limited = await store.query({ tenantId: "t1", limit: 1, offset: 0 });
114
+ expect(limited).toHaveLength(1);
115
+ });
116
+ });
@@ -0,0 +1,101 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
2
+ import { existsSync, unlinkSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import path from "path";
5
+ import { closeDatabase, initDatabase } from "../database";
6
+ import { LocalProjectRoomReadStateStore } from "../stores/LocalProjectRoomReadStateStore";
7
+
8
+ const nextDbPath = (): string => path.join(tmpdir(), `lt-read-state-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
9
+
10
+ describe("LocalProjectRoomReadStateStore", () => {
11
+ let dbPath: string;
12
+
13
+ beforeEach(() => { dbPath = nextDbPath(); });
14
+ afterEach(() => {
15
+ closeDatabase();
16
+ if (existsSync(dbPath)) unlinkSync(dbPath);
17
+ });
18
+
19
+ it("upserts monotonically and isolates scope", async () => {
20
+ const store = new LocalProjectRoomReadStateStore(await initDatabase({ dbPath }));
21
+ await expect(store.get("t1", "room-1", "u1")).resolves.toBeNull();
22
+ await store.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(1000) });
23
+ await store.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(2000) });
24
+ await store.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(500) });
25
+ expect((await store.get("t1", "room-1", "u1"))?.lastReadAt.getTime()).toBe(2000);
26
+ await expect(store.get("t1", "room-2", "u1")).resolves.toBeNull();
27
+ await expect(store.get("t2", "room-1", "u1")).resolves.toBeNull();
28
+ await expect(store.get("t1", "room-1", "u2")).resolves.toBeNull();
29
+ });
30
+
31
+ it("rejects an invalid lastReadAt without writing", async () => {
32
+ const store = new LocalProjectRoomReadStateStore(await initDatabase({ dbPath }));
33
+ await expect(store.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(Number.NaN) })).rejects.toBeInstanceOf(RangeError);
34
+ await expect(store.get("t1", "room-1", "u1")).resolves.toBeNull();
35
+ });
36
+
37
+ it("persists markers across reopen with hydrated dates", async () => {
38
+ const initial = new LocalProjectRoomReadStateStore(await initDatabase({ dbPath }));
39
+ const saved = await initial.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(1500) });
40
+ expect(saved.lastReadAt.getTime()).toBe(1500);
41
+ expect(saved.updatedAt).toBeInstanceOf(Date);
42
+ closeDatabase();
43
+
44
+ const reopened = new LocalProjectRoomReadStateStore(await initDatabase({ dbPath }));
45
+ const persisted = await reopened.get("t1", "room-1", "u1");
46
+ expect(persisted).toEqual(saved);
47
+ expect(persisted?.lastReadAt).toBeInstanceOf(Date);
48
+ expect(persisted?.updatedAt).toBeInstanceOf(Date);
49
+ });
50
+
51
+ it("upgrades a version-1 project room database by adding the read-state table", async () => {
52
+ const db = await initDatabase({ dbPath });
53
+ db.exec(`CREATE TABLE lt_project_rooms (
54
+ id TEXT NOT NULL, tenant_id TEXT NOT NULL, workspace_id TEXT NOT NULL, project_id TEXT NOT NULL,
55
+ type TEXT NOT NULL CHECK (type = 'main'), name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
56
+ PRIMARY KEY (tenant_id, id), UNIQUE (tenant_id, project_id, type)
57
+ );
58
+ CREATE TABLE lt_project_memberships (
59
+ id TEXT NOT NULL, tenant_id TEXT NOT NULL, project_id TEXT NOT NULL, user_id TEXT NOT NULL,
60
+ role TEXT NOT NULL CHECK (role IN ('owner','admin','member','viewer')),
61
+ status TEXT NOT NULL CHECK (status IN ('active','removed')), joined_at TEXT NOT NULL, updated_at TEXT NOT NULL,
62
+ PRIMARY KEY (tenant_id, id), UNIQUE (tenant_id, project_id, user_id)
63
+ );
64
+ CREATE INDEX idx_lt_project_memberships_project ON lt_project_memberships (tenant_id, project_id);
65
+ CREATE TABLE lt_project_bot_memberships (
66
+ id TEXT NOT NULL, tenant_id TEXT NOT NULL, workspace_id TEXT NOT NULL, project_id TEXT NOT NULL,
67
+ room_id TEXT NOT NULL, assistant_id TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('coordinator','specialist')),
68
+ title TEXT NOT NULL, responsibility TEXT, mention_name TEXT NOT NULL,
69
+ status TEXT NOT NULL CHECK (status IN ('active','paused','removed')), room_thread_id TEXT NOT NULL,
70
+ joined_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (tenant_id, id),
71
+ UNIQUE (tenant_id, project_id, assistant_id)
72
+ );
73
+ CREATE UNIQUE INDEX uq_lt_project_bot_memberships_coordinator
74
+ ON lt_project_bot_memberships (tenant_id, project_id) WHERE role = 'coordinator' AND status IN ('active','paused');
75
+ CREATE UNIQUE INDEX uq_lt_project_bot_memberships_mention
76
+ ON lt_project_bot_memberships (tenant_id, room_id, mention_name) WHERE status IN ('active','paused');
77
+ CREATE INDEX idx_lt_project_bot_memberships_project ON lt_project_bot_memberships (tenant_id, project_id);
78
+ CREATE INDEX idx_lt_project_bot_memberships_room ON lt_project_bot_memberships (tenant_id, room_id);
79
+ CREATE TABLE lt_project_room_messages (
80
+ id TEXT NOT NULL, tenant_id TEXT NOT NULL, workspace_id TEXT NOT NULL, project_id TEXT NOT NULL, room_id TEXT NOT NULL,
81
+ author TEXT NOT NULL, content TEXT NOT NULL, mentions TEXT NOT NULL DEFAULT '[]', reply_to_message_id TEXT,
82
+ source TEXT NOT NULL CHECK (source IN ('user','agent','task','routine','system')), source_id TEXT,
83
+ idempotency_key TEXT, created_at TEXT NOT NULL, PRIMARY KEY (tenant_id, id)
84
+ );
85
+ CREATE UNIQUE INDEX uq_lt_project_room_messages_idempotency
86
+ ON lt_project_room_messages (tenant_id, room_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
87
+ CREATE INDEX idx_lt_project_room_messages_room_created
88
+ ON lt_project_room_messages (tenant_id, room_id, created_at DESC, id DESC);
89
+ CREATE TABLE lattice_schema_migrations (
90
+ store_name TEXT NOT NULL, name TEXT NOT NULL, version INTEGER NOT NULL, applied_at TEXT NOT NULL,
91
+ PRIMARY KEY (store_name, name)
92
+ );`);
93
+ db.getRawDb().run("INSERT INTO lattice_schema_migrations (store_name, name, version, applied_at) VALUES (?, ?, ?, ?)", ["project_room", "create_project_room_tables", 1, new Date().toISOString()]);
94
+
95
+ const store = new LocalProjectRoomReadStateStore(db);
96
+ await expect(store.get("t1", "room-1", "u1")).resolves.toBeNull();
97
+ await store.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(1000) });
98
+ expect((await store.get("t1", "room-1", "u1"))?.lastReadAt.getTime()).toBe(1000);
99
+ expect(db.prepare("SELECT name, version FROM lattice_schema_migrations WHERE store_name = ? AND name = ?").get("project_room", "create_project_room_read_states")).toMatchObject({ name: "create_project_room_read_states", version: 2 });
100
+ });
101
+ });
@@ -337,6 +337,45 @@ describe("local project room stores", () => {
337
337
  await expect(store.list({ tenantId: "t1", roomId: "r1", before: { createdAt: new Date("invalid"), id: "x" }, limit: 1 })).rejects.toBeInstanceOf(RangeError);
338
338
  });
339
339
 
340
+ it("counts messages strictly after the horizon with optional human-author exclusion", async () => {
341
+ const db = await initDatabase({ dbPath });
342
+ const store = new LocalProjectRoomMessageStore(db);
343
+ const insert = (id: string, author: string, createdAt: string) => transaction(db, () => {
344
+ write(db, "INSERT INTO lt_project_room_messages (id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
345
+ [id, "t1", "w1", "p1", "r1", author, '{"type":"text","text":"text"}', "[]", null, "user", null, null, createdAt]);
346
+ });
347
+ insert("m1", '{"type":"human","userId":"u1"}', "2026-01-01T00:00:01.000Z");
348
+ insert("m2", '{"type":"human","userId":"u1"}', "2026-01-01T00:00:02.000Z");
349
+ insert("m3", '{"type":"human","userId":"u2"}', "2026-01-01T00:00:03.000Z");
350
+ insert("m4", '{"type":"bot","membershipId":"b1","assistantId":"a1"}', "2026-01-01T00:00:04.000Z");
351
+ const horizon = new Date("2026-01-01T00:00:01.000Z");
352
+
353
+ expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: horizon })).toBe(3);
354
+ expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date("2026-01-01T00:00:04.000Z") })).toBe(0);
355
+ expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date(0) })).toBe(4);
356
+ expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: horizon, excludeAuthorUserId: "u1" })).toBe(2);
357
+ expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: horizon, excludeAuthorUserId: "u2" })).toBe(2);
358
+ expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date("2026-01-01T00:00:03.000Z"), excludeAuthorUserId: "u2" })).toBe(1);
359
+ });
360
+
361
+ it("scopes countAfter by tenant and room and rejects invalid horizons", async () => {
362
+ const db = await initDatabase({ dbPath });
363
+ const store = new LocalProjectRoomMessageStore(db);
364
+ const insert = (id: string, tenantId: string, roomId: string, createdAt: string) => transaction(db, () => {
365
+ write(db, "INSERT INTO lt_project_room_messages (id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
366
+ [id, tenantId, "w1", "p1", roomId, '{"type":"system"}', '{"type":"text","text":"text"}', "[]", null, "system", null, null, createdAt]);
367
+ });
368
+ insert("m1", "t1", "r1", "2026-01-01T00:00:01.000Z");
369
+ insert("m2", "t1", "r2", "2026-01-01T00:00:02.000Z");
370
+ insert("m3", "t2", "r1", "2026-01-01T00:00:03.000Z");
371
+
372
+ expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date(0) })).toBe(1);
373
+ expect(await store.countAfter({ tenantId: "t1", roomId: "r2", after: new Date(0) })).toBe(1);
374
+ expect(await store.countAfter({ tenantId: "t2", roomId: "r1", after: new Date(0) })).toBe(1);
375
+ expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date("2026-01-01T00:00:01.000Z") })).toBe(0);
376
+ await expect(store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date(Number.NaN) })).rejects.toBeInstanceOf(RangeError);
377
+ });
378
+
340
379
  it("classifies a room message ID collision that occurs after preflight", async () => {
341
380
  const db = await initDatabase({ dbPath });
342
381
  const store = new LocalProjectRoomMessageStore(db);
@@ -353,4 +392,26 @@ describe("local project room stores", () => {
353
392
 
354
393
  await expect(store.create({ id: "race-id", tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", author: { type: "system" }, content: { type: "text", text: "message" }, mentions: [], source: "system" })).rejects.toBeInstanceOf(ProjectRoomMessageIdConflictError);
355
394
  });
395
+
396
+ it("skips individually invalid rows when listing while preserving valid rows", async () => {
397
+ const db = await initDatabase({ dbPath });
398
+ const store = new LocalProjectRoomMessageStore(db);
399
+ await store.create({
400
+ id: "m1", tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1",
401
+ author: { type: "human", userId: "u1" }, content: { type: "text", text: "hello" },
402
+ mentions: [], source: "user",
403
+ });
404
+ transaction(db, () => {
405
+ write(db, "INSERT INTO lt_project_room_messages (id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
406
+ ["bad", "t1", "w1", "p1", "r1", '{"type":"human","userId":"u1"}', '{"text":"missing type"}', "[]", null, "user", null, null, "2026-01-01T00:00:00.000Z"]);
407
+ });
408
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
409
+ const page = await store.list({ tenantId: "t1", roomId: "r1", limit: 10 });
410
+ expect(page.map((message) => message.id)).toEqual(["m1"]);
411
+ expect(warn).toHaveBeenCalledWith(
412
+ "[ProjectRoomMessageStore] Skipping invalid message row",
413
+ expect.objectContaining({ tenantId: "t1", roomId: "r1", rowId: "bad" }),
414
+ );
415
+ warn.mockRestore();
416
+ });
356
417
  });
@@ -24,6 +24,7 @@ import { LocalProjectRoomStore } from "./stores/LocalProjectRoomStore";
24
24
  import { LocalProjectMembershipStore } from "./stores/LocalProjectMembershipStore";
25
25
  import { LocalProjectBotMembershipStore } from "./stores/LocalProjectBotMembershipStore";
26
26
  import { LocalProjectRoomMessageStore } from "./stores/LocalProjectRoomMessageStore";
27
+ import { LocalProjectRoomReadStateStore } from "./stores/LocalProjectRoomReadStateStore";
27
28
  import { LocalUserStore } from "./stores/LocalUserStore";
28
29
  import { LocalTenantStore } from "./stores/LocalTenantStore";
29
30
  import { LocalUserTenantLinkStore } from "./stores/LocalUserTenantLinkStore";
@@ -36,6 +37,7 @@ import { LocalEvalStore } from "./stores/LocalEvalStore";
36
37
  import { LocalChannelBindingStore } from "./stores/LocalChannelBindingStore";
37
38
  import { LocalChannelInstallationStore } from "./stores/LocalChannelInstallationStore";
38
39
  import { LocalA2AApiKeyStore } from "./stores/LocalA2AApiKeyStore";
40
+ import { LocalOpenAuditStore } from "./stores/LocalOpenAuditStore";
39
41
  import { LocalThreadMessageQueueStore } from "./stores/LocalThreadMessageQueueStore";
40
42
  import { LocalSkillStore } from "./stores/LocalSkillStore";
41
43
  import { LocalScheduleStorage } from "./stores/LocalScheduleStorage";
@@ -74,6 +76,7 @@ export async function createLocalStoreConfig(options: LocalStoreConfigOptions =
74
76
  projectMembership: new LocalProjectMembershipStore(db),
75
77
  projectBotMembership: new LocalProjectBotMembershipStore(db),
76
78
  projectRoomMessage: new LocalProjectRoomMessageStore(db),
79
+ projectRoomReadState: new LocalProjectRoomReadStateStore(db),
77
80
  user: new LocalUserStore(db),
78
81
  tenant: new LocalTenantStore(db),
79
82
  userTenantLink: new LocalUserTenantLinkStore(db),
@@ -86,6 +89,7 @@ export async function createLocalStoreConfig(options: LocalStoreConfigOptions =
86
89
  channelBinding: new LocalChannelBindingStore(db),
87
90
  channelInstallation: new LocalChannelInstallationStore(db),
88
91
  a2aApiKey: new LocalA2AApiKeyStore(db),
92
+ openAudit: new LocalOpenAuditStore(db),
89
93
  threadMessageQueue: new LocalThreadMessageQueueStore(db),
90
94
  skill: new LocalSkillStore(db),
91
95
  schedule: new LocalScheduleStorage(db),
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export { LocalProjectRoomStore } from "./stores/LocalProjectRoomStore";
35
35
  export { LocalProjectMembershipStore, DuplicateProjectMembershipError, ProjectMembershipIdConflictError } from "./stores/LocalProjectMembershipStore";
36
36
  export { LocalProjectBotMembershipStore, ProjectBotMembershipIdConflictError } from "./stores/LocalProjectBotMembershipStore";
37
37
  export { LocalProjectRoomMessageStore, ProjectRoomMessageIdConflictError, DuplicateProjectRoomMessageIdempotencyKeyError } from "./stores/LocalProjectRoomMessageStore";
38
+ export { LocalProjectRoomReadStateStore } from "./stores/LocalProjectRoomReadStateStore";
38
39
  export { LocalUserStore } from "./stores/LocalUserStore";
39
40
  export { LocalTenantStore } from "./stores/LocalTenantStore";
40
41
  export { LocalUserTenantLinkStore } from "./stores/LocalUserTenantLinkStore";
@@ -54,3 +55,5 @@ export { LocalTaskStore } from "./stores/LocalTaskStore";
54
55
  export { LocalTaskWorkItemStore } from "./stores/LocalTaskWorkItemStore";
55
56
  export { LocalCapabilityBundleStore } from "./stores/LocalCapabilityBundleStore";
56
57
  export { InMemoryConversationStore } from "./stores/InMemoryConversationStore";
58
+
59
+ export * from "./stores/LocalOpenAuditStore";
@@ -4,10 +4,12 @@
4
4
 
5
5
  import { DatabaseWrapper } from "../database";
6
6
  import type {
7
+ UpdateA2AApiKeyInput,
7
8
  A2AApiKeyStore,
8
9
  A2AApiKeyRecord,
9
10
  CreateA2AApiKeyInput,
10
11
  A2AApiKeyEntry,
12
+ OpenGrant,
11
13
  } from "@axiom-lattice/protocols";
12
14
  import { ensureTable, nowISO, parseISO } from "../database";
13
15
  import { encrypt, decrypt } from "@axiom-lattice/core";
@@ -20,6 +22,7 @@ CREATE TABLE IF NOT EXISTS lt_a2a_api_keys (
20
22
  tenant_id TEXT NOT NULL,
21
23
  project_id TEXT,
22
24
  assistant_ids TEXT,
25
+ grants TEXT,
23
26
  label TEXT,
24
27
  enabled INTEGER NOT NULL DEFAULT 1,
25
28
  created_at TEXT NOT NULL,
@@ -33,6 +36,7 @@ interface KeyRow {
33
36
  tenant_id: string;
34
37
  project_id: string;
35
38
  assistant_ids: string | null;
39
+ grants: string | null;
36
40
  label: string | null;
37
41
  enabled: number;
38
42
  created_at: string;
@@ -43,6 +47,16 @@ function generateApiKey(): string {
43
47
  return `a2a_${randomUUID().replace(/-/g, "")}`;
44
48
  }
45
49
 
50
+ function parseGrants(raw: string | null): OpenGrant[] | undefined {
51
+ if (!raw) return undefined;
52
+ try {
53
+ const parsed = JSON.parse(raw);
54
+ return Array.isArray(parsed) ? (parsed as OpenGrant[]) : undefined;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
46
60
  function parseAssistantIds(raw: string | null): string[] | undefined {
47
61
  if (!raw) return undefined;
48
62
  try {
@@ -62,6 +76,7 @@ export class LocalA2AApiKeyStore implements A2AApiKeyStore {
62
76
  ensureTable(db, DDL);
63
77
  // Existing installs: add columns shipped after the original schema.
64
78
  this.ensureColumn("lt_a2a_api_keys", "assistant_ids", "assistant_ids TEXT");
79
+ this.ensureColumn("lt_a2a_api_keys", "grants", "grants TEXT");
65
80
  this.repairManagementIds();
66
81
  }
67
82
 
@@ -128,9 +143,9 @@ export class LocalA2AApiKeyStore implements A2AApiKeyStore {
128
143
  const key = generateApiKey();
129
144
  const now = nowISO();
130
145
  this.db.prepare(
131
- `INSERT INTO lt_a2a_api_keys (id, key_value, tenant_id, project_id, assistant_ids, label, created_at, updated_at)
132
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
133
- ).run(id, encrypt(key), input.tenantId, input.projectId, input.assistantIds ? JSON.stringify(input.assistantIds) : null, input.label || null, now, now);
146
+ `INSERT INTO lt_a2a_api_keys (id, key_value, tenant_id, project_id, assistant_ids, grants, label, created_at, updated_at)
147
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
148
+ ).run(id, encrypt(key), input.tenantId, input.projectId, input.assistantIds ? JSON.stringify(input.assistantIds) : null, input.grants ? JSON.stringify(input.grants) : null, input.label || null, now, now);
134
149
 
135
150
  const record: A2AApiKeyRecord = {
136
151
  id,
@@ -166,6 +181,28 @@ export class LocalA2AApiKeyStore implements A2AApiKeyStore {
166
181
  return mapRowToRecord(row);
167
182
  }
168
183
 
184
+
185
+ async update(id: string, input: UpdateA2AApiKeyInput): Promise<A2AApiKeyRecord> {
186
+ const sets: string[] = [];
187
+ const values: unknown[] = [];
188
+ if (input.label !== undefined) { sets.push("label = ?"); values.push(input.label || null); }
189
+ if (input.projectId !== undefined) { sets.push("project_id = ?"); values.push(input.projectId); }
190
+ if (input.assistantIds !== undefined) { sets.push("assistant_ids = ?"); values.push(input.assistantIds ? JSON.stringify(input.assistantIds) : null); }
191
+ if (input.grants !== undefined) { sets.push("grants = ?"); values.push(input.grants ? JSON.stringify(input.grants) : null); }
192
+ if (sets.length === 0) {
193
+ const row = this.db.prepare("SELECT * FROM lt_a2a_api_keys WHERE id = ?").get(id) as unknown as KeyRow | undefined;
194
+ if (!row) throw new Error(`A2A API key not found: ${id}`);
195
+ return mapRowToRecord(row);
196
+ }
197
+ sets.push("updated_at = ?");
198
+ values.push(nowISO());
199
+ values.push(id);
200
+ this.db.prepare(`UPDATE lt_a2a_api_keys SET ${sets.join(", ")} WHERE id = ?`).run(...values);
201
+ const row = this.db.prepare("SELECT * FROM lt_a2a_api_keys WHERE id = ?").get(id) as unknown as KeyRow | undefined;
202
+ if (!row) throw new Error(`A2A API key not found: ${id}`);
203
+ return mapRowToRecord(row);
204
+ }
205
+
169
206
  async rotate(id: string): Promise<A2AApiKeyRecord> {
170
207
  const key = generateApiKey();
171
208
  const now = nowISO();
@@ -212,6 +249,7 @@ function mapRowToRecord(row: KeyRow): A2AApiKeyRecord {
212
249
  tenantId: row.tenant_id,
213
250
  projectId: row.project_id,
214
251
  assistantIds: parseAssistantIds(row.assistant_ids),
252
+ grants: parseGrants(row.grants),
215
253
  label: row.label || undefined,
216
254
  enabled: row.enabled === 1,
217
255
  createdAt: parseISO(row.created_at),
@@ -191,6 +191,19 @@ function mapRowToEntry(row: ConfigRow): McpServerConfigEntry {
191
191
  }
192
192
  }
193
193
 
194
+ if (config.headers && row.is_env_encrypted) {
195
+ try {
196
+ const decryptedHeaders: Record<string, string> = {};
197
+ for (const [key, value] of Object.entries(config.headers)) {
198
+ decryptedHeaders[key] = decrypt(value);
199
+ }
200
+ config.headers = decryptedHeaders;
201
+ } catch (error) {
202
+ console.error("Failed to decrypt MCP server headers:", error);
203
+ throw new Error("Failed to decrypt MCP server configuration");
204
+ }
205
+ }
206
+
194
207
  return {
195
208
  id: row.id,
196
209
  tenantId: row.tenant_id,
@@ -219,5 +232,14 @@ function encryptEnvInConfig(config: McpServerConfig): { config: McpServerConfig;
219
232
  isEnvEncrypted = true;
220
233
  }
221
234
 
235
+ if (configCopy.headers && Object.keys(configCopy.headers).length > 0) {
236
+ const encryptedHeaders: Record<string, string> = {};
237
+ for (const [key, value] of Object.entries(configCopy.headers)) {
238
+ encryptedHeaders[key] = encrypt(value);
239
+ }
240
+ configCopy.headers = encryptedHeaders;
241
+ isEnvEncrypted = true;
242
+ }
243
+
222
244
  return { config: configCopy, isEnvEncrypted };
223
245
  }