openclaw-db9-audit 0.1.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/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ import { db9AuditConfigSchema, loadDb9AuditConfig } from "./src/config.js";
3
+ import { registerDb9AuditCli } from "./src/cli.js";
4
+ import { createDb9AuditService } from "./src/service.js";
5
+
6
+ const plugin = {
7
+ id: "db9-audit",
8
+ name: "DB9 Audit",
9
+ description: "Mirror OpenClaw transcripts and agent events into DB9",
10
+ configSchema: db9AuditConfigSchema,
11
+ register(api: OpenClawPluginApi) {
12
+ const config = loadDb9AuditConfig(api.pluginConfig);
13
+ const service = createDb9AuditService({ api, config });
14
+ api.registerService(service);
15
+ registerDb9AuditCli(api, config);
16
+ api.on("agent_end", (event, ctx) => {
17
+ service.handleAgentEnd(event, ctx);
18
+ });
19
+ },
20
+ };
21
+
22
+ export default plugin;
@@ -0,0 +1,28 @@
1
+ {
2
+ "id": "db9-audit",
3
+ "name": "DB9 Audit",
4
+ "description": "Mirror OpenClaw transcripts and agent events into DB9 for auditing",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "enabled": { "type": "boolean" },
10
+ "apiBase": { "type": "string", "minLength": 1 },
11
+ "databaseName": { "type": "string", "minLength": 1 },
12
+ "databaseRegion": { "type": "string", "minLength": 1 },
13
+ "schema": { "type": "string", "minLength": 1 },
14
+ "logRoot": { "type": "string", "minLength": 1 },
15
+ "batchSize": { "type": "integer", "minimum": 1 },
16
+ "flushIntervalMs": { "type": "integer", "minimum": 100 },
17
+ "backfillOnStart": { "type": "boolean" },
18
+ "redact": {
19
+ "type": "object",
20
+ "additionalProperties": false,
21
+ "properties": {
22
+ "enabled": { "type": "boolean" },
23
+ "maxFieldBytes": { "type": "integer", "minimum": 1024 }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "openclaw-db9-audit",
3
+ "version": "0.1.0",
4
+ "description": "OpenClaw audit plugin backed by DB9 PostgreSQL and FS WebSocket storage",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.12.0"
8
+ },
9
+ "packageManager": "npm@11.6.2",
10
+ "files": [
11
+ "index.ts",
12
+ "openclaw.plugin.json",
13
+ "src/"
14
+ ],
15
+ "openclaw": {
16
+ "extensions": [
17
+ "./index.ts"
18
+ ]
19
+ },
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "vitest run",
23
+ "check": "npm run typecheck && npm run test"
24
+ },
25
+ "peerDependencies": {
26
+ "openclaw": ">=2026.3.2"
27
+ },
28
+ "dependencies": {
29
+ "pg": "^8.13.3",
30
+ "ws": "^8.18.1"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^25.3.3",
34
+ "@types/pg": "^8.15.5",
35
+ "@types/ws": "^8.18.1",
36
+ "openclaw": "file:../openclaw",
37
+ "typescript": "^5.9.3",
38
+ "vitest": "^4.0.18"
39
+ }
40
+ }
@@ -0,0 +1,277 @@
1
+ import type { PluginLogger } from "openclaw/plugin-sdk/core";
2
+ import { Db9ControlPlaneClient, Db9ControlPlaneError } from "./control-plane.js";
3
+ import type {
4
+ Db9AnonymousRefreshResponse,
5
+ Db9AuditPluginConfig,
6
+ Db9AuditState,
7
+ Db9CustomerResponse,
8
+ Db9DatabaseCredentialsResponse,
9
+ Db9DatabaseResponse,
10
+ } from "./types.js";
11
+ import { parseConnectionString, resolveFsWsUrl, toIsoString } from "./utils.js";
12
+
13
+ export class Db9AuditDatabaseUnavailableError extends Error {
14
+ readonly databaseId: string;
15
+ readonly causeCode?: string | undefined;
16
+
17
+ constructor(params: { databaseId: string; message: string; causeCode?: string | undefined }) {
18
+ super(params.message);
19
+ this.name = "Db9AuditDatabaseUnavailableError";
20
+ this.databaseId = params.databaseId;
21
+ this.causeCode = params.causeCode;
22
+ }
23
+ }
24
+
25
+ function buildState(params: {
26
+ config: Db9AuditPluginConfig;
27
+ apiBase: string;
28
+ customer: {
29
+ id: string;
30
+ token: string;
31
+ tokenExpiresAt?: string | undefined;
32
+ anonymousId?: string | undefined;
33
+ anonymousSecret?: string | undefined;
34
+ isAnonymous: boolean;
35
+ };
36
+ database: {
37
+ id: string;
38
+ name: string;
39
+ state: string;
40
+ region?: string | undefined;
41
+ connectionString: string;
42
+ adminPassword: string;
43
+ };
44
+ previous?: Db9AuditState | undefined;
45
+ }): Db9AuditState {
46
+ const parsed = parseConnectionString(params.database.connectionString);
47
+ const now = toIsoString();
48
+
49
+ return {
50
+ version: 1,
51
+ apiBase: params.apiBase,
52
+ customer: {
53
+ id: params.customer.id,
54
+ token: params.customer.token,
55
+ ...(params.customer.tokenExpiresAt ? { tokenExpiresAt: params.customer.tokenExpiresAt } : {}),
56
+ isAnonymous: params.customer.isAnonymous,
57
+ ...(params.customer.anonymousId ? { anonymousId: params.customer.anonymousId } : {}),
58
+ ...(params.customer.anonymousSecret ? { anonymousSecret: params.customer.anonymousSecret } : {}),
59
+ },
60
+ database: {
61
+ id: params.database.id,
62
+ name: params.database.name,
63
+ state: params.database.state,
64
+ adminUser: parsed.username,
65
+ adminPassword: params.database.adminPassword,
66
+ connectionString: params.database.connectionString,
67
+ host: parsed.host,
68
+ port: parsed.port,
69
+ database: parsed.database,
70
+ ...(params.database.region ? { region: params.database.region } : {}),
71
+ },
72
+ fs: {
73
+ wsUrl: resolveFsWsUrl(parsed.host),
74
+ username: parsed.username,
75
+ password: params.database.adminPassword,
76
+ },
77
+ plugin: {
78
+ schema: params.config.schema,
79
+ logRoot: params.config.logRoot,
80
+ initializedAt: params.previous?.plugin.initializedAt ?? now,
81
+ lastBootstrapAt: now,
82
+ },
83
+ };
84
+ }
85
+
86
+ function requireConnectionString(response: Db9DatabaseResponse | Db9DatabaseCredentialsResponse): string {
87
+ const connectionString = response.connection_string?.trim();
88
+ if (!connectionString) {
89
+ throw new Error("DB9 did not return a connection string");
90
+ }
91
+ return connectionString;
92
+ }
93
+
94
+ function requireAdminPassword(response: Db9DatabaseResponse | Db9DatabaseCredentialsResponse): string {
95
+ const password = response.admin_password?.trim();
96
+ if (!password) {
97
+ throw new Error("DB9 did not return an admin password");
98
+ }
99
+ return password;
100
+ }
101
+
102
+ function isTokenLikelyExpired(tokenExpiresAt: string | undefined): boolean {
103
+ if (!tokenExpiresAt) {
104
+ return false;
105
+ }
106
+ const expiresAt = Date.parse(tokenExpiresAt);
107
+ if (Number.isNaN(expiresAt)) {
108
+ return false;
109
+ }
110
+ return expiresAt - Date.now() < 60_000;
111
+ }
112
+
113
+ async function refreshAnonymousToken(params: {
114
+ client: Db9ControlPlaneClient;
115
+ state: Db9AuditState;
116
+ logger?: PluginLogger | undefined;
117
+ }): Promise<Db9AnonymousRefreshResponse> {
118
+ const anonymousId = params.state.customer.anonymousId?.trim();
119
+ const anonymousSecret = params.state.customer.anonymousSecret?.trim();
120
+ if (!anonymousId || !anonymousSecret) {
121
+ throw new Error("Anonymous recovery credentials are not available");
122
+ }
123
+ params.logger?.info?.("db9-audit: refreshing anonymous DB9 token");
124
+ return await params.client.anonymousRefresh({
125
+ anonymousId,
126
+ anonymousSecret,
127
+ });
128
+ }
129
+
130
+ async function withRefreshedToken<T>(params: {
131
+ client: Db9ControlPlaneClient;
132
+ state: Db9AuditState;
133
+ logger?: PluginLogger | undefined;
134
+ action: (token: string) => Promise<T>;
135
+ }): Promise<{
136
+ result: T;
137
+ token: string;
138
+ tokenExpiresAt?: string | undefined;
139
+ }> {
140
+ let token = params.state.customer.token;
141
+ let tokenExpiresAt = params.state.customer.tokenExpiresAt;
142
+
143
+ const refresh = async () => {
144
+ const refreshed = await refreshAnonymousToken(params);
145
+ token = refreshed.token;
146
+ tokenExpiresAt = refreshed.expires_at;
147
+ };
148
+
149
+ if (isTokenLikelyExpired(tokenExpiresAt)) {
150
+ await refresh();
151
+ }
152
+
153
+ try {
154
+ const result = await params.action(token);
155
+ return { result, token, ...(tokenExpiresAt ? { tokenExpiresAt } : {}) };
156
+ } catch (error) {
157
+ if (!(error instanceof Db9ControlPlaneError) || error.status !== 401) {
158
+ throw error;
159
+ }
160
+ await refresh();
161
+ const result = await params.action(token);
162
+ return { result, token, ...(tokenExpiresAt ? { tokenExpiresAt } : {}) };
163
+ }
164
+ }
165
+
166
+ export async function bootstrapFreshState(params: {
167
+ client: Db9ControlPlaneClient;
168
+ config: Db9AuditPluginConfig;
169
+ logger?: PluginLogger | undefined;
170
+ }): Promise<Db9AuditState> {
171
+ params.logger?.info?.("db9-audit: bootstrapping fresh DB9 state");
172
+ const anonymous = await params.client.anonymousRegister();
173
+ const me = await params.client.getMe(anonymous.token);
174
+ const database = await params.client.createDatabase(anonymous.token, {
175
+ name: params.config.databaseName,
176
+ ...(params.config.databaseRegion ? { region: params.config.databaseRegion } : {}),
177
+ });
178
+
179
+ return buildState({
180
+ config: params.config,
181
+ apiBase: params.client.apiBase,
182
+ customer: {
183
+ id: me.id,
184
+ token: anonymous.token,
185
+ tokenExpiresAt: anonymous.expires_at,
186
+ isAnonymous: anonymous.is_anonymous,
187
+ anonymousId: anonymous.anonymous_id,
188
+ anonymousSecret: anonymous.anonymous_secret,
189
+ },
190
+ database: {
191
+ id: database.id,
192
+ name: database.name,
193
+ state: database.state,
194
+ region: database.region,
195
+ connectionString: requireConnectionString(database),
196
+ adminPassword: requireAdminPassword(database),
197
+ },
198
+ });
199
+ }
200
+
201
+ async function ensureCustomerIdentity(params: {
202
+ client: Db9ControlPlaneClient;
203
+ token: string;
204
+ existingCustomerId: string;
205
+ }): Promise<Db9CustomerResponse> {
206
+ if (params.existingCustomerId.trim()) {
207
+ return {
208
+ id: params.existingCustomerId,
209
+ email: "",
210
+ created_at: "",
211
+ status: "",
212
+ };
213
+ }
214
+ return await params.client.getMe(params.token);
215
+ }
216
+
217
+ export async function recoverDb9AuditState(params: {
218
+ client: Db9ControlPlaneClient;
219
+ config: Db9AuditPluginConfig;
220
+ state: Db9AuditState;
221
+ logger?: PluginLogger | undefined;
222
+ }): Promise<Db9AuditState> {
223
+ if (!params.state.database.id.trim()) {
224
+ throw new Error("State is missing database id");
225
+ }
226
+
227
+ params.logger?.info?.(`db9-audit: recovering DB9 credentials for database ${params.state.database.id}`);
228
+ const recovered = await withRefreshedToken({
229
+ client: params.client,
230
+ state: params.state,
231
+ logger: params.logger,
232
+ action: async (token) => await params.client.getCredentials(token, params.state.database.id),
233
+ }).catch((error: unknown) => {
234
+ if (
235
+ error instanceof Db9ControlPlaneError &&
236
+ (error.status === 404 || error.status === 409)
237
+ ) {
238
+ throw new Db9AuditDatabaseUnavailableError({
239
+ databaseId: params.state.database.id,
240
+ causeCode: error.code,
241
+ message:
242
+ error.status === 404
243
+ ? `DB9 database ${params.state.database.id} no longer exists`
244
+ : `DB9 database ${params.state.database.id} is not available: ${error.message}`,
245
+ });
246
+ }
247
+ throw error;
248
+ });
249
+
250
+ const identity = await ensureCustomerIdentity({
251
+ client: params.client,
252
+ token: recovered.token,
253
+ existingCustomerId: params.state.customer.id,
254
+ });
255
+
256
+ return buildState({
257
+ config: params.config,
258
+ apiBase: params.client.apiBase,
259
+ previous: params.state,
260
+ customer: {
261
+ id: identity.id,
262
+ token: recovered.token,
263
+ tokenExpiresAt: recovered.tokenExpiresAt,
264
+ isAnonymous: params.state.customer.isAnonymous,
265
+ anonymousId: params.state.customer.anonymousId,
266
+ anonymousSecret: params.state.customer.anonymousSecret,
267
+ },
268
+ database: {
269
+ id: params.state.database.id,
270
+ name: params.state.database.name,
271
+ state: "ACTIVE",
272
+ region: params.state.database.region,
273
+ connectionString: requireConnectionString(recovered.result),
274
+ adminPassword: requireAdminPassword(recovered.result),
275
+ },
276
+ });
277
+ }