@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.
- package/CHANGELOG.md +225 -0
- package/drizzle/0000_loose_yellow_claw.sql +28 -0
- package/drizzle/meta/0000_snapshot.json +187 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/package.json +42 -0
- package/src/db.ts +20 -0
- package/src/health-check-plugin-integration.test.ts +93 -0
- package/src/index.ts +419 -0
- package/src/integration/event-bus.integration.test.ts +313 -0
- package/src/logger.ts +65 -0
- package/src/openapi-router.ts +177 -0
- package/src/plugin-lifecycle.test.ts +276 -0
- package/src/plugin-manager/api-router.ts +163 -0
- package/src/plugin-manager/core-services.ts +312 -0
- package/src/plugin-manager/dependency-sorter.ts +103 -0
- package/src/plugin-manager/deregistration-guard.ts +41 -0
- package/src/plugin-manager/extension-points.ts +85 -0
- package/src/plugin-manager/index.ts +13 -0
- package/src/plugin-manager/plugin-admin-router.ts +89 -0
- package/src/plugin-manager/plugin-loader.ts +464 -0
- package/src/plugin-manager/types.ts +14 -0
- package/src/plugin-manager.test.ts +464 -0
- package/src/plugin-manager.ts +431 -0
- package/src/rpc-rest-compat.test.ts +80 -0
- package/src/schema.ts +46 -0
- package/src/services/config-service.test.ts +66 -0
- package/src/services/config-service.ts +322 -0
- package/src/services/event-bus.test.ts +469 -0
- package/src/services/event-bus.ts +317 -0
- package/src/services/health-check-registry.test.ts +101 -0
- package/src/services/health-check-registry.ts +27 -0
- package/src/services/jwt.ts +45 -0
- package/src/services/keystore.test.ts +198 -0
- package/src/services/keystore.ts +136 -0
- package/src/services/plugin-installer.test.ts +90 -0
- package/src/services/plugin-installer.ts +70 -0
- package/src/services/queue-manager.ts +382 -0
- package/src/services/queue-plugin-registry.ts +17 -0
- package/src/services/queue-proxy.ts +182 -0
- package/src/services/service-registry.ts +35 -0
- package/src/test-preload.ts +114 -0
- package/src/utils/plugin-discovery.test.ts +383 -0
- package/src/utils/plugin-discovery.ts +157 -0
- package/src/utils/strip-public-schema.ts +40 -0
- 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
|
+
}
|