@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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +16 -0
- package/dist/index.d.mts +26 -2
- package/dist/index.d.ts +26 -2
- package/dist/index.js +322 -47
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +321 -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__/PostgreSQLOpenAuditStore.test.ts +100 -0
- package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +14 -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 +8 -0
- package/src/index.ts +1 -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/stores/PostgreSQLA2AApiKeyStore.ts +90 -14
- package/src/stores/PostgreSQLOpenAuditStore.ts +143 -0
- package/src/stores/PostgreSQLProjectRoomMessageStore.ts +20 -1
- package/src/stores/PostgresSharedResourceStore.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/pg-stores",
|
|
3
|
-
"version": "3.1.
|
|
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.
|
|
29
|
-
"@axiom-lattice/protocols": "4.2.
|
|
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
|
|
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
|
-
|
|
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
|
}
|