@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.
@@ -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
+ }