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/src/service.ts ADDED
@@ -0,0 +1,356 @@
1
+ import type {
2
+ OpenClawPluginApi,
3
+ OpenClawPluginService,
4
+ OpenClawPluginServiceContext,
5
+ PluginHookAgentContext,
6
+ PluginHookAgentEndEvent,
7
+ } from "openclaw/plugin-sdk/core";
8
+ import { bootstrapFreshState, recoverDb9AuditState } from "./bootstrap.js";
9
+ import { Db9ControlPlaneClient } from "./control-plane.js";
10
+ import { Db9AuditEventLogSync } from "./event-log-sync.js";
11
+ import { Db9FsClient } from "./fs-client.js";
12
+ import { Db9AuditPostgres } from "./postgres.js";
13
+ import { Db9AuditRunTracker } from "./run-tracker.js";
14
+ import { Db9AuditSessionLookup, discoverTranscriptFiles } from "./session-store.js";
15
+ import { Db9AuditStateStore } from "./state-store.js";
16
+ import { Db9AuditTranscriptSync } from "./transcript-sync.js";
17
+ import type { Db9AuditPluginConfig, Db9AuditState } from "./types.js";
18
+ import { extractErrorMessage } from "./utils.js";
19
+
20
+ export class Db9AuditServiceRuntime implements OpenClawPluginService {
21
+ readonly id = "db9-audit";
22
+ private readonly api: OpenClawPluginApi;
23
+ private readonly config: Db9AuditPluginConfig;
24
+ private readonly controlPlane: Db9ControlPlaneClient;
25
+ private serviceContext: OpenClawPluginServiceContext | null = null;
26
+ private stateStore: Db9AuditStateStore | null = null;
27
+ private state: Db9AuditState | null = null;
28
+ private pg: Db9AuditPostgres | null = null;
29
+ private fsClient: Db9FsClient | null = null;
30
+ private readonly runTracker = new Db9AuditRunTracker();
31
+ private readonly sessionLookup = new Db9AuditSessionLookup();
32
+ private transcriptSync: Db9AuditTranscriptSync | null = null;
33
+ private eventLogSync: Db9AuditEventLogSync | null = null;
34
+ private reconnectingStatePromise: Promise<Db9AuditState | null> | null = null;
35
+ private pgConnectPromise: Promise<Db9AuditPostgres | null> | null = null;
36
+ private fsConnectPromise: Promise<Db9FsClient | null> | null = null;
37
+ private flushTimer: NodeJS.Timeout | null = null;
38
+ private stopAgentEvents: (() => void) | null = null;
39
+ private stopTranscriptEvents: (() => void) | null = null;
40
+ private runSummaryQueue: Promise<void> = Promise.resolve();
41
+ private started = false;
42
+ private stopped = false;
43
+
44
+ constructor(params: {
45
+ api: OpenClawPluginApi;
46
+ config: Db9AuditPluginConfig;
47
+ }) {
48
+ this.api = params.api;
49
+ this.config = params.config;
50
+ this.controlPlane = new Db9ControlPlaneClient(params.config.apiBase);
51
+ }
52
+
53
+ async start(ctx: OpenClawPluginServiceContext): Promise<void> {
54
+ this.serviceContext = ctx;
55
+ this.stateStore = new Db9AuditStateStore(ctx.stateDir);
56
+ this.stopped = false;
57
+
58
+ if (!this.config.enabled) {
59
+ ctx.logger.info?.("db9-audit: disabled");
60
+ return;
61
+ }
62
+
63
+ this.state = await this.loadOrBootstrapState();
64
+ this.eventLogSync = new Db9AuditEventLogSync({
65
+ getAppender: async () => {
66
+ const client = await this.ensureFs();
67
+ return client ? { appendText: (path, text) => client.appendText(path, text) } : null;
68
+ },
69
+ logger: ctx.logger,
70
+ logRoot: this.config.logRoot,
71
+ batchSize: this.config.batchSize,
72
+ redactConfig: this.config.redact,
73
+ runTracker: this.runTracker,
74
+ });
75
+ this.transcriptSync = new Db9AuditTranscriptSync({
76
+ sessionLookup: this.sessionLookup,
77
+ redactConfig: this.config.redact,
78
+ batchSize: this.config.batchSize,
79
+ logger: ctx.logger,
80
+ getStore: async () => {
81
+ const pg = await this.ensurePg();
82
+ return pg
83
+ ? {
84
+ getOffset: (sessionFile) => pg.getOffset(sessionFile),
85
+ syncTranscriptBatch: (batch) => pg.syncTranscriptBatch(batch),
86
+ }
87
+ : null;
88
+ },
89
+ onDiagnostic: async (event) => {
90
+ this.eventLogSync?.enqueueDiagnostic(event);
91
+ },
92
+ });
93
+
94
+ await Promise.allSettled([this.ensurePg(), this.ensureFs()]);
95
+
96
+ this.stopAgentEvents = this.api.runtime.events.onAgentEvent((event) => {
97
+ this.runTracker.recordAgentEvent(event);
98
+ this.eventLogSync?.enqueue(event);
99
+ });
100
+ this.stopTranscriptEvents = this.api.runtime.events.onSessionTranscriptUpdate(({ sessionFile }) => {
101
+ this.transcriptSync?.enqueue(sessionFile);
102
+ });
103
+
104
+ this.flushTimer = setInterval(() => {
105
+ void this.flushAll();
106
+ }, this.config.flushIntervalMs);
107
+ this.flushTimer.unref?.();
108
+
109
+ this.started = true;
110
+ this.api.logger.info?.("db9-audit: service started");
111
+
112
+ if (this.config.backfillOnStart) {
113
+ void this.backfillExistingTranscripts();
114
+ }
115
+ }
116
+
117
+ async stop(): Promise<void> {
118
+ this.stopAgentEvents?.();
119
+ this.stopAgentEvents = null;
120
+ this.stopTranscriptEvents?.();
121
+ this.stopTranscriptEvents = null;
122
+ if (this.flushTimer) {
123
+ clearInterval(this.flushTimer);
124
+ this.flushTimer = null;
125
+ }
126
+ await this.flushAll();
127
+ this.stopped = true;
128
+ await this.runSummaryQueue.catch(() => undefined);
129
+ await this.fsClient?.close().catch(() => undefined);
130
+ await this.pg?.close().catch(() => undefined);
131
+ this.fsClient = null;
132
+ this.pg = null;
133
+ this.started = false;
134
+ this.api.logger.info?.("db9-audit: stopped");
135
+ }
136
+
137
+ handleAgentEnd(event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext): void {
138
+ if (!this.config.enabled || !this.started) {
139
+ return;
140
+ }
141
+ const summary = this.runTracker.buildRunSummaryFromHook(event, ctx);
142
+ if (!summary) {
143
+ return;
144
+ }
145
+ this.runSummaryQueue = this.runSummaryQueue
146
+ .then(async () => {
147
+ const pg = await this.ensurePg();
148
+ if (!pg) {
149
+ this.eventLogSync?.enqueueDiagnostic({
150
+ stream: "error",
151
+ sessionKey: summary.sessionKey,
152
+ agentId: summary.agentId,
153
+ runId: summary.runId,
154
+ data: {
155
+ type: "run_summary_skipped",
156
+ reason: "pg_unavailable",
157
+ },
158
+ });
159
+ return;
160
+ }
161
+ await pg.upsertRunSummary(summary);
162
+ })
163
+ .catch((error) => {
164
+ this.api.logger.warn(`db9-audit: run summary upsert failed: ${extractErrorMessage(error) ?? String(error)}`);
165
+ });
166
+ }
167
+
168
+ private async loadOrBootstrapState(): Promise<Db9AuditState> {
169
+ if (!this.stateStore || !this.serviceContext) {
170
+ throw new Error("Service context is not initialized");
171
+ }
172
+ const existing = await this.stateStore.read();
173
+ if (existing) {
174
+ return existing;
175
+ }
176
+ const state = await bootstrapFreshState({
177
+ client: this.controlPlane,
178
+ config: this.config,
179
+ logger: this.serviceContext.logger,
180
+ });
181
+ await this.stateStore.write(state);
182
+ return state;
183
+ }
184
+
185
+ private async refreshStateViaControlPlane(): Promise<Db9AuditState | null> {
186
+ if (this.reconnectingStatePromise) {
187
+ return await this.reconnectingStatePromise;
188
+ }
189
+ if (!this.stateStore || !this.state || !this.serviceContext) {
190
+ return null;
191
+ }
192
+
193
+ this.reconnectingStatePromise = recoverDb9AuditState({
194
+ client: this.controlPlane,
195
+ config: this.config,
196
+ state: this.state,
197
+ logger: this.serviceContext.logger,
198
+ })
199
+ .then(async (nextState) => {
200
+ this.state = nextState;
201
+ await this.stateStore?.write(nextState);
202
+ await this.pg?.close().catch(() => undefined);
203
+ await this.fsClient?.close().catch(() => undefined);
204
+ this.pg = null;
205
+ this.fsClient = null;
206
+ return nextState;
207
+ })
208
+ .catch((error) => {
209
+ this.serviceContext?.logger.warn(`db9-audit: state recovery failed: ${extractErrorMessage(error) ?? String(error)}`);
210
+ return null;
211
+ })
212
+ .finally(() => {
213
+ this.reconnectingStatePromise = null;
214
+ });
215
+
216
+ return await this.reconnectingStatePromise;
217
+ }
218
+
219
+ private async ensurePg(): Promise<Db9AuditPostgres | null> {
220
+ if (this.pg) {
221
+ return this.pg;
222
+ }
223
+ if (this.pgConnectPromise) {
224
+ return await this.pgConnectPromise;
225
+ }
226
+ this.pgConnectPromise = this.connectPg();
227
+ try {
228
+ return await this.pgConnectPromise;
229
+ } finally {
230
+ this.pgConnectPromise = null;
231
+ }
232
+ }
233
+
234
+ private async connectPg(): Promise<Db9AuditPostgres | null> {
235
+ if (!this.state || !this.serviceContext) {
236
+ return null;
237
+ }
238
+ const attempt = async (state: Db9AuditState): Promise<Db9AuditPostgres> => {
239
+ const pg = new Db9AuditPostgres({
240
+ connectionString: state.database.connectionString,
241
+ schema: this.config.schema,
242
+ logger: this.serviceContext?.logger,
243
+ });
244
+ try {
245
+ await pg.ensureSchema();
246
+ return pg;
247
+ } catch (error) {
248
+ await pg.close().catch(() => undefined);
249
+ throw error;
250
+ }
251
+ };
252
+
253
+ try {
254
+ this.pg = await attempt(this.state);
255
+ return this.pg;
256
+ } catch (initialError) {
257
+ this.serviceContext.logger.warn(`db9-audit: PG connect failed, attempting recovery: ${extractErrorMessage(initialError) ?? String(initialError)}`);
258
+ const refreshed = await this.refreshStateViaControlPlane();
259
+ if (!refreshed) {
260
+ return null;
261
+ }
262
+ try {
263
+ this.pg = await attempt(refreshed);
264
+ return this.pg;
265
+ } catch (recoveredError) {
266
+ this.serviceContext.logger.warn(`db9-audit: PG still unavailable after recovery: ${extractErrorMessage(recoveredError) ?? String(recoveredError)}`);
267
+ return null;
268
+ }
269
+ }
270
+ }
271
+
272
+ private async ensureFs(): Promise<Db9FsClient | null> {
273
+ if (this.fsClient) {
274
+ return this.fsClient;
275
+ }
276
+ if (this.fsConnectPromise) {
277
+ return await this.fsConnectPromise;
278
+ }
279
+ this.fsConnectPromise = this.connectFs();
280
+ try {
281
+ return await this.fsConnectPromise;
282
+ } finally {
283
+ this.fsConnectPromise = null;
284
+ }
285
+ }
286
+
287
+ private async connectFs(): Promise<Db9FsClient | null> {
288
+ if (!this.state || !this.serviceContext) {
289
+ return null;
290
+ }
291
+ const attempt = async (state: Db9AuditState): Promise<Db9FsClient> => {
292
+ const client = new Db9FsClient({
293
+ wsUrl: state.fs.wsUrl,
294
+ username: state.fs.username,
295
+ password: state.fs.password,
296
+ logger: this.serviceContext?.logger,
297
+ });
298
+ try {
299
+ await client.connect();
300
+ return client;
301
+ } catch (error) {
302
+ await client.close().catch(() => undefined);
303
+ throw error;
304
+ }
305
+ };
306
+
307
+ try {
308
+ this.fsClient = await attempt(this.state);
309
+ return this.fsClient;
310
+ } catch (initialError) {
311
+ this.serviceContext.logger.warn(`db9-audit: FS connect failed, attempting recovery: ${extractErrorMessage(initialError) ?? String(initialError)}`);
312
+ const refreshed = await this.refreshStateViaControlPlane();
313
+ if (!refreshed) {
314
+ return null;
315
+ }
316
+ try {
317
+ this.fsClient = await attempt(refreshed);
318
+ return this.fsClient;
319
+ } catch (recoveredError) {
320
+ this.serviceContext.logger.warn(`db9-audit: FS still unavailable after recovery: ${extractErrorMessage(recoveredError) ?? String(recoveredError)}`);
321
+ return null;
322
+ }
323
+ }
324
+ }
325
+
326
+ private async backfillExistingTranscripts(): Promise<void> {
327
+ if (!this.serviceContext || !this.transcriptSync) {
328
+ return;
329
+ }
330
+ const files = await discoverTranscriptFiles({
331
+ stateDir: this.serviceContext.stateDir,
332
+ });
333
+ for (const filePath of files) {
334
+ this.transcriptSync.enqueue(filePath);
335
+ }
336
+ await this.flushAll();
337
+ }
338
+
339
+ private async flushAll(): Promise<void> {
340
+ if (this.stopped) {
341
+ return;
342
+ }
343
+ await Promise.allSettled([
344
+ this.transcriptSync?.flushPending() ?? Promise.resolve({ flushedSessions: 0, insertedMessages: 0 }),
345
+ this.eventLogSync?.flushPending() ?? Promise.resolve({ flushedFiles: 0, flushedLines: 0 }),
346
+ this.runSummaryQueue,
347
+ ]);
348
+ }
349
+ }
350
+
351
+ export function createDb9AuditService(params: {
352
+ api: OpenClawPluginApi;
353
+ config: Db9AuditPluginConfig;
354
+ }): Db9AuditServiceRuntime {
355
+ return new Db9AuditServiceRuntime(params);
356
+ }
@@ -0,0 +1,150 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { SessionLookupResult, SessionStoreEntry } from "./types.js";
4
+ import {
5
+ normalizeSessionPath,
6
+ resolveAgentIdFromSessionPath,
7
+ resolveSessionIdFromSessionPath,
8
+ sanitizeAgentId,
9
+ } from "./utils.js";
10
+
11
+ type SessionStoreCacheEntry = {
12
+ mtimeMs: number;
13
+ entries: Record<string, SessionStoreEntry>;
14
+ };
15
+
16
+ function normalizeStoredSessionFile(sessionsDir: string, value: string): string {
17
+ const trimmed = value.trim();
18
+ if (!trimmed) {
19
+ return "";
20
+ }
21
+ return path.isAbsolute(trimmed)
22
+ ? normalizeSessionPath(trimmed)
23
+ : normalizeSessionPath(path.join(sessionsDir, trimmed));
24
+ }
25
+
26
+ export class Db9AuditSessionLookup {
27
+ private readonly storeCache = new Map<string, SessionStoreCacheEntry>();
28
+
29
+ async lookup(sessionFile: string): Promise<SessionLookupResult> {
30
+ const normalizedSessionFile = normalizeSessionPath(sessionFile);
31
+ const sessionsDir = path.dirname(normalizedSessionFile);
32
+ const sessionStorePath = path.join(sessionsDir, "sessions.json");
33
+ const cached = await this.readSessionStore(sessionStorePath);
34
+
35
+ for (const [sessionKey, entry] of Object.entries(cached.entries)) {
36
+ if (!entry.sessionFile) {
37
+ continue;
38
+ }
39
+ const candidatePath = normalizeStoredSessionFile(sessionsDir, entry.sessionFile);
40
+ if (candidatePath !== normalizedSessionFile) {
41
+ continue;
42
+ }
43
+ return {
44
+ agentId:
45
+ resolveAgentIdFromSessionPath(normalizedSessionFile) ??
46
+ sanitizeAgentId(sessionKey.split(":")[1]),
47
+ ...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
48
+ sessionKey,
49
+ sessionStorePath,
50
+ };
51
+ }
52
+
53
+ const fallbackAgentId = resolveAgentIdFromSessionPath(normalizedSessionFile) ?? "unknown";
54
+ const fallbackSessionId = resolveSessionIdFromSessionPath(normalizedSessionFile);
55
+ return {
56
+ agentId: fallbackAgentId,
57
+ ...(fallbackSessionId ? { sessionId: fallbackSessionId } : {}),
58
+ ...(fs.existsSync(sessionStorePath) ? { sessionStorePath } : {}),
59
+ };
60
+ }
61
+
62
+ private async readSessionStore(storePath: string): Promise<SessionStoreCacheEntry> {
63
+ const stat = await fs.promises.stat(storePath).catch(() => null);
64
+ if (!stat) {
65
+ return { mtimeMs: 0, entries: {} };
66
+ }
67
+ const cached = this.storeCache.get(storePath);
68
+ if (cached && cached.mtimeMs === stat.mtimeMs) {
69
+ return cached;
70
+ }
71
+ const raw = await fs.promises.readFile(storePath, "utf8");
72
+ const parsed = JSON.parse(raw) as unknown;
73
+ const entries: Record<string, SessionStoreEntry> = {};
74
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
75
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
76
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
77
+ continue;
78
+ }
79
+ const record = value as Record<string, unknown>;
80
+ entries[key] = {
81
+ ...(typeof record.sessionId === "string" && record.sessionId.trim()
82
+ ? { sessionId: record.sessionId.trim() }
83
+ : {}),
84
+ ...(typeof record.sessionFile === "string" && record.sessionFile.trim()
85
+ ? { sessionFile: record.sessionFile.trim() }
86
+ : {}),
87
+ ...(typeof record.updatedAt === "number" && Number.isFinite(record.updatedAt)
88
+ ? { updatedAt: record.updatedAt }
89
+ : {}),
90
+ };
91
+ }
92
+ }
93
+ const next = { mtimeMs: stat.mtimeMs, entries };
94
+ this.storeCache.set(storePath, next);
95
+ return next;
96
+ }
97
+ }
98
+
99
+ async function walkTranscriptDirs(root: string, output: string[]): Promise<void> {
100
+ const entries = await fs.promises.readdir(root, { withFileTypes: true }).catch(() => []);
101
+ for (const entry of entries) {
102
+ const fullPath = path.join(root, entry.name);
103
+ if (entry.isDirectory()) {
104
+ await walkTranscriptDirs(fullPath, output);
105
+ continue;
106
+ }
107
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
108
+ output.push(normalizeSessionPath(fullPath));
109
+ }
110
+ }
111
+ }
112
+
113
+ export async function discoverTranscriptFiles(params: {
114
+ stateDir: string;
115
+ agentId?: string | undefined;
116
+ since?: Date | undefined;
117
+ }): Promise<string[]> {
118
+ const roots: string[] = [];
119
+ const agentsRoot = path.join(params.stateDir, "agents");
120
+ if (params.agentId) {
121
+ roots.push(path.join(agentsRoot, sanitizeAgentId(params.agentId), "sessions"));
122
+ } else {
123
+ const agentDirs = await fs.promises.readdir(agentsRoot, { withFileTypes: true }).catch(() => []);
124
+ for (const entry of agentDirs) {
125
+ if (!entry.isDirectory()) {
126
+ continue;
127
+ }
128
+ roots.push(path.join(agentsRoot, entry.name, "sessions"));
129
+ }
130
+ }
131
+
132
+ const files: string[] = [];
133
+ for (const root of roots) {
134
+ await walkTranscriptDirs(root, files);
135
+ }
136
+
137
+ if (!params.since) {
138
+ return files.toSorted();
139
+ }
140
+
141
+ const sinceMs = params.since.getTime();
142
+ const filtered: string[] = [];
143
+ for (const filePath of files) {
144
+ const stat = await fs.promises.stat(filePath).catch(() => null);
145
+ if (stat && stat.mtimeMs >= sinceMs) {
146
+ filtered.push(filePath);
147
+ }
148
+ }
149
+ return filtered.toSorted();
150
+ }
@@ -0,0 +1,165 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { Db9AuditState } from "./types.js";
4
+ import { isNonEmptyString, isRecord, maskSecret } from "./utils.js";
5
+
6
+ const STATE_VERSION = 1;
7
+ const PLUGIN_DIR = "db9-audit";
8
+ const STATE_FILE = "state.json";
9
+
10
+ function assertString(record: Record<string, unknown>, key: string): string {
11
+ const value = record[key];
12
+ if (!isNonEmptyString(value)) {
13
+ throw new Error(`Invalid state field: ${key}`);
14
+ }
15
+ return value.trim();
16
+ }
17
+
18
+ function assertOptionalString(record: Record<string, unknown>, key: string): string | undefined {
19
+ const value = record[key];
20
+ if (value === undefined || value === null || value === "") {
21
+ return undefined;
22
+ }
23
+ if (!isNonEmptyString(value)) {
24
+ throw new Error(`Invalid state field: ${key}`);
25
+ }
26
+ return value.trim();
27
+ }
28
+
29
+ function assertNumber(record: Record<string, unknown>, key: string): number {
30
+ const value = record[key];
31
+ if (typeof value !== "number" || !Number.isFinite(value)) {
32
+ throw new Error(`Invalid state field: ${key}`);
33
+ }
34
+ return value;
35
+ }
36
+
37
+ export function parseDb9AuditState(value: unknown): Db9AuditState {
38
+ if (!isRecord(value)) {
39
+ throw new Error("State payload must be an object");
40
+ }
41
+ if (value.version !== STATE_VERSION) {
42
+ throw new Error(`Unsupported state version: ${String(value.version)}`);
43
+ }
44
+
45
+ const customer = value.customer;
46
+ const database = value.database;
47
+ const fsState = value.fs;
48
+ const plugin = value.plugin;
49
+
50
+ if (!isRecord(customer) || !isRecord(database) || !isRecord(fsState) || !isRecord(plugin)) {
51
+ throw new Error("State payload is missing required sections");
52
+ }
53
+
54
+ return {
55
+ version: STATE_VERSION,
56
+ apiBase: assertString(value, "apiBase"),
57
+ customer: {
58
+ id: assertString(customer, "id"),
59
+ token: assertString(customer, "token"),
60
+ ...(assertOptionalString(customer, "tokenExpiresAt")
61
+ ? { tokenExpiresAt: assertOptionalString(customer, "tokenExpiresAt") }
62
+ : {}),
63
+ isAnonymous: customer.isAnonymous === true,
64
+ ...(assertOptionalString(customer, "anonymousId")
65
+ ? { anonymousId: assertOptionalString(customer, "anonymousId") }
66
+ : {}),
67
+ ...(assertOptionalString(customer, "anonymousSecret")
68
+ ? { anonymousSecret: assertOptionalString(customer, "anonymousSecret") }
69
+ : {}),
70
+ },
71
+ database: {
72
+ id: assertString(database, "id"),
73
+ name: assertString(database, "name"),
74
+ state: assertString(database, "state"),
75
+ adminUser: assertString(database, "adminUser"),
76
+ adminPassword: assertString(database, "adminPassword"),
77
+ connectionString: assertString(database, "connectionString"),
78
+ host: assertString(database, "host"),
79
+ port: assertNumber(database, "port"),
80
+ database: assertString(database, "database"),
81
+ ...(assertOptionalString(database, "region")
82
+ ? { region: assertOptionalString(database, "region") }
83
+ : {}),
84
+ },
85
+ fs: {
86
+ wsUrl: assertString(fsState, "wsUrl"),
87
+ username: assertString(fsState, "username"),
88
+ password: assertString(fsState, "password"),
89
+ },
90
+ plugin: {
91
+ schema: assertString(plugin, "schema"),
92
+ logRoot: assertString(plugin, "logRoot"),
93
+ initializedAt: assertString(plugin, "initializedAt"),
94
+ lastBootstrapAt: assertString(plugin, "lastBootstrapAt"),
95
+ },
96
+ };
97
+ }
98
+
99
+ export function summarizeDb9AuditState(state: Db9AuditState): Record<string, unknown> {
100
+ return {
101
+ version: state.version,
102
+ apiBase: state.apiBase,
103
+ customer: {
104
+ id: state.customer.id,
105
+ token: maskSecret(state.customer.token),
106
+ tokenExpiresAt: state.customer.tokenExpiresAt,
107
+ isAnonymous: state.customer.isAnonymous,
108
+ anonymousId: state.customer.anonymousId,
109
+ anonymousSecret: state.customer.anonymousSecret ? maskSecret(state.customer.anonymousSecret) : undefined,
110
+ },
111
+ database: {
112
+ id: state.database.id,
113
+ name: state.database.name,
114
+ state: state.database.state,
115
+ adminUser: state.database.adminUser,
116
+ adminPassword: maskSecret(state.database.adminPassword),
117
+ connectionString: `${state.database.host}:${state.database.port}/${state.database.database}`,
118
+ },
119
+ fs: {
120
+ wsUrl: state.fs.wsUrl,
121
+ username: state.fs.username,
122
+ password: maskSecret(state.fs.password),
123
+ },
124
+ plugin: state.plugin,
125
+ };
126
+ }
127
+
128
+ export class Db9AuditStateStore {
129
+ readonly stateFilePath: string;
130
+
131
+ constructor(stateDir: string) {
132
+ this.stateFilePath = path.join(stateDir, "plugins", PLUGIN_DIR, STATE_FILE);
133
+ }
134
+
135
+ async read(): Promise<Db9AuditState | null> {
136
+ let raw: string;
137
+ try {
138
+ raw = await fs.promises.readFile(this.stateFilePath, "utf8");
139
+ } catch (error) {
140
+ const nodeError = error as NodeJS.ErrnoException;
141
+ if (nodeError.code === "ENOENT") {
142
+ return null;
143
+ }
144
+ throw error;
145
+ }
146
+ return parseDb9AuditState(JSON.parse(raw));
147
+ }
148
+
149
+ async write(state: Db9AuditState): Promise<void> {
150
+ const directory = path.dirname(this.stateFilePath);
151
+ await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
152
+ await fs.promises.chmod(directory, 0o700).catch(() => undefined);
153
+ const tempFile = `${this.stateFilePath}.tmp`;
154
+ const payload = `${JSON.stringify(state, null, 2)}\n`;
155
+ const handle = await fs.promises.open(tempFile, "w", 0o600);
156
+ try {
157
+ await handle.writeFile(payload, "utf8");
158
+ await handle.sync();
159
+ } finally {
160
+ await handle.close();
161
+ }
162
+ await fs.promises.rename(tempFile, this.stateFilePath);
163
+ await fs.promises.chmod(this.stateFilePath, 0o600).catch(() => undefined);
164
+ }
165
+ }