@checkstack/backend-api 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 +228 -0
- package/package.json +33 -0
- package/src/assertions.test.ts +345 -0
- package/src/assertions.ts +371 -0
- package/src/auth-strategy.ts +58 -0
- package/src/chart-metadata.ts +77 -0
- package/src/config-service.ts +71 -0
- package/src/config-versioning.ts +310 -0
- package/src/contract.ts +8 -0
- package/src/core-services.ts +45 -0
- package/src/email-layout.ts +246 -0
- package/src/encryption.ts +95 -0
- package/src/event-bus-types.ts +28 -0
- package/src/extension-point.ts +11 -0
- package/src/health-check.ts +68 -0
- package/src/hooks.ts +182 -0
- package/src/index.ts +23 -0
- package/src/markdown.test.ts +106 -0
- package/src/markdown.ts +104 -0
- package/src/notification-strategy.ts +436 -0
- package/src/oauth-handler.ts +442 -0
- package/src/plugin-admin-contract.ts +64 -0
- package/src/plugin-system.ts +103 -0
- package/src/rpc.ts +284 -0
- package/src/schema-utils.ts +79 -0
- package/src/service-ref.ts +15 -0
- package/src/test-utils.ts +65 -0
- package/src/types.ts +111 -0
- package/src/zod-config.ts +149 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Handler Factory
|
|
3
|
+
*
|
|
4
|
+
* Provides a generic, reusable OAuth 2.0 handler for plugins that need OAuth flows.
|
|
5
|
+
* Used by notification strategies but can be reused by any plugin.
|
|
6
|
+
*
|
|
7
|
+
* @module @checkstack/backend-api/oauth-handler
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
11
|
+
// OAuth Configuration Interface
|
|
12
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* OAuth 2.0 provider configuration.
|
|
16
|
+
* Designed for declarative definition with minimal boilerplate.
|
|
17
|
+
*/
|
|
18
|
+
export interface OAuthConfig {
|
|
19
|
+
/**
|
|
20
|
+
* OAuth 2.0 client ID.
|
|
21
|
+
* Can be a function for lazy loading from ConfigService.
|
|
22
|
+
*/
|
|
23
|
+
clientId: string | (() => string | Promise<string>);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* OAuth 2.0 client secret.
|
|
27
|
+
* Can be a function for lazy loading from ConfigService.
|
|
28
|
+
*/
|
|
29
|
+
clientSecret: string | (() => string | Promise<string>);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Scopes to request from the OAuth provider.
|
|
33
|
+
*/
|
|
34
|
+
scopes: string[];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Provider's authorization URL (where users are redirected to consent).
|
|
38
|
+
* @example "https://slack.com/oauth/v2/authorize"
|
|
39
|
+
*/
|
|
40
|
+
authorizationUrl: string;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Provider's token exchange URL.
|
|
44
|
+
* @example "https://slack.com/api/oauth.v2.access"
|
|
45
|
+
*/
|
|
46
|
+
tokenUrl: string;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract the user's external ID from the token response.
|
|
50
|
+
* This ID is used to identify the user on the external platform.
|
|
51
|
+
*
|
|
52
|
+
* @example (response) => response.authed_user.id // Slack
|
|
53
|
+
* @example (response) => response.user.id // Discord
|
|
54
|
+
*/
|
|
55
|
+
extractExternalId: (tokenResponse: Record<string, unknown>) => string;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Optional: Custom state encoder for CSRF protection.
|
|
59
|
+
* Default implementation encodes userId and returnUrl as base64 JSON.
|
|
60
|
+
*/
|
|
61
|
+
encodeState?: (userId: string, returnUrl: string) => string;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Optional: Custom state decoder.
|
|
65
|
+
* Must match the encoder implementation.
|
|
66
|
+
*/
|
|
67
|
+
decodeState?: (state: string) => { userId: string; returnUrl: string };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Optional: Custom authorization URL builder.
|
|
71
|
+
* Use when provider has non-standard OAuth parameters.
|
|
72
|
+
*/
|
|
73
|
+
buildAuthUrl?: (params: {
|
|
74
|
+
clientId: string;
|
|
75
|
+
redirectUri: string;
|
|
76
|
+
scopes: string[];
|
|
77
|
+
state: string;
|
|
78
|
+
}) => string;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Optional: Custom token refresh logic.
|
|
82
|
+
* Only needed if the provider uses refresh tokens.
|
|
83
|
+
*/
|
|
84
|
+
refreshToken?: (refreshToken: string) => Promise<{
|
|
85
|
+
accessToken: string;
|
|
86
|
+
refreshToken?: string;
|
|
87
|
+
expiresIn?: number;
|
|
88
|
+
}>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Optional: Extract access token from response.
|
|
92
|
+
* Default: response.access_token
|
|
93
|
+
*/
|
|
94
|
+
extractAccessToken?: (response: Record<string, unknown>) => string;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Optional: Extract refresh token from response.
|
|
98
|
+
* Default: response.refresh_token
|
|
99
|
+
*/
|
|
100
|
+
extractRefreshToken?: (
|
|
101
|
+
response: Record<string, unknown>
|
|
102
|
+
) => string | undefined;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Optional: Extract token expiration (seconds from now).
|
|
106
|
+
* Default: response.expires_in
|
|
107
|
+
*/
|
|
108
|
+
extractExpiresIn?: (response: Record<string, unknown>) => number | undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
112
|
+
// Handler Configuration
|
|
113
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Token storage callback parameters.
|
|
117
|
+
*/
|
|
118
|
+
export interface OAuthTokenData {
|
|
119
|
+
userId: string;
|
|
120
|
+
externalId: string;
|
|
121
|
+
accessToken: string;
|
|
122
|
+
refreshToken?: string;
|
|
123
|
+
expiresAt?: Date;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Configuration for creating an OAuth handler.
|
|
128
|
+
*/
|
|
129
|
+
export interface OAuthHandlerConfig {
|
|
130
|
+
/** OAuth provider configuration */
|
|
131
|
+
oauth: OAuthConfig;
|
|
132
|
+
|
|
133
|
+
/** Unique identifier for this OAuth integration */
|
|
134
|
+
qualifiedId: string;
|
|
135
|
+
|
|
136
|
+
/** Base URL for constructing callback URLs */
|
|
137
|
+
baseUrl: string;
|
|
138
|
+
|
|
139
|
+
/** Default return URL after OAuth flow completes */
|
|
140
|
+
defaultReturnUrl: string;
|
|
141
|
+
|
|
142
|
+
/** Called when tokens are received from provider */
|
|
143
|
+
onTokenReceived: (data: OAuthTokenData) => Promise<void>;
|
|
144
|
+
|
|
145
|
+
/** Called when user unlinks their account */
|
|
146
|
+
onUnlink: (userId: string) => Promise<void>;
|
|
147
|
+
|
|
148
|
+
/** Get current user ID from the request (requires auth) */
|
|
149
|
+
getUserIdFromRequest: (req: Request) => Promise<string | undefined>;
|
|
150
|
+
|
|
151
|
+
/** Optional: Error page URL for displaying errors */
|
|
152
|
+
errorUrl?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Result of creating an OAuth handler.
|
|
157
|
+
*/
|
|
158
|
+
export interface OAuthHandlerResult {
|
|
159
|
+
/** The HTTP request handler */
|
|
160
|
+
handler: (req: Request) => Promise<Response>;
|
|
161
|
+
|
|
162
|
+
/** Generated endpoint paths */
|
|
163
|
+
paths: {
|
|
164
|
+
/** Start OAuth flow */
|
|
165
|
+
auth: string;
|
|
166
|
+
/** Handle provider callback */
|
|
167
|
+
callback: string;
|
|
168
|
+
/** Refresh expired token */
|
|
169
|
+
refresh: string;
|
|
170
|
+
/** Unlink account */
|
|
171
|
+
unlink: string;
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
176
|
+
// Default Implementations
|
|
177
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
178
|
+
|
|
179
|
+
function defaultEncodeState(userId: string, returnUrl: string): string {
|
|
180
|
+
const data = JSON.stringify({ userId, returnUrl, ts: Date.now() });
|
|
181
|
+
return btoa(data);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function defaultDecodeState(state: string): {
|
|
185
|
+
userId: string;
|
|
186
|
+
returnUrl: string;
|
|
187
|
+
} {
|
|
188
|
+
try {
|
|
189
|
+
const data = JSON.parse(atob(state));
|
|
190
|
+
return { userId: data.userId, returnUrl: data.returnUrl };
|
|
191
|
+
} catch {
|
|
192
|
+
throw new Error("Invalid OAuth state");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function defaultBuildAuthUrl(params: {
|
|
197
|
+
clientId: string;
|
|
198
|
+
redirectUri: string;
|
|
199
|
+
scopes: string[];
|
|
200
|
+
state: string;
|
|
201
|
+
authorizationUrl: string;
|
|
202
|
+
}): string {
|
|
203
|
+
const url = new URL(params.authorizationUrl);
|
|
204
|
+
url.searchParams.set("client_id", params.clientId);
|
|
205
|
+
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
206
|
+
url.searchParams.set("scope", params.scopes.join(" "));
|
|
207
|
+
url.searchParams.set("state", params.state);
|
|
208
|
+
url.searchParams.set("response_type", "code");
|
|
209
|
+
return url.toString();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function resolveValue(
|
|
213
|
+
value: string | (() => string | Promise<string>)
|
|
214
|
+
): Promise<string> {
|
|
215
|
+
return typeof value === "function" ? await value() : value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
219
|
+
// OAuth Handler Factory
|
|
220
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Creates a reusable OAuth 2.0 handler for a given configuration.
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* ```typescript
|
|
227
|
+
* const { handler, paths } = createOAuthHandler({
|
|
228
|
+
* oauth: {
|
|
229
|
+
* clientId: () => config.slackClientId,
|
|
230
|
+
* clientSecret: () => config.slackClientSecret,
|
|
231
|
+
* scopes: ["users:read", "chat:write"],
|
|
232
|
+
* authorizationUrl: "https://slack.com/oauth/v2/authorize",
|
|
233
|
+
* tokenUrl: "https://slack.com/api/oauth.v2.access",
|
|
234
|
+
* extractExternalId: (res) => res.authed_user.id,
|
|
235
|
+
* },
|
|
236
|
+
* qualifiedId: "notification-slack.slack",
|
|
237
|
+
* baseUrl: "https://myapp.com",
|
|
238
|
+
* defaultReturnUrl: "/notification/settings",
|
|
239
|
+
* onTokenReceived: (data) => storeToken(data),
|
|
240
|
+
* onUnlink: (userId) => clearToken(userId),
|
|
241
|
+
* getUserIdFromRequest: (req) => authService.getUserId(req),
|
|
242
|
+
* });
|
|
243
|
+
*
|
|
244
|
+
* // Register: rpc.registerHttpHandler(handler, `/oauth/${qualifiedId}`);
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export function createOAuthHandler(
|
|
248
|
+
config: OAuthHandlerConfig
|
|
249
|
+
): OAuthHandlerResult {
|
|
250
|
+
const { oauth, qualifiedId, baseUrl, defaultReturnUrl } = config;
|
|
251
|
+
|
|
252
|
+
const encodeState = oauth.encodeState ?? defaultEncodeState;
|
|
253
|
+
const decodeState = oauth.decodeState ?? defaultDecodeState;
|
|
254
|
+
|
|
255
|
+
const basePath = `/oauth/${qualifiedId}`;
|
|
256
|
+
const paths = {
|
|
257
|
+
auth: `${basePath}/auth`,
|
|
258
|
+
callback: `${basePath}/callback`,
|
|
259
|
+
refresh: `${basePath}/refresh`,
|
|
260
|
+
unlink: `${basePath}/unlink`,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const callbackUrl = `${baseUrl}/api/notification${paths.callback}`;
|
|
264
|
+
|
|
265
|
+
async function handler(req: Request): Promise<Response> {
|
|
266
|
+
const url = new URL(req.url);
|
|
267
|
+
const pathname = url.pathname;
|
|
268
|
+
|
|
269
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
270
|
+
// GET /auth - Start OAuth flow
|
|
271
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
272
|
+
if (pathname.endsWith("/auth") && req.method === "GET") {
|
|
273
|
+
const userId = await config.getUserIdFromRequest(req);
|
|
274
|
+
if (!userId) {
|
|
275
|
+
return new Response("Unauthorized", { status: 401 });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const returnUrl = url.searchParams.get("returnUrl") ?? defaultReturnUrl;
|
|
279
|
+
const state = encodeState(userId, returnUrl);
|
|
280
|
+
|
|
281
|
+
const clientId = await resolveValue(oauth.clientId);
|
|
282
|
+
|
|
283
|
+
const authUrl = oauth.buildAuthUrl
|
|
284
|
+
? oauth.buildAuthUrl({
|
|
285
|
+
clientId,
|
|
286
|
+
redirectUri: callbackUrl,
|
|
287
|
+
scopes: oauth.scopes,
|
|
288
|
+
state,
|
|
289
|
+
})
|
|
290
|
+
: defaultBuildAuthUrl({
|
|
291
|
+
clientId,
|
|
292
|
+
redirectUri: callbackUrl,
|
|
293
|
+
scopes: oauth.scopes,
|
|
294
|
+
state,
|
|
295
|
+
authorizationUrl: oauth.authorizationUrl,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return Response.redirect(authUrl, 302);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
302
|
+
// GET /callback - Handle provider callback
|
|
303
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
304
|
+
if (pathname.endsWith("/callback") && req.method === "GET") {
|
|
305
|
+
const code = url.searchParams.get("code");
|
|
306
|
+
const state = url.searchParams.get("state");
|
|
307
|
+
const error = url.searchParams.get("error");
|
|
308
|
+
|
|
309
|
+
if (error) {
|
|
310
|
+
const errorUrl = config.errorUrl ?? defaultReturnUrl;
|
|
311
|
+
return Response.redirect(
|
|
312
|
+
`${errorUrl}?error=${encodeURIComponent(error)}`,
|
|
313
|
+
302
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!code || !state) {
|
|
318
|
+
return new Response("Missing code or state", { status: 400 });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let stateData: { userId: string; returnUrl: string };
|
|
322
|
+
try {
|
|
323
|
+
stateData = decodeState(state);
|
|
324
|
+
} catch {
|
|
325
|
+
return new Response("Invalid state", { status: 400 });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Exchange code for tokens
|
|
329
|
+
const clientId = await resolveValue(oauth.clientId);
|
|
330
|
+
const clientSecret = await resolveValue(oauth.clientSecret);
|
|
331
|
+
|
|
332
|
+
const tokenResponse = await fetch(oauth.tokenUrl, {
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: {
|
|
335
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
336
|
+
},
|
|
337
|
+
body: new URLSearchParams({
|
|
338
|
+
client_id: clientId,
|
|
339
|
+
client_secret: clientSecret,
|
|
340
|
+
code,
|
|
341
|
+
redirect_uri: callbackUrl,
|
|
342
|
+
grant_type: "authorization_code",
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
if (!tokenResponse.ok) {
|
|
347
|
+
const errorText = await tokenResponse.text();
|
|
348
|
+
console.error("OAuth token exchange failed:", errorText);
|
|
349
|
+
return Response.redirect(
|
|
350
|
+
`${stateData.returnUrl}?error=token_exchange_failed`,
|
|
351
|
+
302
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
|
|
356
|
+
|
|
357
|
+
// Extract token data
|
|
358
|
+
const extractAccessToken =
|
|
359
|
+
oauth.extractAccessToken ??
|
|
360
|
+
((r: Record<string, unknown>) => r.access_token as string);
|
|
361
|
+
const extractRefreshToken =
|
|
362
|
+
oauth.extractRefreshToken ??
|
|
363
|
+
((r: Record<string, unknown>) => r.refresh_token as string | undefined);
|
|
364
|
+
const extractExpiresIn =
|
|
365
|
+
oauth.extractExpiresIn ??
|
|
366
|
+
((r: Record<string, unknown>) => r.expires_in as number | undefined);
|
|
367
|
+
|
|
368
|
+
const accessToken = extractAccessToken(tokenData);
|
|
369
|
+
const refreshToken = extractRefreshToken(tokenData);
|
|
370
|
+
const expiresIn = extractExpiresIn(tokenData);
|
|
371
|
+
const externalId = oauth.extractExternalId(tokenData);
|
|
372
|
+
|
|
373
|
+
const expiresAt = expiresIn
|
|
374
|
+
? new Date(Date.now() + expiresIn * 1000)
|
|
375
|
+
: undefined;
|
|
376
|
+
|
|
377
|
+
// Store tokens
|
|
378
|
+
await config.onTokenReceived({
|
|
379
|
+
userId: stateData.userId,
|
|
380
|
+
externalId,
|
|
381
|
+
accessToken,
|
|
382
|
+
refreshToken,
|
|
383
|
+
expiresAt,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
return Response.redirect(stateData.returnUrl, 302);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
390
|
+
// POST /refresh - Refresh expired token
|
|
391
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
392
|
+
if (pathname.endsWith("/refresh") && req.method === "POST") {
|
|
393
|
+
const userId = await config.getUserIdFromRequest(req);
|
|
394
|
+
if (!userId) {
|
|
395
|
+
return new Response("Unauthorized", { status: 401 });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!oauth.refreshToken) {
|
|
399
|
+
return Response.json(
|
|
400
|
+
{ error: "Token refresh not supported" },
|
|
401
|
+
{ status: 501 }
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Get refresh token from request body
|
|
406
|
+
const body = (await req.json()) as { refreshToken?: string };
|
|
407
|
+
if (!body.refreshToken) {
|
|
408
|
+
return Response.json(
|
|
409
|
+
{ error: "Missing refreshToken" },
|
|
410
|
+
{ status: 400 }
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const result = await oauth.refreshToken(body.refreshToken);
|
|
416
|
+
return Response.json(result);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
return Response.json(
|
|
419
|
+
{ error: error instanceof Error ? error.message : "Refresh failed" },
|
|
420
|
+
{ status: 500 }
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
426
|
+
// DELETE /unlink - Disconnect account
|
|
427
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
428
|
+
if (pathname.endsWith("/unlink") && req.method === "DELETE") {
|
|
429
|
+
const userId = await config.getUserIdFromRequest(req);
|
|
430
|
+
if (!userId) {
|
|
431
|
+
return new Response("Unauthorized", { status: 401 });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
await config.onUnlink(userId);
|
|
435
|
+
return new Response(undefined, { status: 204 });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return new Response("Not Found", { status: 404 });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return { handler, paths };
|
|
442
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { oc } from "@orpc/contract";
|
|
3
|
+
import type { ProcedureMetadata } from "@checkstack/common";
|
|
4
|
+
import type { Permission } from "@checkstack/common";
|
|
5
|
+
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
// Permissions
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export const pluginAdminPermissions = {
|
|
11
|
+
install: {
|
|
12
|
+
id: "plugin.install",
|
|
13
|
+
description: "Install new plugins from npm",
|
|
14
|
+
},
|
|
15
|
+
deregister: {
|
|
16
|
+
id: "plugin.deregister",
|
|
17
|
+
description: "Deregister (uninstall) plugins",
|
|
18
|
+
},
|
|
19
|
+
} as const satisfies Record<string, Permission>;
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Contract
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const _base = oc.$meta<ProcedureMetadata>({});
|
|
26
|
+
|
|
27
|
+
export const pluginAdminContract = {
|
|
28
|
+
/**
|
|
29
|
+
* Install a plugin from npm and load it across all instances.
|
|
30
|
+
*/
|
|
31
|
+
install: _base
|
|
32
|
+
.meta({
|
|
33
|
+
userType: "user",
|
|
34
|
+
permissions: [pluginAdminPermissions.install.id],
|
|
35
|
+
})
|
|
36
|
+
.input(
|
|
37
|
+
z.object({
|
|
38
|
+
packageName: z.string().min(1, "Package name is required"),
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
.output(
|
|
42
|
+
z.object({
|
|
43
|
+
success: z.boolean(),
|
|
44
|
+
pluginId: z.string(),
|
|
45
|
+
path: z.string(),
|
|
46
|
+
})
|
|
47
|
+
),
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Deregister a plugin across all instances.
|
|
51
|
+
*/
|
|
52
|
+
deregister: _base
|
|
53
|
+
.meta({
|
|
54
|
+
userType: "user",
|
|
55
|
+
permissions: [pluginAdminPermissions.deregister.id],
|
|
56
|
+
})
|
|
57
|
+
.input(
|
|
58
|
+
z.object({
|
|
59
|
+
pluginId: z.string().min(1, "Plugin ID is required"),
|
|
60
|
+
deleteSchema: z.boolean().default(false),
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
.output(z.object({ success: z.boolean() })),
|
|
64
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { ServiceRef } from "./service-ref";
|
|
3
|
+
import { ExtensionPoint } from "./extension-point";
|
|
4
|
+
import type { Permission, PluginMetadata } from "@checkstack/common";
|
|
5
|
+
import type { Hook, HookSubscribeOptions, HookUnsubscribe } from "./hooks";
|
|
6
|
+
import { Router } from "@orpc/server";
|
|
7
|
+
import { RpcContext } from "./rpc";
|
|
8
|
+
import { AnyContractRouter } from "@orpc/contract";
|
|
9
|
+
|
|
10
|
+
export type Deps = Record<string, ServiceRef<unknown>>;
|
|
11
|
+
|
|
12
|
+
// Helper to extract the T from ServiceRef<T>
|
|
13
|
+
export type ResolvedDeps<T extends Deps> = {
|
|
14
|
+
[K in keyof T]: T[K]["T"];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Helper type for database dependency injection.
|
|
19
|
+
* If schema S is provided, adds typed database; otherwise adds nothing.
|
|
20
|
+
*/
|
|
21
|
+
export type DatabaseDeps<S extends Record<string, unknown> | undefined> =
|
|
22
|
+
S extends undefined ? unknown : { database: NodePgDatabase<NonNullable<S>> };
|
|
23
|
+
|
|
24
|
+
export type PluginContext = {
|
|
25
|
+
pluginId: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Context available during the afterPluginsReady phase.
|
|
30
|
+
* Contains hook operations that are only safe after all plugins are initialized.
|
|
31
|
+
*/
|
|
32
|
+
export type AfterPluginsReadyContext = {
|
|
33
|
+
/**
|
|
34
|
+
* Subscribe to a hook. Only available in afterPluginsReady phase.
|
|
35
|
+
* @returns Unsubscribe function
|
|
36
|
+
*/
|
|
37
|
+
onHook: <T>(
|
|
38
|
+
hook: Hook<T>,
|
|
39
|
+
listener: (payload: T) => Promise<void>,
|
|
40
|
+
options?: HookSubscribeOptions
|
|
41
|
+
) => HookUnsubscribe;
|
|
42
|
+
/**
|
|
43
|
+
* Emit a hook event. Only available in afterPluginsReady phase.
|
|
44
|
+
*/
|
|
45
|
+
emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type BackendPluginRegistry = {
|
|
49
|
+
registerInit: <
|
|
50
|
+
D extends Deps,
|
|
51
|
+
S extends Record<string, unknown> | undefined = undefined
|
|
52
|
+
>(args: {
|
|
53
|
+
deps: D;
|
|
54
|
+
schema?: S;
|
|
55
|
+
/**
|
|
56
|
+
* Phase 2: Initialize the plugin.
|
|
57
|
+
* Use this to register routers, services, and set up internal state.
|
|
58
|
+
* DO NOT make RPC calls to other plugins here - use afterPluginsReady instead.
|
|
59
|
+
*/
|
|
60
|
+
init: (deps: ResolvedDeps<D> & DatabaseDeps<S>) => Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Phase 3: Called after ALL plugins have initialized.
|
|
63
|
+
* Safe to make RPC calls to other plugins and subscribe to hooks.
|
|
64
|
+
* Receives the same deps as init, plus onHook and emitHook.
|
|
65
|
+
*/
|
|
66
|
+
afterPluginsReady?: (
|
|
67
|
+
deps: ResolvedDeps<D> & DatabaseDeps<S> & AfterPluginsReadyContext
|
|
68
|
+
) => Promise<void>;
|
|
69
|
+
}) => void;
|
|
70
|
+
registerService: <S>(ref: ServiceRef<S>, impl: S) => void;
|
|
71
|
+
registerExtensionPoint: <T>(ref: ExtensionPoint<T>, impl: T) => void;
|
|
72
|
+
getExtensionPoint: <T>(ref: ExtensionPoint<T>) => T;
|
|
73
|
+
registerPermissions: (permissions: Permission[]) => void;
|
|
74
|
+
/**
|
|
75
|
+
* Registers an oRPC router and its contract for this plugin.
|
|
76
|
+
* The contract is used for OpenAPI generation.
|
|
77
|
+
*/
|
|
78
|
+
registerRouter: <C extends AnyContractRouter>(
|
|
79
|
+
router: Router<C, RpcContext>,
|
|
80
|
+
contract: C
|
|
81
|
+
) => void;
|
|
82
|
+
/**
|
|
83
|
+
* Register cleanup logic to be called when the plugin is deregistered.
|
|
84
|
+
* Multiple cleanup handlers can be registered; they run in LIFO order.
|
|
85
|
+
*/
|
|
86
|
+
registerCleanup: (cleanup: () => Promise<void>) => void;
|
|
87
|
+
pluginManager: {
|
|
88
|
+
getAllPermissions: () => { id: string; description?: string }[];
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type BackendPlugin = {
|
|
93
|
+
/**
|
|
94
|
+
* Plugin metadata containing the pluginId.
|
|
95
|
+
* This should be imported from the plugin's common package.
|
|
96
|
+
*/
|
|
97
|
+
metadata: PluginMetadata;
|
|
98
|
+
register: (env: BackendPluginRegistry) => void;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export function createBackendPlugin(config: BackendPlugin): BackendPlugin {
|
|
102
|
+
return config;
|
|
103
|
+
}
|