@axiom-lattice/pg-stores 3.1.8 → 3.1.10

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.8",
3
+ "version": "3.1.10",
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.8",
29
- "@axiom-lattice/protocols": "4.2.0"
28
+ "@axiom-lattice/core": "4.2.10",
29
+ "@axiom-lattice/protocols": "4.2.1"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
@@ -55,7 +55,7 @@ describe("PostgreSQLA2AApiKeyStore standalone migrations", () => {
55
55
  mockClientSql.length = 0;
56
56
  });
57
57
 
58
- it("registers and applies both the table and assistant_ids migrations", async () => {
58
+ it("registers and applies the table, assistant_ids, and grants migrations", async () => {
59
59
  const store = new PostgreSQLA2AApiKeyStore({
60
60
  poolConfig: "postgres://unused",
61
61
  autoMigrate: false,
@@ -66,6 +66,7 @@ describe("PostgreSQLA2AApiKeyStore standalone migrations", () => {
66
66
  expect(mockRecordedMigrationNames).toEqual([
67
67
  "create_a2a_api_keys_table",
68
68
  "add_a2a_key_assistant_ids",
69
+ "add_api_key_grants",
69
70
  ]);
70
71
  expect(
71
72
  mockClientSql.some((sql) => sql.includes("ADD COLUMN IF NOT EXISTS assistant_ids")),
@@ -131,3 +131,48 @@ describe("PostgreSQLA2AApiKeyStore", () => {
131
131
  expect("workspaceId" in (entry ?? {})).toBe(false);
132
132
  });
133
133
  });
134
+
135
+ describe("PostgreSQLA2AApiKeyStore grants column", () => {
136
+ const pool2 = { query: mockQuery } as unknown as Pool;
137
+
138
+ beforeEach(() => {
139
+ jest.clearAllMocks();
140
+ });
141
+
142
+ it("create persists grants as JSON", async () => {
143
+ const store = new PostgreSQLA2AApiKeyStore({ pool: pool2 });
144
+ const grants = [{ domain: "kb", items: ["search"] }];
145
+ mockQuery.mockResolvedValueOnce({
146
+ rows: [{
147
+ id: "k1", key_value: "enc:a2a_x", tenant_id: "t1", project_id: "p1",
148
+ assistant_ids: null, grants, label: null, enabled: true,
149
+ created_at: new Date("2026-01-01T00:00:00.000Z"),
150
+ updated_at: new Date("2026-01-01T00:00:00.000Z"),
151
+ }],
152
+ });
153
+
154
+ const record = await store.create({
155
+ tenantId: "t1", projectId: "p1", grants,
156
+ });
157
+
158
+ const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
159
+ expect(sql).toContain("grants");
160
+ expect(params[4]).toBe(JSON.stringify(grants));
161
+ expect(record.grants).toEqual(grants);
162
+ });
163
+
164
+ it("mapRowToRecord projects grants back", async () => {
165
+ const store = new PostgreSQLA2AApiKeyStore({ pool: pool2 });
166
+ const grants = [{ domain: "kb" }];
167
+ mockQuery.mockResolvedValueOnce({
168
+ rows: [{
169
+ id: "k1", key_value: "enc:a2a_x", tenant_id: "t1", project_id: "p1",
170
+ assistant_ids: null, grants, label: null, enabled: true,
171
+ created_at: new Date("2026-01-01T00:00:00.000Z"),
172
+ updated_at: new Date("2026-01-01T00:00:00.000Z"),
173
+ }],
174
+ });
175
+ const record = await store.findById("k1");
176
+ expect(record?.grants).toEqual(grants);
177
+ });
178
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it, jest, beforeEach } from "@jest/globals";
2
+ import type { Pool } from "pg";
3
+
4
+ const mockQuery = jest.fn();
5
+ import { PostgreSQLOpenAuditStore } from "../stores/PostgreSQLOpenAuditStore";
6
+
7
+ const pool = { query: mockQuery } as unknown as Pool;
8
+
9
+ describe("PostgreSQLOpenAuditStore", () => {
10
+ beforeEach(() => jest.clearAllMocks());
11
+
12
+ it("append inserts a row with JSON args", async () => {
13
+ const store = new PostgreSQLOpenAuditStore({ pool });
14
+ mockQuery.mockResolvedValueOnce({ rows: [] });
15
+ await store.append({
16
+ tenantId: "t1",
17
+ credentialId: "k1",
18
+ credentialKind: "api_key",
19
+ projectId: "p1",
20
+ domain: "kb",
21
+ action: "search",
22
+ args: { query: "x" },
23
+ status: "ok",
24
+ durationMs: 12.7,
25
+ });
26
+ const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
27
+ expect(sql).toContain("INSERT INTO lattice_open_audit");
28
+ expect(params[6]).toBe(JSON.stringify({ query: "x" }));
29
+ expect(params[7]).toBe("ok");
30
+ expect(params[9]).toBe(13);
31
+ });
32
+
33
+ it("append persists null args safely", async () => {
34
+ const store = new PostgreSQLOpenAuditStore({ pool });
35
+ mockQuery.mockResolvedValueOnce({ rows: [] });
36
+ await store.append({
37
+ tenantId: "t1",
38
+ credentialId: "k1",
39
+ credentialKind: "browser_token",
40
+ projectId: "p1",
41
+ domain: "kb",
42
+ action: "search",
43
+ args: undefined,
44
+ status: "error",
45
+ errorCode: "GRANT_DENIED",
46
+ durationMs: 1,
47
+ });
48
+ const params = mockQuery.mock.calls[0][1] as unknown[];
49
+ expect(params[6]).toBeNull();
50
+ expect(params[8]).toBe("GRANT_DENIED");
51
+ });
52
+ });
53
+
54
+ describe("PostgreSQLOpenAuditStore.query", () => {
55
+ beforeEach(() => jest.clearAllMocks());
56
+
57
+ it("builds a tenant-scoped filtered query", async () => {
58
+ const store = new PostgreSQLOpenAuditStore({ pool });
59
+ mockQuery.mockResolvedValueOnce({ rows: [] });
60
+ await store.query({
61
+ tenantId: "t1",
62
+ domain: "kb",
63
+ status: "error",
64
+ from: "2026-01-01T00:00:00Z",
65
+ limit: 10,
66
+ offset: 5,
67
+ });
68
+ const [sql, values] = mockQuery.mock.calls[0] as [string, unknown[]];
69
+ expect(sql).toContain("tenant_id = $1");
70
+ expect(sql).toContain("domain = $2");
71
+ expect(sql).toContain("status = $3");
72
+ expect(sql).toContain("created_at >= $4");
73
+ expect(sql).toContain("ORDER BY created_at DESC");
74
+ expect(values).toEqual(["t1", "kb", "error", "2026-01-01T00:00:00Z", 10, 5]);
75
+ });
76
+
77
+ it("maps rows back to records", async () => {
78
+ const store = new PostgreSQLOpenAuditStore({ pool });
79
+ mockQuery.mockResolvedValueOnce({
80
+ rows: [
81
+ {
82
+ id: "a1",
83
+ tenant_id: "t1",
84
+ credential_id: "k1",
85
+ credential_kind: "api_key",
86
+ project_id: "p1",
87
+ domain: "kb",
88
+ action: "search",
89
+ args: { q: "x" },
90
+ status: "ok",
91
+ error_code: null,
92
+ duration_ms: 7,
93
+ created_at: new Date("2026-01-01T00:00:00Z"),
94
+ },
95
+ ],
96
+ });
97
+ const rows = await store.query({ tenantId: "t1" });
98
+ expect(rows[0]).toMatchObject({ id: "a1", status: "ok", errorCode: undefined });
99
+ });
100
+ });
@@ -100,4 +100,18 @@ describe("PostgreSQLProjectRoomMessageStore", () => {
100
100
  await expect(new PostgreSQLProjectRoomMessageStore({ pool }).findById("tenant-1", "message-1"))
101
101
  .rejects.toThrow("Invalid project room message row");
102
102
  });
103
+
104
+ it("skips individually invalid rows when listing while preserving valid rows", async () => {
105
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
106
+ const malformed = { ...row, id: "message-bad", content: { text: "missing type" } };
107
+ query.mockResolvedValueOnce({ rows: [row, malformed] });
108
+ const store = new PostgreSQLProjectRoomMessageStore({ pool });
109
+ await expect(store.list({ tenantId: "tenant-1", roomId: "room-1", limit: 10 }))
110
+ .resolves.toEqual([expect.objectContaining({ id: "message-1" })]);
111
+ expect(warn).toHaveBeenCalledWith(
112
+ "[ProjectRoomMessageStore] Skipping invalid message row",
113
+ expect.objectContaining({ tenantId: "tenant-1", roomId: "room-1", rowId: "message-bad" }),
114
+ );
115
+ warn.mockRestore();
116
+ });
103
117
  });
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it, jest, beforeEach } from "@jest/globals";
2
+ import type { Pool } from "pg";
3
+ import { PostgresSharedResourceStore } from "../stores/PostgresSharedResourceStore";
4
+
5
+ const mockQuery = jest.fn();
6
+
7
+ describe("PostgresSharedResourceStore openGrants", () => {
8
+ const pool = { query: mockQuery } as unknown as Pool;
9
+ beforeEach(() => jest.clearAllMocks());
10
+
11
+ it("persists open_grants as JSON on create", async () => {
12
+ const store = new PostgresSharedResourceStore({ pool } as never);
13
+ const openGrants = [{ domain: "kb", items: ["search"] }];
14
+ mockQuery.mockResolvedValueOnce({
15
+ rows: [
16
+ {
17
+ token: "tok",
18
+ address: JSON.stringify({ volume: "v", resourcePath: "index.html" }),
19
+ title: null,
20
+ created_by: "u1",
21
+ tenant_id: "t1",
22
+ workspace_id: "w1",
23
+ project_id: "p1",
24
+ assistant_id: null,
25
+ vm_isolation: "project",
26
+ visibility: "public",
27
+ password_hash: null,
28
+ expires_at: null,
29
+ max_access: null,
30
+ open_grants: openGrants,
31
+ access_count: 0,
32
+ revoked: false,
33
+ created_at: new Date().toISOString(),
34
+ version: 1,
35
+ },
36
+ ],
37
+ });
38
+
39
+ const record = await store.create({
40
+ token: "tok",
41
+ address: { volume: "v", resourcePath: "index.html" },
42
+ title: null,
43
+ createdBy: "u1",
44
+ tenantId: "t1",
45
+ workspaceId: "w1",
46
+ projectId: "p1",
47
+ assistantId: null,
48
+ vmIsolation: "project",
49
+ visibility: "public",
50
+ passwordHash: null,
51
+ expiresAt: null,
52
+ maxAccess: null,
53
+ openGrants,
54
+ revoked: false,
55
+ } as never);
56
+
57
+ const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
58
+ expect(sql).toContain("open_grants");
59
+ expect(params).toContain(JSON.stringify(openGrants));
60
+ expect(record.openGrants).toEqual(openGrants);
61
+ });
62
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * create_open_audit_table migration tests
3
+ */
4
+
5
+ import { describe, expect, it, jest } from "@jest/globals";
6
+ import type { PoolClient } from "pg";
7
+ import { createOpenAuditTable } from "../migrations/create_open_audit_table";
8
+
9
+ describe("create_open_audit_table migration", () => {
10
+ it("has name create_open_audit_table", () => {
11
+ expect(createOpenAuditTable.name).toBe("create_open_audit_table");
12
+ });
13
+
14
+ it("has version 179", () => {
15
+ expect(createOpenAuditTable.version).toBe(179);
16
+ });
17
+
18
+ it("creates the audit table and tenant index in up()", async () => {
19
+ const queries: string[] = [];
20
+ const client = {
21
+ query: jest.fn(async (sql: string) => {
22
+ queries.push(sql);
23
+ }),
24
+ };
25
+ await createOpenAuditTable.up(client as unknown as PoolClient);
26
+ expect(queries.some((q) => q.includes("CREATE TABLE IF NOT EXISTS lattice_open_audit"))).toBe(true);
27
+ expect(queries.some((q) => q.includes("idx_lattice_open_audit_tenant_created"))).toBe(true);
28
+ });
29
+
30
+ it("drops the table in down()", async () => {
31
+ const queries: string[] = [];
32
+ const client = {
33
+ query: jest.fn(async (sql: string) => {
34
+ queries.push(sql);
35
+ }),
36
+ };
37
+ await createOpenAuditTable.down!(client as unknown as PoolClient);
38
+ expect(queries.some((q) => q.includes("DROP TABLE IF EXISTS lattice_open_audit"))).toBe(true);
39
+ });
40
+ });
@@ -17,6 +17,7 @@ import { ThreadMessageQueueStore } from "./stores/ThreadMessageQueueStore";
17
17
  import { ChannelBindingStore } from "./stores/ChannelBindingStore";
18
18
  import { PostgreSQLChannelInstallationStore } from "./stores/PostgreSQLChannelInstallationStore";
19
19
  import { PostgreSQLA2AApiKeyStore } from "./stores/PostgreSQLA2AApiKeyStore";
20
+ import { PostgreSQLOpenAuditStore } from "./stores/PostgreSQLOpenAuditStore";
20
21
  import { PostgreSQLAgentWebAppStore } from "./stores/PostgreSQLAgentWebAppStore";
21
22
  import { PostgreSQLScheduleStorage } from "./stores/PostgreSQLScheduleStorage";
22
23
  import { PostgreSQLTaskStore } from "./stores/PostgreSQLTaskStore";
@@ -67,6 +68,9 @@ import { createWorkflowTrackingTables, addStepThreadId, addWorkflowRunsTenantSta
67
68
  import { evalMigrations } from "./migrations/eval_migrations";
68
69
  import { createA2AApiKeysTable } from "./migrations/a2a_api_key_migration";
69
70
  import { addA2AKeyAssistantIds } from "./migrations/add_a2a_key_assistant_ids";
71
+ import { addApiKeyGrants } from "./migrations/add_api_key_grants";
72
+ import { createOpenAuditTable } from "./migrations/create_open_audit_table";
73
+ import { addShareOpenGrants } from "./migrations/add_share_open_grants";
70
74
  import { taskMigrations } from "./migrations/task_migration";
71
75
  import { createMenuItemsTable } from "./migrations/menu_items_migration";
72
76
  import { addFileContentType } from "./migrations/menu_items_add_file_type";
@@ -148,6 +152,9 @@ export async function createPgStoreConfig(connectionString: string) {
148
152
  mm.register(addWorkflowRunsTenantStatusUpdatedIndex); // v161
149
153
  mm.register(addProjectKindColumn); // v165
150
154
  mm.register(addA2AKeyAssistantIds); // v167
155
+ mm.register(addApiKeyGrants); // v178
156
+ mm.register(createOpenAuditTable); // v179
157
+ mm.register(addShareOpenGrants); // v180
151
158
  mm.register(addTaskWorkItemEventKeyMigration); // v168
152
159
  mm.register(createAgentWebAppsTable); // v169
153
160
  mm.register(createCapabilityBundlesTable); // v170
@@ -189,6 +196,7 @@ export async function createPgStoreConfig(connectionString: string) {
189
196
  task: new PostgreSQLTaskStore(opts),
190
197
  taskWorkItem: taskWorkItemStore,
191
198
  a2aApiKey: new PostgreSQLA2AApiKeyStore(opts),
199
+ openAudit: new PostgreSQLOpenAuditStore(opts),
192
200
  agentWebApp: new PostgreSQLAgentWebAppStore(opts),
193
201
  capabilityBundle: new PostgreSQLCapabilityBundleStore(opts),
194
202
  schedule: new PostgreSQLScheduleStorage(opts),
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ export * from "./stores/ThreadMessageQueueStore";
57
57
  export * from "./stores/ChannelIdentityMappingStore";
58
58
  export * from "./stores/PostgreSQLChannelInstallationStore";
59
59
  export * from "./stores/PostgreSQLA2AApiKeyStore";
60
+ export * from "./stores/PostgreSQLOpenAuditStore";
60
61
  export * from "./stores/PostgreSQLAgentWebAppStore";
61
62
  export * from "./stores/PostgreSQLTaskWorkItemStore";
62
63
  export * from "./stores/PostgreSQLCapabilityBundleStore";
@@ -0,0 +1,25 @@
1
+ import type { Migration } from "./migration";
2
+ import type { PoolClient } from "pg";
3
+
4
+ /**
5
+ * Adds the Open-door grants JSONB column to API keys.
6
+ *
7
+ * Absent/empty grants → legacy derivation via effectiveGrants()
8
+ * (assistantIds → agent-domain grant). The A2A door never reads this column.
9
+ */
10
+ export const addApiKeyGrants: Migration = {
11
+ version: 178,
12
+ name: "add_api_key_grants",
13
+ up: async (client: PoolClient) => {
14
+ await client.query(`
15
+ ALTER TABLE lattice_a2a_api_keys
16
+ ADD COLUMN IF NOT EXISTS grants JSONB
17
+ `);
18
+ },
19
+ down: async (client: PoolClient) => {
20
+ await client.query(`
21
+ ALTER TABLE lattice_a2a_api_keys
22
+ DROP COLUMN IF EXISTS grants
23
+ `);
24
+ },
25
+ };
@@ -0,0 +1,23 @@
1
+ import type { Migration } from "./migration";
2
+ import type { PoolClient } from "pg";
3
+
4
+ /**
5
+ * Adds Open-door grants to resource shares. Sandbox tokens minted for a share
6
+ * append these (sanitized against the V1 whitelist) to their self grant.
7
+ */
8
+ export const addShareOpenGrants: Migration = {
9
+ version: 180,
10
+ name: "add_share_open_grants",
11
+ up: async (client: PoolClient) => {
12
+ await client.query(`
13
+ ALTER TABLE lattice_shared_resources
14
+ ADD COLUMN IF NOT EXISTS open_grants JSONB
15
+ `);
16
+ },
17
+ down: async (client: PoolClient) => {
18
+ await client.query(`
19
+ ALTER TABLE lattice_shared_resources
20
+ DROP COLUMN IF EXISTS open_grants
21
+ `);
22
+ },
23
+ };
@@ -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
+ };
@@ -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
- if (decrypt(row.key_value) === key) return mapRowToRecord(row);
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
- return result.rows[0] ? mapRowToRecord(result.rows[0]) : null;
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.map(mapRowToRecord);
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.map(mapRowToRecord);
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
- [encrypt(key), input.tenantId, input.projectId, input.assistantIds ? JSON.stringify(input.assistantIds) : null, input.label || null],
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
- const key = decrypt(row.key_value);
211
- map.set(key, {
212
- key,
213
- tenantId: row.tenant_id,
214
- projectId: row.project_id,
215
- assistantIds: row.assistant_ids ?? undefined,
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
  }