@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,313 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { EventBus } from "../services/event-bus";
|
|
3
|
+
import { createHook } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
createMockLogger,
|
|
6
|
+
createMockQueueManager,
|
|
7
|
+
} from "@checkstack/test-utils-backend";
|
|
8
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
9
|
+
import type { QueueManager } from "@checkstack/queue-api";
|
|
10
|
+
|
|
11
|
+
describe("EventBus Integration Tests", () => {
|
|
12
|
+
let eventBus: EventBus;
|
|
13
|
+
let mockQueueManager: QueueManager;
|
|
14
|
+
let mockLogger: Logger;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockQueueManager = createMockQueueManager();
|
|
18
|
+
mockLogger = createMockLogger();
|
|
19
|
+
eventBus = new EventBus(mockQueueManager, mockLogger);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("Permission Sync Scenario", () => {
|
|
23
|
+
it("should sync permissions across plugins using work-queue mode", async () => {
|
|
24
|
+
// Simulate the permissionsRegistered hook
|
|
25
|
+
const permissionsRegistered = createHook<{
|
|
26
|
+
pluginId: string;
|
|
27
|
+
permissions: Array<{ id: string; description?: string }>;
|
|
28
|
+
}>("core.permissionsRegistered");
|
|
29
|
+
|
|
30
|
+
const syncedPermissions: Array<{ id: string; description?: string }> = [];
|
|
31
|
+
|
|
32
|
+
// Auth-backend subscribes to sync permissions (work-queue mode)
|
|
33
|
+
await eventBus.subscribe(
|
|
34
|
+
"auth-backend",
|
|
35
|
+
permissionsRegistered,
|
|
36
|
+
async ({ permissions }) => {
|
|
37
|
+
// Simulate DB sync
|
|
38
|
+
syncedPermissions.push(...permissions);
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
mode: "work-queue",
|
|
42
|
+
workerGroup: "permission-db-sync",
|
|
43
|
+
maxRetries: 3,
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Emit permission registration events from different plugins
|
|
48
|
+
await eventBus.emit(permissionsRegistered, {
|
|
49
|
+
pluginId: "catalog",
|
|
50
|
+
permissions: [
|
|
51
|
+
{ id: "catalog-backend.read", description: "Read catalog" },
|
|
52
|
+
{ id: "catalog-backend.manage", description: "Manage catalog" },
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await eventBus.emit(permissionsRegistered, {
|
|
57
|
+
pluginId: "queue",
|
|
58
|
+
permissions: [
|
|
59
|
+
{ id: "queue-backend.read", description: "Read queue" },
|
|
60
|
+
{ id: "queue-backend.manage", description: "Manage queue" },
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Wait for async processing
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
66
|
+
|
|
67
|
+
// All permissions should be synced
|
|
68
|
+
expect(syncedPermissions.length).toBe(4);
|
|
69
|
+
expect(syncedPermissions.map((p) => p.id)).toContain(
|
|
70
|
+
"catalog-backend.read"
|
|
71
|
+
);
|
|
72
|
+
expect(syncedPermissions.map((p) => p.id)).toContain(
|
|
73
|
+
"catalog-backend.manage"
|
|
74
|
+
);
|
|
75
|
+
expect(syncedPermissions.map((p) => p.id)).toContain(
|
|
76
|
+
"queue-backend.read"
|
|
77
|
+
);
|
|
78
|
+
expect(syncedPermissions.map((p) => p.id)).toContain(
|
|
79
|
+
"queue-backend.manage"
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should distribute jobs across instances in work-queue mode", async () => {
|
|
84
|
+
const testHook = createHook<{ data: string }>("test.hook");
|
|
85
|
+
|
|
86
|
+
let instance1Count = 0;
|
|
87
|
+
let instance2Count = 0;
|
|
88
|
+
|
|
89
|
+
// Simulate two different backend instances (different plugin IDs)
|
|
90
|
+
// Both use same workerGroup name but get namespaced differently
|
|
91
|
+
await eventBus.subscribe(
|
|
92
|
+
"plugin-instance-1",
|
|
93
|
+
testHook,
|
|
94
|
+
async () => {
|
|
95
|
+
instance1Count++;
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
mode: "work-queue",
|
|
99
|
+
workerGroup: "sync",
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
await eventBus.subscribe(
|
|
104
|
+
"plugin-instance-2",
|
|
105
|
+
testHook,
|
|
106
|
+
async () => {
|
|
107
|
+
instance2Count++;
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
mode: "work-queue",
|
|
111
|
+
workerGroup: "sync",
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Emit multiple events
|
|
116
|
+
await eventBus.emit(testHook, { data: "test1" });
|
|
117
|
+
await eventBus.emit(testHook, { data: "test2" });
|
|
118
|
+
await eventBus.emit(testHook, { data: "test3" });
|
|
119
|
+
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
121
|
+
|
|
122
|
+
// Both instances should process jobs (different namespaces due to different plugin IDs)
|
|
123
|
+
const total = instance1Count + instance2Count;
|
|
124
|
+
expect(total).toBe(6); // Each instance gets all 3 jobs (different namespaces)
|
|
125
|
+
|
|
126
|
+
// Each instance should have processed the jobs
|
|
127
|
+
expect(instance1Count).toBe(3);
|
|
128
|
+
expect(instance2Count).toBe(3);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("Broadcast Scenario", () => {
|
|
133
|
+
it("should notify all plugin instances in broadcast mode", async () => {
|
|
134
|
+
const configUpdated = createHook<{ key: string; value: string }>(
|
|
135
|
+
"core.configUpdated"
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const plugin1Notifications: string[] = [];
|
|
139
|
+
const plugin2Notifications: string[] = [];
|
|
140
|
+
|
|
141
|
+
// Multiple plugins subscribe to config updates
|
|
142
|
+
await eventBus.subscribe("plugin-1", configUpdated, async ({ key }) => {
|
|
143
|
+
plugin1Notifications.push(key);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await eventBus.subscribe("plugin-2", configUpdated, async ({ key }) => {
|
|
147
|
+
plugin2Notifications.push(key);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Emit config update
|
|
151
|
+
await eventBus.emit(configUpdated, {
|
|
152
|
+
key: "database.url",
|
|
153
|
+
value: "postgresql://localhost",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
157
|
+
|
|
158
|
+
// Both plugins should receive the notification
|
|
159
|
+
expect(plugin1Notifications).toContain("database.url");
|
|
160
|
+
expect(plugin2Notifications).toContain("database.url");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("Mixed Mode Scenario", () => {
|
|
165
|
+
it("should handle both broadcast and work-queue for same hook", async () => {
|
|
166
|
+
const dataProcessed = createHook<{ id: string }>("data.processed");
|
|
167
|
+
|
|
168
|
+
const broadcastNotifications: string[] = [];
|
|
169
|
+
const workQueueProcessed: string[] = [];
|
|
170
|
+
|
|
171
|
+
// Broadcast subscriber (logging/monitoring)
|
|
172
|
+
await eventBus.subscribe(
|
|
173
|
+
"logger-plugin",
|
|
174
|
+
dataProcessed,
|
|
175
|
+
async ({ id }) => {
|
|
176
|
+
broadcastNotifications.push(`logged-${id}`);
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Work-queue subscribers (actual processing - only one should handle)
|
|
181
|
+
await eventBus.subscribe(
|
|
182
|
+
"processor-plugin",
|
|
183
|
+
dataProcessed,
|
|
184
|
+
async ({ id }) => {
|
|
185
|
+
workQueueProcessed.push(`processed-${id}`);
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
mode: "work-queue",
|
|
189
|
+
workerGroup: "processor",
|
|
190
|
+
}
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Different work-queue subscriber with different group
|
|
194
|
+
await eventBus.subscribe(
|
|
195
|
+
"archiver-plugin",
|
|
196
|
+
dataProcessed,
|
|
197
|
+
async ({ id }) => {
|
|
198
|
+
workQueueProcessed.push(`archived-${id}`);
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
mode: "work-queue",
|
|
202
|
+
workerGroup: "archiver",
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Emit event
|
|
207
|
+
await eventBus.emit(dataProcessed, { id: "data-123" });
|
|
208
|
+
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
210
|
+
|
|
211
|
+
// Broadcast should always receive
|
|
212
|
+
expect(broadcastNotifications).toContain("logged-data-123");
|
|
213
|
+
|
|
214
|
+
// Work-queue should process (both groups should handle it)
|
|
215
|
+
expect(workQueueProcessed).toContain("processed-data-123");
|
|
216
|
+
expect(workQueueProcessed).toContain("archived-data-123");
|
|
217
|
+
expect(workQueueProcessed.length).toBe(2);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("Error Resilience", () => {
|
|
222
|
+
it("should continue processing other listeners if one fails", async () => {
|
|
223
|
+
const testHook = createHook<{ value: number }>("test.hook");
|
|
224
|
+
|
|
225
|
+
const successful: number[] = [];
|
|
226
|
+
|
|
227
|
+
// First listener fails
|
|
228
|
+
await eventBus.subscribe("plugin-1", testHook, async () => {
|
|
229
|
+
throw new Error("Simulated failure");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Second listener succeeds
|
|
233
|
+
await eventBus.subscribe("plugin-2", testHook, async ({ value }) => {
|
|
234
|
+
successful.push(value);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Third listener succeeds
|
|
238
|
+
await eventBus.subscribe("plugin-3", testHook, async ({ value }) => {
|
|
239
|
+
successful.push(value * 2);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await eventBus.emit(testHook, { value: 10 });
|
|
243
|
+
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
245
|
+
|
|
246
|
+
// Despite first listener failing, others should succeed
|
|
247
|
+
expect(successful).toContain(10);
|
|
248
|
+
expect(successful).toContain(20);
|
|
249
|
+
expect(mockLogger.error).toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("Lifecycle", () => {
|
|
254
|
+
it("should properly clean up on shutdown", async () => {
|
|
255
|
+
const hook1 = createHook<{ test: string }>("hook1");
|
|
256
|
+
const hook2 = createHook<{ test: string }>("hook2");
|
|
257
|
+
|
|
258
|
+
await eventBus.subscribe("test-plugin", hook1, async () => {});
|
|
259
|
+
await eventBus.subscribe("test-plugin", hook2, async () => {});
|
|
260
|
+
|
|
261
|
+
await eventBus.shutdown();
|
|
262
|
+
|
|
263
|
+
// Should log shutdown
|
|
264
|
+
expect(mockLogger.info).toHaveBeenCalledWith("EventBus shut down");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should allow unsubscribe and re-subscribe with same workerGroup", async () => {
|
|
268
|
+
const testHook = createHook<{ value: number }>("test.hook");
|
|
269
|
+
const values: number[] = [];
|
|
270
|
+
|
|
271
|
+
// Subscribe
|
|
272
|
+
const unsub = await eventBus.subscribe(
|
|
273
|
+
"test-plugin",
|
|
274
|
+
testHook,
|
|
275
|
+
async ({ value }) => {
|
|
276
|
+
values.push(value);
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
mode: "work-queue",
|
|
280
|
+
workerGroup: "processor",
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
await eventBus.emit(testHook, { value: 1 });
|
|
285
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
286
|
+
|
|
287
|
+
expect(values).toContain(1);
|
|
288
|
+
|
|
289
|
+
// Unsubscribe
|
|
290
|
+
await unsub();
|
|
291
|
+
|
|
292
|
+
// Re-subscribe with same workerGroup (should work)
|
|
293
|
+
await eventBus.subscribe(
|
|
294
|
+
"test-plugin",
|
|
295
|
+
testHook,
|
|
296
|
+
async ({ value }) => {
|
|
297
|
+
values.push(value * 10);
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
mode: "work-queue",
|
|
301
|
+
workerGroup: "processor", // Same name OK after unsubscribe
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
await eventBus.emit(testHook, { value: 2 });
|
|
306
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
307
|
+
|
|
308
|
+
// First value from first subscription, second value from second subscription
|
|
309
|
+
expect(values).toContain(1);
|
|
310
|
+
expect(values).toContain(20); // 2 * 10
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createLogger, format, transports } from "winston";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
|
|
5
|
+
const { combine, timestamp, printf, colorize, json } = format;
|
|
6
|
+
|
|
7
|
+
const devFormat = printf(({ level, message, timestamp, ...meta }) => {
|
|
8
|
+
const plugin = meta.plugin ? `[${meta.plugin}] ` : "";
|
|
9
|
+
// Stringify rest of meta if it exists and isn't just plugin
|
|
10
|
+
const { plugin: _p, ...rest } = meta;
|
|
11
|
+
const metaStr = Object.keys(rest).length > 0 ? JSON.stringify(rest) : "";
|
|
12
|
+
|
|
13
|
+
return `${timestamp} ${level}: ${plugin}${message} ${metaStr}`;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Plain text format for file logging (without colors)
|
|
17
|
+
const fileFormat = printf(({ level, message, timestamp, ...meta }) => {
|
|
18
|
+
const plugin = meta.plugin ? `[${meta.plugin}] ` : "";
|
|
19
|
+
const { plugin: _p, ...rest } = meta;
|
|
20
|
+
const metaStr = Object.keys(rest).length > 0 ? JSON.stringify(rest) : "";
|
|
21
|
+
|
|
22
|
+
return `${timestamp} ${level}: ${plugin}${message} ${metaStr}`;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Setup file transports for development
|
|
26
|
+
const developmentTransports: transports.StreamTransportInstance[] = [
|
|
27
|
+
new transports.Console(),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
if (process.env.NODE_ENV !== "production") {
|
|
31
|
+
// Create logs directory if it doesn't exist
|
|
32
|
+
const logsDir = path.join(process.cwd(), ".dev", "logs");
|
|
33
|
+
if (!fs.existsSync(logsDir)) {
|
|
34
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Add file transports
|
|
38
|
+
developmentTransports.push(
|
|
39
|
+
// Timestamped log file
|
|
40
|
+
new transports.File({
|
|
41
|
+
filename: path.join(
|
|
42
|
+
logsDir,
|
|
43
|
+
`backend-${
|
|
44
|
+
new Date().toISOString().replaceAll(":", "-").split(".")[0]
|
|
45
|
+
}.log`
|
|
46
|
+
),
|
|
47
|
+
format: combine(timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), fileFormat),
|
|
48
|
+
}),
|
|
49
|
+
// Latest log file (always overwritten)
|
|
50
|
+
new transports.File({
|
|
51
|
+
filename: path.join(logsDir, "latest.log"),
|
|
52
|
+
format: combine(timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), fileFormat),
|
|
53
|
+
options: { flags: "w" }, // Overwrite on each start
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const rootLogger = createLogger({
|
|
59
|
+
level: process.env.LOG_LEVEL || "info",
|
|
60
|
+
format:
|
|
61
|
+
process.env.NODE_ENV === "production"
|
|
62
|
+
? json()
|
|
63
|
+
: combine(colorize(), timestamp({ format: "HH:mm:ss" }), devFormat),
|
|
64
|
+
transports: developmentTransports,
|
|
65
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI Router - Exposes OpenAPI specification for external applications.
|
|
3
|
+
*
|
|
4
|
+
* This router provides a `/api/openapi.json` endpoint that returns the
|
|
5
|
+
* aggregated OpenAPI specification for all endpoints accessible by
|
|
6
|
+
* external applications (userType: "authenticated" | "public").
|
|
7
|
+
*/
|
|
8
|
+
import { OpenAPIGenerator } from "@orpc/openapi";
|
|
9
|
+
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
|
10
|
+
import type { AnyContractRouter } from "@orpc/contract";
|
|
11
|
+
import type { PluginManager } from "./plugin-manager";
|
|
12
|
+
import type { AuthService } from "@checkstack/backend-api";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a user has a specific permission.
|
|
16
|
+
* Supports wildcard (*) for admin access.
|
|
17
|
+
*/
|
|
18
|
+
function hasPermission(
|
|
19
|
+
user: { permissions?: string[] },
|
|
20
|
+
permission: string
|
|
21
|
+
): boolean {
|
|
22
|
+
if (!user.permissions) return false;
|
|
23
|
+
return (
|
|
24
|
+
user.permissions.includes("*") || user.permissions.includes(permission)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract procedure metadata from a contract using oRPC internal structure.
|
|
30
|
+
*/
|
|
31
|
+
function extractProcedureMetadata(
|
|
32
|
+
contract: unknown
|
|
33
|
+
): { userType?: string; permissions?: string[] } | undefined {
|
|
34
|
+
const orpcData = (contract as Record<string, unknown>)?.["~orpc"] as
|
|
35
|
+
| { meta?: { userType?: string; permissions?: string[] } }
|
|
36
|
+
| undefined;
|
|
37
|
+
return orpcData?.meta;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a lookup map of operationId -> metadata from all contracts.
|
|
42
|
+
* operationId format: "pluginId.procedureName"
|
|
43
|
+
*/
|
|
44
|
+
function buildMetadataLookup(
|
|
45
|
+
contracts: Map<string, AnyContractRouter>
|
|
46
|
+
): Map<string, { userType?: string; permissions?: string[] }> {
|
|
47
|
+
const lookup = new Map<
|
|
48
|
+
string,
|
|
49
|
+
{ userType?: string; permissions?: string[] }
|
|
50
|
+
>();
|
|
51
|
+
|
|
52
|
+
for (const [pluginId, contract] of contracts) {
|
|
53
|
+
// Contract is an object with procedure names as keys
|
|
54
|
+
for (const [procedureName, procedure] of Object.entries(
|
|
55
|
+
contract as Record<string, unknown>
|
|
56
|
+
)) {
|
|
57
|
+
const meta = extractProcedureMetadata(procedure);
|
|
58
|
+
if (meta) {
|
|
59
|
+
const operationId = `${pluginId}.${procedureName}`;
|
|
60
|
+
lookup.set(operationId, meta);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return lookup;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate OpenAPI specification from registered plugin contracts.
|
|
70
|
+
* Returns all endpoints with their userType metadata visible as x-orpc-meta.
|
|
71
|
+
*/
|
|
72
|
+
export async function generateOpenApiSpec({
|
|
73
|
+
pluginManager,
|
|
74
|
+
baseUrl,
|
|
75
|
+
}: {
|
|
76
|
+
pluginManager: PluginManager;
|
|
77
|
+
baseUrl: string;
|
|
78
|
+
}): Promise<Record<string, unknown>> {
|
|
79
|
+
const contracts = pluginManager.getAllContracts();
|
|
80
|
+
|
|
81
|
+
// Build aggregated contract object: { pluginId: contract, ... }
|
|
82
|
+
const aggregatedContract: Record<string, AnyContractRouter> = {};
|
|
83
|
+
for (const [pluginId, contract] of contracts) {
|
|
84
|
+
aggregatedContract[pluginId] = contract;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Build metadata lookup from contracts
|
|
88
|
+
const metadataLookup = buildMetadataLookup(contracts);
|
|
89
|
+
|
|
90
|
+
// Create OpenAPI generator with Zod v4 converter
|
|
91
|
+
const generator = new OpenAPIGenerator({
|
|
92
|
+
schemaConverters: [new ZodToJsonSchemaConverter()],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Generate spec for all endpoints
|
|
96
|
+
const spec = (await generator.generate(aggregatedContract, {
|
|
97
|
+
info: {
|
|
98
|
+
title: "Checkstack API",
|
|
99
|
+
version: "1.0.0",
|
|
100
|
+
description: "API documentation for Checkstack platform endpoints.",
|
|
101
|
+
},
|
|
102
|
+
servers: [{ url: baseUrl }],
|
|
103
|
+
})) as {
|
|
104
|
+
paths?: Record<
|
|
105
|
+
string,
|
|
106
|
+
Record<string, { operationId?: string; "x-orpc-meta"?: unknown }>
|
|
107
|
+
>;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Post-process: Add x-orpc-meta to each operation and prefix paths with /api
|
|
111
|
+
if (spec.paths) {
|
|
112
|
+
const prefixedPaths: typeof spec.paths = {};
|
|
113
|
+
|
|
114
|
+
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
115
|
+
// Prefix path with /api
|
|
116
|
+
const prefixedPath = `/api${path.startsWith("/") ? path : `/${path}`}`;
|
|
117
|
+
prefixedPaths[prefixedPath] = methods;
|
|
118
|
+
|
|
119
|
+
// Add metadata to each operation
|
|
120
|
+
for (const operation of Object.values(methods)) {
|
|
121
|
+
if (operation.operationId) {
|
|
122
|
+
const meta = metadataLookup.get(operation.operationId);
|
|
123
|
+
if (meta) {
|
|
124
|
+
operation["x-orpc-meta"] = meta;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
spec.paths = prefixedPaths;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return spec as Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Create the OpenAPI endpoint handler for Hono.
|
|
138
|
+
* Returns a fetch handler that serves the OpenAPI spec.
|
|
139
|
+
*/
|
|
140
|
+
export function createOpenApiHandler({
|
|
141
|
+
pluginManager,
|
|
142
|
+
authService,
|
|
143
|
+
baseUrl,
|
|
144
|
+
requiredPermission,
|
|
145
|
+
}: {
|
|
146
|
+
pluginManager: PluginManager;
|
|
147
|
+
authService: AuthService;
|
|
148
|
+
baseUrl: string;
|
|
149
|
+
requiredPermission: string;
|
|
150
|
+
}): (req: Request) => Promise<Response> {
|
|
151
|
+
return async (req: Request) => {
|
|
152
|
+
// Authenticate request
|
|
153
|
+
const user = await authService.authenticate(req);
|
|
154
|
+
|
|
155
|
+
if (!user) {
|
|
156
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Check permission (applications.manage from auth plugin)
|
|
160
|
+
// Services don't have permissions, so deny them access to docs
|
|
161
|
+
if (user.type === "service" || !hasPermission(user, requiredPermission)) {
|
|
162
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const spec = await generateOpenApiSpec({ pluginManager, baseUrl });
|
|
167
|
+
|
|
168
|
+
return Response.json(spec);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error("Failed to generate OpenAPI spec:", error);
|
|
171
|
+
return Response.json(
|
|
172
|
+
{ error: "Failed to generate OpenAPI specification" },
|
|
173
|
+
{ status: 500 }
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|