@axiom-lattice/pg-stores 5.1.2 → 5.2.0
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 +69 -0
- package/dist/index.d.mts +87 -3
- package/dist/index.d.ts +87 -3
- package/dist/index.js +534 -68
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +477 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLMcpServerConfigStore.integration.test.ts +122 -0
- package/src/__tests__/PostgreSQLMcpServerConfigStore.test.ts +81 -0
- package/src/__tests__/PostgreSQLPlatformAuditStore.test.ts +446 -0
- package/src/__tests__/PostgreSQLUserTenantLinkStore.test.ts +62 -0
- package/src/createPgStoreConfig.ts +6 -1
- package/src/index.ts +2 -0
- package/src/migrations/mcp_server_config_migrations.ts +47 -0
- package/src/migrations/platform_audit_migration.ts +45 -0
- package/src/stores/PostgreSQLMcpServerConfigStore.ts +343 -15
- package/src/stores/PostgreSQLPlatformAuditStore.ts +209 -0
- package/src/stores/PostgreSQLUserTenantLinkStore.ts +2 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PlatformAuditAction,
|
|
3
|
+
PlatformAuditAppendInput,
|
|
4
|
+
PlatformAuditInitiator,
|
|
5
|
+
PlatformAuditQuery,
|
|
6
|
+
PlatformAuditRecord,
|
|
7
|
+
PlatformAuditSource,
|
|
8
|
+
PlatformAuditStatus,
|
|
9
|
+
PlatformAuditStore,
|
|
10
|
+
} from "@axiom-lattice/protocols";
|
|
11
|
+
import type { Pool } from "pg";
|
|
12
|
+
|
|
13
|
+
const TABLE = "lattice_platform_audit";
|
|
14
|
+
|
|
15
|
+
interface AuditRow {
|
|
16
|
+
id: string;
|
|
17
|
+
initiator_user_id: string | null;
|
|
18
|
+
initiator_role: string | null;
|
|
19
|
+
initiator: PlatformAuditInitiator | string;
|
|
20
|
+
source: string;
|
|
21
|
+
executor: PlatformAuditRecord["executor"] | string;
|
|
22
|
+
tenant_id: string | null;
|
|
23
|
+
action: string;
|
|
24
|
+
status: string;
|
|
25
|
+
summary: Record<string, unknown> | string;
|
|
26
|
+
before: Record<string, unknown> | string | null;
|
|
27
|
+
after: Record<string, unknown> | string | null;
|
|
28
|
+
error_code: string | null;
|
|
29
|
+
duration_ms: number;
|
|
30
|
+
created_at: Date;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseJson<T>(value: T | string): T {
|
|
34
|
+
return typeof value === "string" ? (JSON.parse(value) as T) : value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalizes an optional timestamp boundary to a canonical UTC ISO string.
|
|
39
|
+
*
|
|
40
|
+
* Equivalent inputs (e.g. `2024-01-01T08:00:01+08:00`, date-only values, or
|
|
41
|
+
* explicit `Z`) all compare as the same instant. Empty or unparseable values
|
|
42
|
+
* are ignored rather than compared as raw text.
|
|
43
|
+
*/
|
|
44
|
+
function normalizeBoundary(value?: string): string | undefined {
|
|
45
|
+
if (value === undefined) return undefined;
|
|
46
|
+
const trimmed = value.trim();
|
|
47
|
+
if (trimmed === "") return undefined;
|
|
48
|
+
const parsed = new Date(trimmed);
|
|
49
|
+
if (Number.isNaN(parsed.getTime())) return undefined;
|
|
50
|
+
return parsed.toISOString();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Normalizes a pagination value: non-finite values use the fallback, finite
|
|
55
|
+
* values are truncated to integers, and negatives are clamped to zero.
|
|
56
|
+
*/
|
|
57
|
+
function normalizePagination(
|
|
58
|
+
value: number | undefined,
|
|
59
|
+
fallback: number,
|
|
60
|
+
): number {
|
|
61
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
62
|
+
return Math.max(0, Math.trunc(value));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mapRow(row: AuditRow): PlatformAuditRecord {
|
|
66
|
+
return {
|
|
67
|
+
id: row.id,
|
|
68
|
+
initiator: parseJson<PlatformAuditInitiator>(row.initiator),
|
|
69
|
+
source: row.source as PlatformAuditSource,
|
|
70
|
+
executor: parseJson<PlatformAuditRecord["executor"]>(row.executor),
|
|
71
|
+
...(row.tenant_id !== null ? { tenantId: row.tenant_id } : {}),
|
|
72
|
+
action: row.action as PlatformAuditAction,
|
|
73
|
+
status: row.status as PlatformAuditStatus,
|
|
74
|
+
summary: parseJson<Record<string, unknown>>(row.summary),
|
|
75
|
+
...(row.before !== null
|
|
76
|
+
? { before: parseJson<Record<string, unknown>>(row.before) }
|
|
77
|
+
: {}),
|
|
78
|
+
...(row.after !== null
|
|
79
|
+
? { after: parseJson<Record<string, unknown>>(row.after) }
|
|
80
|
+
: {}),
|
|
81
|
+
...(row.error_code !== null ? { errorCode: row.error_code } : {}),
|
|
82
|
+
durationMs: Number(row.duration_ms),
|
|
83
|
+
createdAt: row.created_at,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* PostgreSQL implementation of {@link PlatformAuditStore}.
|
|
89
|
+
*
|
|
90
|
+
* Persists platform-level audit records in the append-only
|
|
91
|
+
* `lattice_platform_audit` table. Records are returned newest-first by
|
|
92
|
+
* `createdAt` descending, then `id` descending.
|
|
93
|
+
*/
|
|
94
|
+
export class PostgreSQLPlatformAuditStore implements PlatformAuditStore {
|
|
95
|
+
constructor(private readonly options: { pool: Pool }) {}
|
|
96
|
+
|
|
97
|
+
private get pool(): Pool {
|
|
98
|
+
return this.options.pool;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Appends a record, assigning a fresh id and creation timestamp. */
|
|
102
|
+
async append(record: PlatformAuditAppendInput): Promise<PlatformAuditRecord> {
|
|
103
|
+
const initiator = record.initiator;
|
|
104
|
+
const initiatorUserId =
|
|
105
|
+
initiator.type === "human" ? initiator.userId : null;
|
|
106
|
+
const initiatorRole = initiator.type === "human" ? initiator.role : null;
|
|
107
|
+
const durationMs = Number.isFinite(record.durationMs)
|
|
108
|
+
? Math.max(0, Math.round(record.durationMs))
|
|
109
|
+
: 0;
|
|
110
|
+
|
|
111
|
+
const result = await this.pool.query<AuditRow>(
|
|
112
|
+
`INSERT INTO ${TABLE}
|
|
113
|
+
(initiator_user_id, initiator_role, initiator, source, executor,
|
|
114
|
+
tenant_id, action, status, summary, before, after, error_code,
|
|
115
|
+
duration_ms)
|
|
116
|
+
VALUES ($1,$2,$3::jsonb,$4,$5::jsonb,$6,$7,$8,$9::jsonb,$10::jsonb,
|
|
117
|
+
$11::jsonb,$12,$13)
|
|
118
|
+
RETURNING *`,
|
|
119
|
+
[
|
|
120
|
+
initiatorUserId,
|
|
121
|
+
initiatorRole,
|
|
122
|
+
JSON.stringify(initiator),
|
|
123
|
+
record.source,
|
|
124
|
+
JSON.stringify(record.executor),
|
|
125
|
+
record.tenantId ?? null,
|
|
126
|
+
record.action,
|
|
127
|
+
record.status,
|
|
128
|
+
JSON.stringify(record.summary),
|
|
129
|
+
record.before === undefined ? null : JSON.stringify(record.before),
|
|
130
|
+
record.after === undefined ? null : JSON.stringify(record.after),
|
|
131
|
+
record.errorCode ?? null,
|
|
132
|
+
durationMs,
|
|
133
|
+
],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const row = result.rows[0];
|
|
137
|
+
if (!row) throw new Error("Platform audit record was not persisted");
|
|
138
|
+
return mapRow(row);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Returns a record by id, or null when it does not exist. */
|
|
142
|
+
async get(id: string): Promise<PlatformAuditRecord | null> {
|
|
143
|
+
const result = await this.pool.query<AuditRow>(
|
|
144
|
+
`SELECT * FROM ${TABLE} WHERE id = $1`,
|
|
145
|
+
[id],
|
|
146
|
+
);
|
|
147
|
+
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Returns matching records newest-first.
|
|
152
|
+
*
|
|
153
|
+
* `initiatorUserId` only matches human initiators. `from` is inclusive and
|
|
154
|
+
* `to` is exclusive after both are normalized to instants; invalid or empty
|
|
155
|
+
* boundaries are ignored. Records are ordered by `createdAt` descending,
|
|
156
|
+
* then by `id` descending. Results are paginated with a default limit of 100;
|
|
157
|
+
* non-finite `offset`/`limit` fall back to defaults, finite values are
|
|
158
|
+
* truncated to integers, and negatives are clamped to 0.
|
|
159
|
+
*/
|
|
160
|
+
async query(params: PlatformAuditQuery): Promise<PlatformAuditRecord[]> {
|
|
161
|
+
const clauses: string[] = [];
|
|
162
|
+
const values: unknown[] = [];
|
|
163
|
+
|
|
164
|
+
if (params.initiatorUserId !== undefined) {
|
|
165
|
+
values.push(params.initiatorUserId);
|
|
166
|
+
clauses.push(`initiator_user_id = $${values.length}`);
|
|
167
|
+
}
|
|
168
|
+
if (params.source !== undefined) {
|
|
169
|
+
values.push(params.source);
|
|
170
|
+
clauses.push(`source = $${values.length}`);
|
|
171
|
+
}
|
|
172
|
+
if (params.tenantId !== undefined) {
|
|
173
|
+
values.push(params.tenantId);
|
|
174
|
+
clauses.push(`tenant_id = $${values.length}`);
|
|
175
|
+
}
|
|
176
|
+
if (params.action !== undefined) {
|
|
177
|
+
values.push(params.action);
|
|
178
|
+
clauses.push(`action = $${values.length}`);
|
|
179
|
+
}
|
|
180
|
+
if (params.status !== undefined) {
|
|
181
|
+
values.push(params.status);
|
|
182
|
+
clauses.push(`status = $${values.length}`);
|
|
183
|
+
}
|
|
184
|
+
const from = normalizeBoundary(params.from);
|
|
185
|
+
if (from !== undefined) {
|
|
186
|
+
values.push(from);
|
|
187
|
+
clauses.push(`created_at >= $${values.length}`);
|
|
188
|
+
}
|
|
189
|
+
const to = normalizeBoundary(params.to);
|
|
190
|
+
if (to !== undefined) {
|
|
191
|
+
values.push(to);
|
|
192
|
+
clauses.push(`created_at < $${values.length}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const limit = normalizePagination(params.limit, 100);
|
|
196
|
+
const offset = normalizePagination(params.offset, 0);
|
|
197
|
+
values.push(limit, offset);
|
|
198
|
+
|
|
199
|
+
const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
200
|
+
const { rows } = await this.pool.query<AuditRow>(
|
|
201
|
+
`SELECT * FROM ${TABLE}${where}
|
|
202
|
+
ORDER BY created_at DESC, id DESC
|
|
203
|
+
LIMIT $${values.length - 1} OFFSET $${values.length}`,
|
|
204
|
+
values,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
return rows.map(mapRow);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Pool } from "pg";
|
|
2
2
|
import type { PoolConfig } from "pg";
|
|
3
3
|
import {
|
|
4
|
+
assertNotPlatformTenant,
|
|
4
5
|
UserTenantLinkStore,
|
|
5
6
|
UserTenantLink,
|
|
6
7
|
CreateUserTenantLinkRequest,
|
|
@@ -132,6 +133,7 @@ export class PostgreSQLUserTenantLinkStore implements UserTenantLinkStore {
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
async createLink(data: CreateUserTenantLinkRequest): Promise<UserTenantLink> {
|
|
136
|
+
assertNotPlatformTenant(data.tenantId);
|
|
135
137
|
await this.ensureInitialized();
|
|
136
138
|
|
|
137
139
|
const now = new Date();
|