@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,431 @@
1
+ import type { Hono } from "hono";
2
+ import { adminPool, db } from "./db";
3
+ import { ServiceRegistry } from "./services/service-registry";
4
+ import {
5
+ BackendPlugin,
6
+ ServiceRef,
7
+ ExtensionPoint,
8
+ coreServices,
9
+ coreHooks,
10
+ HookUnsubscribe,
11
+ } from "@checkstack/backend-api";
12
+ import type { AnyContractRouter } from "@orpc/contract";
13
+ import type { Permission, PluginMetadata } from "@checkstack/common";
14
+
15
+ // Extracted modules
16
+ import { registerCoreServices } from "./plugin-manager/core-services";
17
+ import { createExtensionPointManager } from "./plugin-manager/extension-points";
18
+ import { loadPlugins as loadPluginsImpl } from "./plugin-manager/plugin-loader";
19
+ import { rootLogger } from "./logger";
20
+
21
+ export interface DeregisterOptions {
22
+ deleteSchema: boolean;
23
+ }
24
+
25
+ export class PluginManager {
26
+ private registry = new ServiceRegistry();
27
+ private pluginRpcRouters = new Map<string, unknown>();
28
+ private pluginHttpHandlers = new Map<
29
+ string,
30
+ (req: Request) => Promise<Response>
31
+ >();
32
+ private extensionPointManager = createExtensionPointManager();
33
+
34
+ // Permission registry - stores all registered permissions with pluginId for hook emission
35
+ private registeredPermissions: (Permission & { pluginId: string })[] = [];
36
+
37
+ // Plugin metadata registry - stores PluginMetadata for request-time context injection
38
+ private pluginMetadataRegistry = new Map<string, PluginMetadata>();
39
+
40
+ // Cleanup handlers registered by plugins (LIFO execution)
41
+ private cleanupHandlers = new Map<string, Array<() => Promise<void>>>();
42
+
43
+ // Contract registry - stores plugin contracts for OpenAPI generation
44
+ private pluginContractRegistry = new Map<string, AnyContractRouter>();
45
+
46
+ // Hook subscriptions per plugin (for bulk unsubscribe)
47
+ private hookSubscriptions = new Map<string, HookUnsubscribe[]>();
48
+
49
+ constructor() {
50
+ registerCoreServices({
51
+ registry: this.registry,
52
+ adminPool,
53
+ pluginRpcRouters: this.pluginRpcRouters,
54
+ pluginHttpHandlers: this.pluginHttpHandlers,
55
+ pluginContractRegistry: this.pluginContractRegistry,
56
+ });
57
+ }
58
+
59
+ registerExtensionPoint<T>(ref: ExtensionPoint<T>, impl: T) {
60
+ this.extensionPointManager.registerExtensionPoint(ref, impl);
61
+ }
62
+
63
+ getExtensionPoint<T>(ref: ExtensionPoint<T>): T {
64
+ return this.extensionPointManager.getExtensionPoint(ref);
65
+ }
66
+
67
+ /**
68
+ * Register a core router (not from a plugin, but from core backend).
69
+ * Used for admin endpoints like plugin installation/deregistration.
70
+ */
71
+ registerCoreRouter(routerId: string, router: unknown): void {
72
+ this.pluginRpcRouters.set(routerId, router);
73
+ }
74
+
75
+ getAllPermissions(): Permission[] {
76
+ return this.registeredPermissions.map(
77
+ ({ id, description, isAuthenticatedDefault, isPublicDefault }) => ({
78
+ id,
79
+ description,
80
+ isAuthenticatedDefault,
81
+ isPublicDefault,
82
+ })
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Get all registered contracts for OpenAPI generation.
88
+ * Returns a map of pluginId -> contract.
89
+ */
90
+ getAllContracts(): Map<string, AnyContractRouter> {
91
+ return new Map(this.pluginContractRegistry);
92
+ }
93
+
94
+ async loadPlugins(
95
+ rootRouter: Hono,
96
+ manualPlugins: BackendPlugin[] = [],
97
+ options: { skipDiscovery?: boolean } = {}
98
+ ) {
99
+ await loadPluginsImpl({
100
+ rootRouter,
101
+ manualPlugins,
102
+ skipDiscovery: options.skipDiscovery,
103
+ deps: {
104
+ registry: this.registry,
105
+ pluginRpcRouters: this.pluginRpcRouters,
106
+ pluginHttpHandlers: this.pluginHttpHandlers,
107
+ extensionPointManager: this.extensionPointManager,
108
+ registeredPermissions: this.registeredPermissions,
109
+ getAllPermissions: () => this.getAllPermissions(),
110
+ db,
111
+ pluginMetadataRegistry: this.pluginMetadataRegistry,
112
+ cleanupHandlers: this.cleanupHandlers,
113
+ pluginContractRegistry: this.pluginContractRegistry,
114
+ },
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Deregister a plugin at runtime.
120
+ * Only works for plugins with isUninstallable: true.
121
+ */
122
+ async deregisterPlugin(
123
+ pluginId: string,
124
+ options: DeregisterOptions
125
+ ): Promise<void> {
126
+ rootLogger.info(`🔄 Deregistering plugin: ${pluginId}...`);
127
+
128
+ // 1. Emit pluginDeregistering hook locally (instance-local, not distributed)
129
+ // This lets other plugins on THIS instance cleanup dependencies
130
+ const eventBus = await this.registry.get(coreServices.eventBus, {
131
+ pluginId: "core",
132
+ });
133
+ await eventBus.emitLocal(coreHooks.pluginDeregistering, {
134
+ pluginId,
135
+ reason: "uninstall" as const,
136
+ });
137
+
138
+ // 2. Run cleanup handlers (LIFO order)
139
+ const handlers = this.cleanupHandlers.get(pluginId) || [];
140
+ for (const handler of handlers.toReversed()) {
141
+ try {
142
+ await handler();
143
+ } catch (error) {
144
+ rootLogger.error(`Cleanup handler failed for ${pluginId}:`, error);
145
+ }
146
+ }
147
+ this.cleanupHandlers.delete(pluginId);
148
+
149
+ // 3. Unsubscribe from all EventBus hooks
150
+ const subscriptions = this.hookSubscriptions.get(pluginId) || [];
151
+ for (const unsubscribe of subscriptions) {
152
+ try {
153
+ await unsubscribe();
154
+ } catch (error) {
155
+ rootLogger.error(`Failed to unsubscribe hook for ${pluginId}:`, error);
156
+ }
157
+ }
158
+ this.hookSubscriptions.delete(pluginId);
159
+
160
+ // 4. Remove from router maps and contract registry
161
+ this.pluginRpcRouters.delete(pluginId);
162
+ this.pluginHttpHandlers.delete(pluginId);
163
+ this.pluginContractRegistry.delete(pluginId);
164
+ rootLogger.debug(` -> Removed routers and contracts for ${pluginId}`);
165
+
166
+ // 5. Remove permissions from registry
167
+ const beforeCount = this.registeredPermissions.length;
168
+ this.registeredPermissions = this.registeredPermissions.filter(
169
+ (p) => p.pluginId !== pluginId
170
+ );
171
+ rootLogger.debug(
172
+ ` -> Removed ${
173
+ beforeCount - this.registeredPermissions.length
174
+ } permissions`
175
+ );
176
+
177
+ // 6. Drop schema if requested
178
+ if (options.deleteSchema) {
179
+ try {
180
+ const schemaName = `plugin_${pluginId}`;
181
+ await db.execute(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
182
+ rootLogger.info(` -> Dropped schema: ${schemaName}`);
183
+ } catch (error) {
184
+ rootLogger.error(`Failed to drop schema for ${pluginId}:`, error);
185
+ }
186
+ }
187
+
188
+ // 7. Emit pluginDeregistered hook (for permission cleanup in auth-backend)
189
+ await eventBus.emit(coreHooks.pluginDeregistered, { pluginId });
190
+
191
+ rootLogger.info(`✅ Plugin deregistered: ${pluginId}`);
192
+ }
193
+
194
+ /**
195
+ * Track a hook subscription for a plugin (for bulk unsubscribe during deregistration)
196
+ */
197
+ trackHookSubscription(pluginId: string, unsubscribe: HookUnsubscribe): void {
198
+ const existing = this.hookSubscriptions.get(pluginId) || [];
199
+ existing.push(unsubscribe);
200
+ this.hookSubscriptions.set(pluginId, existing);
201
+ }
202
+
203
+ /**
204
+ * Request deregistration of a plugin across all instances.
205
+ * This emits a broadcast hook that all instances (including this one) will receive.
206
+ * Each instance then performs local cleanup via deregisterPlugin().
207
+ */
208
+ async requestDeregistration(
209
+ pluginId: string,
210
+ options: DeregisterOptions
211
+ ): Promise<void> {
212
+ rootLogger.info(`📢 Broadcasting deregistration request for: ${pluginId}`);
213
+
214
+ // Emit broadcast hook - all instances receive and perform local cleanup
215
+ const eventBus = await this.registry.get(coreServices.eventBus, {
216
+ pluginId: "core",
217
+ });
218
+ await eventBus.emit(coreHooks.pluginDeregistrationRequested, {
219
+ pluginId,
220
+ deleteSchema: options.deleteSchema,
221
+ });
222
+
223
+ rootLogger.info(`✅ Deregistration request broadcast for: ${pluginId}`);
224
+ }
225
+
226
+ /**
227
+ * Request installation/loading of a plugin across all instances.
228
+ * This emits a broadcast hook that all instances (including this one) will receive.
229
+ * Each instance then loads the plugin into memory.
230
+ */
231
+ async requestInstallation(
232
+ pluginId: string,
233
+ pluginPath: string
234
+ ): Promise<void> {
235
+ rootLogger.info(`📢 Broadcasting installation request for: ${pluginId}`);
236
+
237
+ // Emit broadcast hook - all instances receive and load the plugin
238
+ const eventBus = await this.registry.get(coreServices.eventBus, {
239
+ pluginId: "core",
240
+ });
241
+ await eventBus.emit(coreHooks.pluginInstallationRequested, {
242
+ pluginId,
243
+ pluginPath,
244
+ });
245
+
246
+ rootLogger.info(`✅ Installation request broadcast for: ${pluginId}`);
247
+ }
248
+
249
+ /**
250
+ * Setup lifecycle listeners for multi-instance coordination.
251
+ * Must be called after EventBus is available (after loadPlugins).
252
+ */
253
+ async setupLifecycleListeners(): Promise<void> {
254
+ const eventBus = await this.registry.get(coreServices.eventBus, {
255
+ pluginId: "core",
256
+ });
257
+
258
+ // Listen for deregistration broadcasts (from any instance)
259
+ await eventBus.subscribe(
260
+ "core",
261
+ coreHooks.pluginDeregistrationRequested,
262
+ async ({ pluginId, deleteSchema }) => {
263
+ rootLogger.info(`📥 Received deregistration request for: ${pluginId}`);
264
+ await this.deregisterPlugin(pluginId, { deleteSchema });
265
+ }
266
+ );
267
+
268
+ // Listen for installation broadcasts (from any instance)
269
+ await eventBus.subscribe(
270
+ "core",
271
+ coreHooks.pluginInstallationRequested,
272
+ async ({ pluginId, pluginPath }) => {
273
+ rootLogger.info(
274
+ `📥 Received installation request for: ${pluginId} at ${pluginPath}`
275
+ );
276
+ await this.loadSinglePlugin(pluginId, pluginPath);
277
+ }
278
+ );
279
+
280
+ rootLogger.debug("🔗 Lifecycle listeners registered");
281
+ }
282
+
283
+ async getService<T>(ref: ServiceRef<T>): Promise<T | undefined> {
284
+ try {
285
+ return await this.registry.get(ref, { pluginId: "core" });
286
+ } catch {
287
+ return undefined;
288
+ }
289
+ }
290
+
291
+ registerService<T>(ref: ServiceRef<T>, impl: T) {
292
+ this.registry.register(ref, impl);
293
+ }
294
+
295
+ /**
296
+ * Load a single plugin at runtime (for dynamic installation).
297
+ * If the plugin isn't available locally, it will be installed via npm first.
298
+ * This imports the plugin module, registers it, and initializes it.
299
+ *
300
+ * @param pluginId - The plugin ID (package name)
301
+ * @param pluginPath - The expected path (may not exist yet on this instance)
302
+ */
303
+ async loadSinglePlugin(pluginId: string, pluginPath: string): Promise<void> {
304
+ rootLogger.info(`🔌 Loading plugin at runtime: ${pluginId}`);
305
+
306
+ // Emit instance-local installing hook
307
+ const eventBus = await this.registry.get(coreServices.eventBus, {
308
+ pluginId: "core",
309
+ });
310
+ await eventBus.emitLocal(coreHooks.pluginInstalling, { pluginId });
311
+
312
+ try {
313
+ // 1. Try to import the plugin - if it fails, install it first
314
+ let pluginModule;
315
+
316
+ try {
317
+ // Try importing by package name first
318
+ pluginModule = await import(pluginId);
319
+ } catch {
320
+ try {
321
+ // Try importing by path
322
+ pluginModule = await import(pluginPath);
323
+ } catch {
324
+ // Plugin not available locally - need to install it
325
+ rootLogger.info(
326
+ ` -> Plugin ${pluginId} not found locally, installing via npm...`
327
+ );
328
+
329
+ const installer = await this.registry.get(
330
+ coreServices.pluginInstaller,
331
+ { pluginId: "core" }
332
+ );
333
+ const result = await installer.install(pluginId);
334
+
335
+ // Now try importing again
336
+ try {
337
+ pluginModule = await import(result.name);
338
+ } catch {
339
+ pluginModule = await import(result.path);
340
+ }
341
+ }
342
+ }
343
+
344
+ const backendPlugin: BackendPlugin = pluginModule.default;
345
+
346
+ if (!backendPlugin || typeof backendPlugin.register !== "function") {
347
+ throw new Error(
348
+ `Plugin ${pluginId} does not export a valid BackendPlugin`
349
+ );
350
+ }
351
+
352
+ const metaPluginId = backendPlugin.metadata.pluginId;
353
+
354
+ // Store metadata for request-time context injection
355
+ this.pluginMetadataRegistry.set(metaPluginId, backendPlugin.metadata);
356
+
357
+ // 2. Register plugin (Phase 1)
358
+ const pendingInits: { pluginId: string; init: () => Promise<void> }[] =
359
+ [];
360
+
361
+ backendPlugin.register({
362
+ registerInit: (args) => {
363
+ pendingInits.push({
364
+ pluginId: metaPluginId,
365
+ init: async () => {
366
+ // Resolve dependencies
367
+ const resolvedDeps: Record<string, unknown> = {};
368
+ for (const [key, ref] of Object.entries(args.deps)) {
369
+ resolvedDeps[key] = await this.registry.get(
370
+ ref as ServiceRef<unknown>,
371
+ backendPlugin.metadata
372
+ );
373
+ }
374
+ await args.init(resolvedDeps as never);
375
+ },
376
+ });
377
+ },
378
+ registerPermissions: (permissions) => {
379
+ const prefixed = permissions.map((p) => ({
380
+ ...p,
381
+ id: `${metaPluginId}.${p.id}`,
382
+ pluginId: metaPluginId,
383
+ }));
384
+ this.registeredPermissions.push(...prefixed);
385
+
386
+ // Emit permission hook
387
+ eventBus.emit(coreHooks.permissionsRegistered, {
388
+ pluginId: metaPluginId,
389
+ permissions: prefixed,
390
+ });
391
+ },
392
+ registerService: (ref, impl) => {
393
+ this.registry.register(ref, impl);
394
+ },
395
+ registerExtensionPoint: (ref, impl) => {
396
+ this.extensionPointManager.registerExtensionPoint(ref, impl);
397
+ },
398
+ registerCleanup: (cleanup) => {
399
+ const handlers = this.cleanupHandlers.get(metaPluginId) || [];
400
+ handlers.push(cleanup);
401
+ this.cleanupHandlers.set(metaPluginId, handlers);
402
+ },
403
+ getExtensionPoint: <T>(ref: ExtensionPoint<T>) =>
404
+ this.extensionPointManager.getExtensionPoint(ref),
405
+ registerRouter: (router: unknown, contract: AnyContractRouter) => {
406
+ this.pluginRpcRouters.set(metaPluginId, router);
407
+ this.pluginContractRegistry.set(metaPluginId, contract);
408
+ },
409
+ pluginManager: {
410
+ getAllPermissions: () => this.getAllPermissions(),
411
+ },
412
+ });
413
+
414
+ // 3. Initialize plugin (Phase 2)
415
+ for (const pending of pendingInits) {
416
+ await pending.init();
417
+ }
418
+
419
+ // 4. Emit pluginInitialized
420
+ await eventBus.emit(coreHooks.pluginInitialized, { pluginId });
421
+
422
+ // 5. Emit pluginInstalled
423
+ await eventBus.emit(coreHooks.pluginInstalled, { pluginId });
424
+
425
+ rootLogger.info(`✅ Plugin loaded at runtime: ${pluginId}`);
426
+ } catch (error) {
427
+ rootLogger.error(`❌ Failed to load plugin ${pluginId}:`, error);
428
+ throw error;
429
+ }
430
+ }
431
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { PluginManager } from "./plugin-manager";
3
+ import { coreServices, os, RpcContext } from "@checkstack/backend-api";
4
+ import { Hono } from "hono";
5
+ import { createMockDbModule } from "@checkstack/test-utils-backend";
6
+ import { createMockLoggerModule } from "@checkstack/test-utils-backend";
7
+
8
+ // Mock DB and other globals
9
+ mock.module("./db", () => createMockDbModule());
10
+
11
+ mock.module("./logger", () => createMockLoggerModule());
12
+
13
+ describe("RPC REST Compatibility", () => {
14
+ let pluginManager: PluginManager;
15
+ let app: Hono;
16
+
17
+ beforeEach(() => {
18
+ pluginManager = new PluginManager();
19
+ app = new Hono();
20
+ });
21
+
22
+ it("should handle GET /api/auth/permissions via oRPC router", async () => {
23
+ // 1. Setup a mock auth router
24
+ const authRouter = os.router({
25
+ permissions: os.handler(async () => {
26
+ return { permissions: ["test-perm"] };
27
+ }),
28
+ });
29
+
30
+ // 2. Register it in plugin manager (now using new pattern - router AND contract required)
31
+ // Note: In real usage, the path is derived from pluginId. For test, we manually set it.
32
+ const rpcService = await pluginManager.getService(coreServices.rpc);
33
+ // The new API auto-prefixes based on pluginId, but for test we need to manually set the map key
34
+ // Since we're testing the router handler directly, we use the derived name "auth"
35
+ // Second argument is the contract (for OpenAPI generation) - using a mock object for test
36
+ rpcService?.registerRouter(authRouter, { permissions: {} });
37
+
38
+ // 3. Mock the auth service to skip real authentication
39
+ const mockAuth: any = {
40
+ authenticate: mock(async () => ({ id: "user-1", permissions: ["*"] })),
41
+ };
42
+ pluginManager.registerService(coreServices.auth, mockAuth);
43
+
44
+ // Register other dummy services needed for context
45
+ pluginManager.registerService(coreServices.logger, {
46
+ info: mock(),
47
+ debug: mock(),
48
+ error: mock(),
49
+ warn: mock(),
50
+ } as any);
51
+ pluginManager.registerService(coreServices.database, {} as any);
52
+ pluginManager.registerService(coreServices.fetch, {} as any);
53
+ pluginManager.registerService(coreServices.healthCheckRegistry, {} as any);
54
+ pluginManager.registerService(coreServices.queuePluginRegistry, {
55
+ getPlugins: () => [],
56
+ } as any);
57
+ pluginManager.registerService(coreServices.queueManager, {
58
+ getActivePlugin: () => "none",
59
+ getQueue: () => ({}),
60
+ } as any);
61
+
62
+ // 4. Mount the plugins
63
+ await pluginManager.loadPlugins(app);
64
+
65
+ // 5. Simulate the request that frontend makes (now /api/auth instead of /api/auth-backend)
66
+ const res = await app.request("/api/auth/permissions", {
67
+ method: "GET",
68
+ });
69
+
70
+ // If it's a 404, my theory about dots vs slashes or GET vs POST is correct
71
+ console.log("Response status:", res.status);
72
+ if (res.status === 200) {
73
+ const body = await res.json();
74
+ console.log("Response body:", JSON.stringify(body));
75
+ expect(body.permissions).toContain("test-perm");
76
+ } else {
77
+ console.log("Response text:", await res.text());
78
+ }
79
+ });
80
+ });
package/src/schema.ts ADDED
@@ -0,0 +1,46 @@
1
+ import {
2
+ pgTable,
3
+ text,
4
+ boolean,
5
+ json,
6
+ serial,
7
+ timestamp,
8
+ jsonb,
9
+ primaryKey,
10
+ } from "drizzle-orm/pg-core";
11
+
12
+ // --- Plugin System Schema ---
13
+ export const plugins = pgTable("plugins", {
14
+ id: serial("id").primaryKey(),
15
+ name: text("name").notNull().unique(),
16
+ path: text("path").notNull(),
17
+ isUninstallable: boolean("is_uninstallable").default(false).notNull(),
18
+ config: json("config").default({}),
19
+ enabled: boolean("enabled").default(true).notNull(),
20
+ type: text("type").default("backend").notNull(),
21
+ });
22
+
23
+ // --- JWT Key Store Schema ---
24
+ export const jwtKeys = pgTable("jwt_keys", {
25
+ id: text("id").primaryKey(), // The "kid"
26
+ publicKey: text("public_key").notNull(), // JWK JSON string
27
+ privateKey: text("private_key").notNull(), // Encrypted JWK JSON string (or plain if env is secure)
28
+ algorithm: text("algorithm").notNull(), // e.g. RS256
29
+ createdAt: text("created_at").notNull(), // ISO string
30
+ expiresAt: text("expires_at"), // ISO string, null if indefinite
31
+ revokedAt: text("revoked_at"), // ISO string, null if valid
32
+ });
33
+
34
+ // --- Plugin Configs Schema ---
35
+ export const pluginConfigs = pgTable(
36
+ "plugin_configs",
37
+ {
38
+ pluginId: text("plugin_id").notNull(),
39
+ configId: text("config_id").notNull(),
40
+ data: jsonb("data").notNull(), // Stores VersionedConfig with encrypted secrets
41
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
42
+ },
43
+ (table) => ({
44
+ pk: primaryKey({ columns: [table.pluginId, table.configId] }),
45
+ })
46
+ );
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, beforeAll } from "bun:test";
2
+ import { z } from "zod";
3
+ import { configString, isSecretSchema } from "@checkstack/backend-api";
4
+ import { encrypt, decrypt, isEncrypted } from "@checkstack/backend-api";
5
+
6
+ describe("Secret Detection", () => {
7
+ it("should detect direct secret fields", () => {
8
+ const schema = configString({ "x-secret": true });
9
+ expect(isSecretSchema(schema)).toBe(true);
10
+ });
11
+
12
+ it("should detect optional secret fields", () => {
13
+ const schema = configString({ "x-secret": true }).optional();
14
+ expect(isSecretSchema(schema)).toBe(true);
15
+ });
16
+
17
+ it("should not detect regular string fields", () => {
18
+ const schema = z.string();
19
+ expect(isSecretSchema(schema)).toBe(false);
20
+ });
21
+
22
+ it("should not detect optional regular string fields", () => {
23
+ const schema = z.string().optional();
24
+ expect(isSecretSchema(schema)).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe("Encryption and Decryption", () => {
29
+ beforeAll(() => {
30
+ // Set a test encryption key (32 bytes = 64 hex chars)
31
+ process.env.ENCRYPTION_MASTER_KEY =
32
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
33
+ });
34
+
35
+ it("should encrypt and decrypt a secret value", () => {
36
+ const plaintext = "my-secret-value";
37
+ const encrypted = encrypt(plaintext);
38
+
39
+ expect(encrypted).not.toBe(plaintext);
40
+ expect(isEncrypted(encrypted)).toBe(true);
41
+
42
+ const decrypted = decrypt(encrypted);
43
+ expect(decrypted).toBe(plaintext);
44
+ });
45
+
46
+ it("should produce different ciphertexts for the same plaintext", () => {
47
+ const plaintext = "same-secret";
48
+ const encrypted1 = encrypt(plaintext);
49
+ const encrypted2 = encrypt(plaintext);
50
+
51
+ // Different IVs should produce different ciphertexts
52
+ expect(encrypted1).not.toBe(encrypted2);
53
+
54
+ // But both should decrypt to the same value
55
+ expect(decrypt(encrypted1)).toBe(plaintext);
56
+ expect(decrypt(encrypted2)).toBe(plaintext);
57
+ });
58
+
59
+ it("should correctly identify encrypted vs plaintext values", () => {
60
+ const plaintext = "not-encrypted";
61
+ const encrypted = encrypt(plaintext);
62
+
63
+ expect(isEncrypted(plaintext)).toBe(false);
64
+ expect(isEncrypted(encrypted)).toBe(true);
65
+ });
66
+ });