@checkstack/backend 0.0.2

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/drizzle/0000_loose_yellow_claw.sql +28 -0
  3. package/drizzle/meta/0000_snapshot.json +187 -0
  4. package/drizzle/meta/_journal.json +13 -0
  5. package/drizzle.config.ts +10 -0
  6. package/package.json +42 -0
  7. package/src/db.ts +20 -0
  8. package/src/health-check-plugin-integration.test.ts +93 -0
  9. package/src/index.ts +419 -0
  10. package/src/integration/event-bus.integration.test.ts +313 -0
  11. package/src/logger.ts +65 -0
  12. package/src/openapi-router.ts +177 -0
  13. package/src/plugin-lifecycle.test.ts +276 -0
  14. package/src/plugin-manager/api-router.ts +163 -0
  15. package/src/plugin-manager/core-services.ts +312 -0
  16. package/src/plugin-manager/dependency-sorter.ts +103 -0
  17. package/src/plugin-manager/deregistration-guard.ts +41 -0
  18. package/src/plugin-manager/extension-points.ts +85 -0
  19. package/src/plugin-manager/index.ts +13 -0
  20. package/src/plugin-manager/plugin-admin-router.ts +89 -0
  21. package/src/plugin-manager/plugin-loader.ts +464 -0
  22. package/src/plugin-manager/types.ts +14 -0
  23. package/src/plugin-manager.test.ts +464 -0
  24. package/src/plugin-manager.ts +431 -0
  25. package/src/rpc-rest-compat.test.ts +80 -0
  26. package/src/schema.ts +46 -0
  27. package/src/services/config-service.test.ts +66 -0
  28. package/src/services/config-service.ts +322 -0
  29. package/src/services/event-bus.test.ts +469 -0
  30. package/src/services/event-bus.ts +317 -0
  31. package/src/services/health-check-registry.test.ts +101 -0
  32. package/src/services/health-check-registry.ts +27 -0
  33. package/src/services/jwt.ts +45 -0
  34. package/src/services/keystore.test.ts +198 -0
  35. package/src/services/keystore.ts +136 -0
  36. package/src/services/plugin-installer.test.ts +90 -0
  37. package/src/services/plugin-installer.ts +70 -0
  38. package/src/services/queue-manager.ts +382 -0
  39. package/src/services/queue-plugin-registry.ts +17 -0
  40. package/src/services/queue-proxy.ts +182 -0
  41. package/src/services/service-registry.ts +35 -0
  42. package/src/test-preload.ts +114 -0
  43. package/src/utils/plugin-discovery.test.ts +383 -0
  44. package/src/utils/plugin-discovery.ts +157 -0
  45. package/src/utils/strip-public-schema.ts +40 -0
  46. package/tsconfig.json +6 -0
@@ -0,0 +1,322 @@
1
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
+ import { eq, and } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import {
5
+ ConfigService,
6
+ VersionedPluginRecord,
7
+ Migration,
8
+ Versioned,
9
+ encrypt,
10
+ decrypt,
11
+ isEncrypted,
12
+ isSecretSchema,
13
+ } from "@checkstack/backend-api";
14
+ import { pluginConfigs } from "../schema";
15
+
16
+ /**
17
+ * Implementation of ConfigService.
18
+ * Provides plugin-scoped configuration management with automatic secret handling.
19
+ */
20
+ export class ConfigServiceImpl implements ConfigService {
21
+ constructor(
22
+ private readonly pluginId: string,
23
+ private readonly db: NodePgDatabase<Record<string, unknown>>
24
+ ) {}
25
+
26
+ /**
27
+ * Recursively encrypt secret fields in a config object.
28
+ */
29
+ private encryptSecrets(
30
+ schema: z.ZodTypeAny,
31
+ data: Record<string, unknown>
32
+ ): Record<string, unknown> {
33
+ if (!("shape" in schema)) return data;
34
+
35
+ const objectSchema = schema as z.ZodObject<z.ZodRawShape>;
36
+ const result: Record<string, unknown> = { ...data };
37
+
38
+ for (const [key, fieldSchema] of Object.entries(objectSchema.shape)) {
39
+ const value = data[key];
40
+
41
+ if (isSecretSchema(fieldSchema as z.ZodTypeAny)) {
42
+ // Encrypt secret field (only if not already encrypted)
43
+ if (
44
+ typeof value === "string" &&
45
+ value.trim() !== "" &&
46
+ !isEncrypted(value)
47
+ ) {
48
+ result[key] = encrypt(value);
49
+ }
50
+ } else if (
51
+ typeof value === "object" &&
52
+ value !== null &&
53
+ fieldSchema instanceof z.ZodObject
54
+ ) {
55
+ // Recursively handle nested objects
56
+ result[key] = this.encryptSecrets(
57
+ fieldSchema,
58
+ value as Record<string, unknown>
59
+ );
60
+ } else if (
61
+ typeof value === "object" &&
62
+ value !== null &&
63
+ fieldSchema instanceof z.ZodArray &&
64
+ Array.isArray(value) &&
65
+ fieldSchema.element instanceof z.ZodObject
66
+ ) {
67
+ // Handle arrays of objects
68
+ result[key] = value.map((item) =>
69
+ this.encryptSecrets(fieldSchema.element as z.ZodTypeAny, item)
70
+ );
71
+ }
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ /**
78
+ * Recursively decrypt secret fields in a config object.
79
+ */
80
+ private decryptSecrets(
81
+ schema: z.ZodTypeAny,
82
+ data: Record<string, unknown>
83
+ ): Record<string, unknown> {
84
+ if (!("shape" in schema)) return data;
85
+
86
+ const objectSchema = schema as z.ZodObject<z.ZodRawShape>;
87
+ const result: Record<string, unknown> = { ...data };
88
+
89
+ for (const [key, fieldSchema] of Object.entries(objectSchema.shape)) {
90
+ const value = data[key];
91
+
92
+ if (isSecretSchema(fieldSchema as z.ZodTypeAny)) {
93
+ // Decrypt secret field
94
+ if (typeof value === "string" && isEncrypted(value)) {
95
+ try {
96
+ result[key] = decrypt(value);
97
+ } catch (error) {
98
+ console.error(`Failed to decrypt secret for key ${key}:`, error);
99
+ result[key] = value; // Preserve encrypted value if decryption fails
100
+ }
101
+ }
102
+ } else if (
103
+ typeof value === "object" &&
104
+ value !== null &&
105
+ fieldSchema instanceof z.ZodObject
106
+ ) {
107
+ // Recursively handle nested objects
108
+ result[key] = this.decryptSecrets(
109
+ fieldSchema,
110
+ value as Record<string, unknown>
111
+ );
112
+ } else if (
113
+ typeof value === "object" &&
114
+ value !== null &&
115
+ fieldSchema instanceof z.ZodArray &&
116
+ Array.isArray(value) &&
117
+ fieldSchema.element instanceof z.ZodObject
118
+ ) {
119
+ // Handle arrays of objects
120
+ result[key] = value.map((item) =>
121
+ this.decryptSecrets(fieldSchema.element as z.ZodTypeAny, item)
122
+ );
123
+ }
124
+ }
125
+
126
+ return result;
127
+ }
128
+
129
+ /**
130
+ * Remove secret fields from a config object.
131
+ */
132
+ private redactSecrets(
133
+ schema: z.ZodTypeAny,
134
+ data: Record<string, unknown>
135
+ ): Record<string, unknown> {
136
+ if (!("shape" in schema)) return data;
137
+
138
+ const objectSchema = schema as z.ZodObject<z.ZodRawShape>;
139
+ const result: Record<string, unknown> = { ...data };
140
+
141
+ for (const [key, fieldSchema] of Object.entries(objectSchema.shape)) {
142
+ if (isSecretSchema(fieldSchema as z.ZodTypeAny)) {
143
+ // Remove secret fields entirely
144
+ delete result[key];
145
+ }
146
+ }
147
+
148
+ return result;
149
+ }
150
+
151
+ async set<T>(
152
+ configId: string,
153
+ schema: z.ZodType<T>,
154
+ version: number,
155
+ data: T,
156
+ _migrations?: Migration<unknown, unknown>[]
157
+ ): Promise<void> {
158
+ // Get existing config if any
159
+ const existing = await this.db
160
+ .select()
161
+ .from(pluginConfigs)
162
+ .where(
163
+ and(
164
+ eq(pluginConfigs.pluginId, this.pluginId),
165
+ eq(pluginConfigs.configId, configId)
166
+ )
167
+ )
168
+ .limit(1);
169
+
170
+ const existingRecord = existing[0]?.data as
171
+ | VersionedPluginRecord<Record<string, unknown>>
172
+ | undefined;
173
+
174
+ // Merge with existing secrets (preserve unchanged secrets)
175
+ let processedData = data as Record<string, unknown>;
176
+ if (existingRecord && "shape" in schema) {
177
+ const objectSchema = schema as z.ZodObject<z.ZodRawShape>;
178
+ const result: Record<string, unknown> = { ...processedData };
179
+
180
+ for (const [key, fieldSchema] of Object.entries(objectSchema.shape)) {
181
+ if (isSecretSchema(fieldSchema as z.ZodTypeAny)) {
182
+ const newValue = processedData[key];
183
+
184
+ if (typeof newValue === "string" && newValue.trim() !== "") {
185
+ // New secret value provided - will be encrypted below
186
+ } else if (existingRecord.data[key]) {
187
+ // Preserve existing encrypted value
188
+ result[key] = existingRecord.data[key];
189
+ }
190
+ }
191
+ }
192
+
193
+ processedData = result;
194
+ }
195
+
196
+ // Encrypt secrets
197
+ const encryptedData = this.encryptSecrets(schema, processedData);
198
+
199
+ // Create versioned plugin record
200
+ const versionedRecord: VersionedPluginRecord<Record<string, unknown>> = {
201
+ version,
202
+ pluginId: this.pluginId,
203
+ data: encryptedData,
204
+ };
205
+
206
+ // Upsert to database
207
+ await this.db
208
+ .insert(pluginConfigs)
209
+ .values({
210
+ pluginId: this.pluginId,
211
+ configId,
212
+ data: versionedRecord as unknown as Record<string, unknown>,
213
+ updatedAt: new Date(),
214
+ })
215
+ .onConflictDoUpdate({
216
+ target: [pluginConfigs.pluginId, pluginConfigs.configId],
217
+ set: {
218
+ data: versionedRecord as unknown as Record<string, unknown>,
219
+ updatedAt: new Date(),
220
+ },
221
+ });
222
+ }
223
+
224
+ async get<T>(
225
+ configId: string,
226
+ schema: z.ZodType<T>,
227
+ version: number,
228
+ migrations?: Migration<unknown, unknown>[]
229
+ ): Promise<T | undefined> {
230
+ const result = await this.db
231
+ .select()
232
+ .from(pluginConfigs)
233
+ .where(
234
+ and(
235
+ eq(pluginConfigs.pluginId, this.pluginId),
236
+ eq(pluginConfigs.configId, configId)
237
+ )
238
+ )
239
+ .limit(1);
240
+
241
+ if (result.length === 0) return undefined;
242
+
243
+ const storedRecord = result[0].data as VersionedPluginRecord<
244
+ Record<string, unknown>
245
+ >;
246
+
247
+ // Create a Versioned instance for parsing/migration
248
+ const versioned = new Versioned<Record<string, unknown>>({
249
+ version,
250
+ schema: z.record(z.string(), z.unknown()),
251
+ migrations,
252
+ });
253
+
254
+ // Parse and migrate the stored record
255
+ const migratedData = await versioned.parse(storedRecord);
256
+
257
+ // Decrypt secrets
258
+ const decryptedData = this.decryptSecrets(schema, migratedData);
259
+
260
+ // Validate with schema
261
+ return schema.parse(decryptedData);
262
+ }
263
+
264
+ async getRedacted<T>(
265
+ configId: string,
266
+ schema: z.ZodType<T>,
267
+ version: number,
268
+ migrations?: Migration<unknown, unknown>[]
269
+ ): Promise<Partial<T> | undefined> {
270
+ const result = await this.db
271
+ .select()
272
+ .from(pluginConfigs)
273
+ .where(
274
+ and(
275
+ eq(pluginConfigs.pluginId, this.pluginId),
276
+ eq(pluginConfigs.configId, configId)
277
+ )
278
+ )
279
+ .limit(1);
280
+
281
+ if (result.length === 0) return undefined;
282
+
283
+ const storedRecord = result[0].data as VersionedPluginRecord<
284
+ Record<string, unknown>
285
+ >;
286
+
287
+ // Create a Versioned instance for parsing/migration
288
+ const versioned = new Versioned<Record<string, unknown>>({
289
+ version,
290
+ schema: z.record(z.string(), z.unknown()),
291
+ migrations,
292
+ });
293
+
294
+ // Parse and migrate the stored record
295
+ const migratedData = await versioned.parse(storedRecord);
296
+
297
+ // Redact secrets (don't decrypt)
298
+ const redactedData = this.redactSecrets(schema, migratedData);
299
+
300
+ return redactedData as Partial<T>;
301
+ }
302
+
303
+ async delete(configId: string): Promise<void> {
304
+ await this.db
305
+ .delete(pluginConfigs)
306
+ .where(
307
+ and(
308
+ eq(pluginConfigs.pluginId, this.pluginId),
309
+ eq(pluginConfigs.configId, configId)
310
+ )
311
+ );
312
+ }
313
+
314
+ async list(): Promise<string[]> {
315
+ const results = await this.db
316
+ .select({ configId: pluginConfigs.configId })
317
+ .from(pluginConfigs)
318
+ .where(eq(pluginConfigs.pluginId, this.pluginId));
319
+
320
+ return results.map((r) => r.configId);
321
+ }
322
+ }