@axiom-lattice/local-stores 3.1.8 → 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.8",
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.8",
29
- "@axiom-lattice/protocols": "4.2.0"
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,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
+ });
@@ -392,4 +392,26 @@ describe("local project room stores", () => {
392
392
 
393
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);
394
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
+ });
395
417
  });
@@ -37,6 +37,7 @@ import { LocalEvalStore } from "./stores/LocalEvalStore";
37
37
  import { LocalChannelBindingStore } from "./stores/LocalChannelBindingStore";
38
38
  import { LocalChannelInstallationStore } from "./stores/LocalChannelInstallationStore";
39
39
  import { LocalA2AApiKeyStore } from "./stores/LocalA2AApiKeyStore";
40
+ import { LocalOpenAuditStore } from "./stores/LocalOpenAuditStore";
40
41
  import { LocalThreadMessageQueueStore } from "./stores/LocalThreadMessageQueueStore";
41
42
  import { LocalSkillStore } from "./stores/LocalSkillStore";
42
43
  import { LocalScheduleStorage } from "./stores/LocalScheduleStorage";
@@ -88,6 +89,7 @@ export async function createLocalStoreConfig(options: LocalStoreConfigOptions =
88
89
  channelBinding: new LocalChannelBindingStore(db),
89
90
  channelInstallation: new LocalChannelInstallationStore(db),
90
91
  a2aApiKey: new LocalA2AApiKeyStore(db),
92
+ openAudit: new LocalOpenAuditStore(db),
91
93
  threadMessageQueue: new LocalThreadMessageQueueStore(db),
92
94
  skill: new LocalSkillStore(db),
93
95
  schedule: new LocalScheduleStorage(db),
package/src/index.ts CHANGED
@@ -55,3 +55,5 @@ export { LocalTaskStore } from "./stores/LocalTaskStore";
55
55
  export { LocalTaskWorkItemStore } from "./stores/LocalTaskWorkItemStore";
56
56
  export { LocalCapabilityBundleStore } from "./stores/LocalCapabilityBundleStore";
57
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),
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Local SQLite implementation of the Open audit store (append-only).
3
+ */
4
+
5
+ import { DatabaseWrapper, ensureTable, nowISO, parseISO } from "../database";
6
+ import type {
7
+ OpenAuditStore,
8
+ OpenAuditAppendInput,
9
+ OpenAuditQuery,
10
+ OpenAuditRecord,
11
+ } from "@axiom-lattice/protocols";
12
+ import { randomUUID } from "crypto";
13
+
14
+ const DDL = `
15
+ CREATE TABLE IF NOT EXISTS lt_open_audit (
16
+ id TEXT PRIMARY KEY,
17
+ tenant_id TEXT NOT NULL,
18
+ credential_id TEXT NOT NULL,
19
+ credential_kind TEXT NOT NULL,
20
+ project_id TEXT NOT NULL,
21
+ domain TEXT NOT NULL,
22
+ action TEXT NOT NULL,
23
+ args TEXT,
24
+ status TEXT NOT NULL,
25
+ error_code TEXT,
26
+ duration_ms INTEGER NOT NULL,
27
+ created_at TEXT NOT NULL
28
+ );
29
+ `;
30
+
31
+ /** SQLite-backed append-only audit store for the Open API surface. */
32
+ export class LocalOpenAuditStore implements OpenAuditStore {
33
+ private db: DatabaseWrapper;
34
+
35
+ constructor(db: DatabaseWrapper) {
36
+ this.db = db;
37
+ ensureTable(db, DDL);
38
+ }
39
+
40
+ async query(params: OpenAuditQuery): Promise<OpenAuditRecord[]> {
41
+ const clauses = ["tenant_id = ?"];
42
+ const values: unknown[] = [params.tenantId];
43
+ if (params.credentialId) {
44
+ clauses.push("credential_id = ?");
45
+ values.push(params.credentialId);
46
+ }
47
+ if (params.domain) {
48
+ clauses.push("domain = ?");
49
+ values.push(params.domain);
50
+ }
51
+ if (params.status) {
52
+ clauses.push("status = ?");
53
+ values.push(params.status);
54
+ }
55
+ if (params.from) {
56
+ clauses.push("created_at >= ?");
57
+ values.push(params.from);
58
+ }
59
+ if (params.to) {
60
+ clauses.push("created_at < ?");
61
+ values.push(params.to);
62
+ }
63
+ const limit = params.limit ?? 100;
64
+ const offset = params.offset ?? 0;
65
+ values.push(limit, offset);
66
+ const rows = this.db
67
+ .prepare(
68
+ `SELECT * FROM lt_open_audit WHERE ${clauses.join(" AND ")}
69
+ ORDER BY created_at DESC LIMIT ? OFFSET ?`,
70
+ )
71
+ .all(...values) as unknown as Array<Record<string, unknown>>;
72
+ return rows.map((row) => ({
73
+ id: row.id as string,
74
+ tenantId: row.tenant_id as string,
75
+ credentialId: row.credential_id as string,
76
+ credentialKind: row.credential_kind as OpenAuditRecord["credentialKind"],
77
+ projectId: row.project_id as string,
78
+ domain: row.domain as string,
79
+ action: row.action as string,
80
+ args: row.args ? JSON.parse(row.args as string) : undefined,
81
+ status: row.status as "ok" | "error",
82
+ errorCode: (row.error_code as string) ?? undefined,
83
+ durationMs: row.duration_ms as number,
84
+ createdAt: parseISO(row.created_at as string),
85
+ }));
86
+ }
87
+
88
+ async append(record: OpenAuditAppendInput): Promise<void> {
89
+ this.db
90
+ .prepare(
91
+ `INSERT INTO lt_open_audit
92
+ (id, tenant_id, credential_id, credential_kind, project_id, domain, action, args, status, error_code, duration_ms, created_at)
93
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
94
+ )
95
+ .run(
96
+ randomUUID(),
97
+ record.tenantId,
98
+ record.credentialId,
99
+ record.credentialKind,
100
+ record.projectId,
101
+ record.domain,
102
+ record.action,
103
+ record.args === undefined ? null : JSON.stringify(record.args),
104
+ record.status,
105
+ record.errorCode ?? null,
106
+ Math.max(0, Math.round(record.durationMs)),
107
+ nowISO(),
108
+ );
109
+ }
110
+ }
@@ -26,6 +26,26 @@ function mapRow(row: Row): ProjectRoomMessage {
26
26
  ...(row.source_id === null ? {} : { sourceId: row.source_id }), ...(row.idempotency_key === null ? {} : { idempotencyKey: row.idempotency_key }), createdAt };
27
27
  }
28
28
 
29
+ function mapRowsTolerant(
30
+ rows: Row[],
31
+ scope: { tenantId: string; roomId: string },
32
+ ): ProjectRoomMessage[] {
33
+ const messages: ProjectRoomMessage[] = [];
34
+ for (const row of rows) {
35
+ try {
36
+ messages.push(mapRow(row));
37
+ } catch (error) {
38
+ console.warn("[ProjectRoomMessageStore] Skipping invalid message row", {
39
+ tenantId: scope.tenantId,
40
+ roomId: scope.roomId,
41
+ rowId: typeof row.id === "string" ? row.id : undefined,
42
+ reason: error instanceof Error ? error.message : String(error),
43
+ });
44
+ }
45
+ }
46
+ return messages;
47
+ }
48
+
29
49
  /** Persists validated project room messages in local SQLite. */
30
50
  export class LocalProjectRoomMessageStore implements ProjectRoomMessageStore {
31
51
  constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
@@ -37,7 +57,7 @@ export class LocalProjectRoomMessageStore implements ProjectRoomMessageStore {
37
57
  const rows = input.before
38
58
  ? this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND room_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT ?`).all(input.tenantId, input.roomId, input.before.createdAt.toISOString(), input.before.createdAt.toISOString(), input.before.id, limit)
39
59
  : this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND room_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(input.tenantId, input.roomId, limit);
40
- return (rows as Row[]).map(mapRow);
60
+ return mapRowsTolerant(rows as Row[], { tenantId: input.tenantId, roomId: input.roomId });
41
61
  }
42
62
  async findById(tenantId: string, id: string): Promise<ProjectRoomMessage | null> { const row = this.byId(tenantId, id); return row ? mapRow(row) : null; }
43
63
  async countAfter(input: {