@checkstack/notification-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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@checkstack/notification-backend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "checkstack": {
7
+ "type": "backend"
8
+ },
9
+ "scripts": {
10
+ "typecheck": "tsc --noEmit",
11
+ "generate": "drizzle-kit generate",
12
+ "lint": "bun run lint:code",
13
+ "lint:code": "eslint . --max-warnings 0",
14
+ "test": "bun test"
15
+ },
16
+ "dependencies": {
17
+ "@checkstack/notification-common": "workspace:*",
18
+ "@checkstack/backend-api": "workspace:*",
19
+ "@checkstack/signal-common": "workspace:*",
20
+ "@checkstack/queue-api": "workspace:*",
21
+ "@checkstack/auth-backend": "workspace:*",
22
+ "@checkstack/auth-common": "workspace:*",
23
+ "drizzle-orm": "^0.45.1",
24
+ "zod": "^4.2.1",
25
+ "@checkstack/common": "workspace:*"
26
+ },
27
+ "devDependencies": {
28
+ "@checkstack/drizzle-helper": "workspace:*",
29
+ "@checkstack/scripts": "workspace:*",
30
+ "@checkstack/tsconfig": "workspace:*",
31
+ "@checkstack/test-utils-backend": "workspace:*",
32
+ "@orpc/server": "^1.13.2",
33
+ "@types/node": "^20.0.0",
34
+ "drizzle-kit": "^0.31.8",
35
+ "typescript": "^5.0.0"
36
+ }
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,280 @@
1
+ import {
2
+ createBackendPlugin,
3
+ coreServices,
4
+ createExtensionPoint,
5
+ coreHooks,
6
+ type NotificationStrategy,
7
+ type RegisteredNotificationStrategy,
8
+ type NotificationStrategyRegistry,
9
+ } from "@checkstack/backend-api";
10
+ import {
11
+ permissionList,
12
+ pluginMetadata,
13
+ notificationContract,
14
+ } from "@checkstack/notification-common";
15
+ import type { PluginMetadata } from "@checkstack/common";
16
+ import { eq } from "drizzle-orm";
17
+
18
+ import * as schema from "./schema";
19
+ import { createNotificationRouter } from "./router";
20
+ import { authHooks } from "@checkstack/auth-backend";
21
+ import { createOAuthCallbackHandler } from "./oauth-callback-handler";
22
+ import { createStrategyService } from "./strategy-service";
23
+
24
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
25
+ // Extension Point
26
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27
+
28
+ export interface NotificationStrategyExtensionPoint {
29
+ /**
30
+ * Register a notification strategy.
31
+ * The strategy will be namespaced by the plugin's ID automatically.
32
+ */
33
+ addStrategy<TConfig, TUserConfig, TLayoutConfig>(
34
+ strategy: NotificationStrategy<TConfig, TUserConfig, TLayoutConfig>,
35
+ pluginMetadata: PluginMetadata
36
+ ): void;
37
+ }
38
+
39
+ export const notificationStrategyExtensionPoint =
40
+ createExtensionPoint<NotificationStrategyExtensionPoint>(
41
+ "notification.strategyExtensionPoint"
42
+ );
43
+
44
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
45
+ // Registry Implementation
46
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
47
+
48
+ /**
49
+ * Create a new notification strategy registry instance.
50
+ */
51
+ function createNotificationStrategyRegistry(): NotificationStrategyRegistry & {
52
+ getNewPermissions: () => Array<{
53
+ id: string;
54
+ description: string;
55
+ ownerPluginId: string;
56
+ }>;
57
+ } {
58
+ const strategies = new Map<
59
+ string,
60
+ RegisteredNotificationStrategy<unknown, unknown, unknown>
61
+ >();
62
+ const newPermissions: Array<{
63
+ id: string;
64
+ description: string;
65
+ ownerPluginId: string;
66
+ }> = [];
67
+
68
+ return {
69
+ register<TConfig, TUserConfig, TLayoutConfig>(
70
+ strategy: NotificationStrategy<TConfig, TUserConfig, TLayoutConfig>,
71
+ metadata: PluginMetadata
72
+ ): void {
73
+ const qualifiedId = `${metadata.pluginId}.${strategy.id}`;
74
+ const permissionId = `${metadata.pluginId}.strategy.${strategy.id}.use`;
75
+
76
+ // Cast to unknown for storage - registry stores heterogeneous strategies
77
+ const registered: RegisteredNotificationStrategy<
78
+ unknown,
79
+ unknown,
80
+ unknown
81
+ > = {
82
+ ...(strategy as NotificationStrategy<unknown, unknown, unknown>),
83
+ qualifiedId,
84
+ ownerPluginId: metadata.pluginId,
85
+ permissionId,
86
+ };
87
+
88
+ strategies.set(qualifiedId, registered);
89
+
90
+ // Track new permission for later registration
91
+ newPermissions.push({
92
+ id: permissionId,
93
+ description: `Use ${strategy.displayName} notification channel`,
94
+ ownerPluginId: metadata.pluginId,
95
+ });
96
+ },
97
+
98
+ getStrategy(
99
+ qualifiedId: string
100
+ ): RegisteredNotificationStrategy<unknown, unknown, unknown> | undefined {
101
+ return strategies.get(qualifiedId);
102
+ },
103
+
104
+ getStrategies(): RegisteredNotificationStrategy<
105
+ unknown,
106
+ unknown,
107
+ unknown
108
+ >[] {
109
+ return [...strategies.values()];
110
+ },
111
+
112
+ getStrategiesForUser(
113
+ userPermissions: Set<string>
114
+ ): RegisteredNotificationStrategy<unknown, unknown, unknown>[] {
115
+ return [...strategies.values()].filter((s) =>
116
+ userPermissions.has(s.permissionId)
117
+ );
118
+ },
119
+
120
+ getNewPermissions() {
121
+ return newPermissions;
122
+ },
123
+ };
124
+ }
125
+
126
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
127
+ // Plugin Definition
128
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
129
+
130
+ export default createBackendPlugin({
131
+ metadata: pluginMetadata,
132
+
133
+ register(env) {
134
+ // Create the strategy registry
135
+ const strategyRegistry = createNotificationStrategyRegistry();
136
+
137
+ // Register static permissions
138
+ env.registerPermissions(permissionList);
139
+
140
+ // Register the extension point
141
+ env.registerExtensionPoint(notificationStrategyExtensionPoint, {
142
+ addStrategy: (strategy, metadata) => {
143
+ strategyRegistry.register(strategy, metadata);
144
+ },
145
+ });
146
+
147
+ env.registerInit({
148
+ schema,
149
+ deps: {
150
+ logger: coreServices.logger,
151
+ rpc: coreServices.rpc,
152
+ rpcClient: coreServices.rpcClient,
153
+ config: coreServices.config,
154
+ signalService: coreServices.signalService,
155
+ },
156
+ init: async ({
157
+ logger,
158
+ database,
159
+ rpc,
160
+ rpcClient,
161
+ config,
162
+ signalService,
163
+ }) => {
164
+ logger.debug("🔔 Initializing Notification Backend...");
165
+
166
+ const db = database;
167
+ const baseUrl =
168
+ process.env.VITE_API_BASE_URL ?? "http://localhost:3000";
169
+
170
+ // Create strategy service for config management (shared with afterPluginsReady)
171
+ const strategyService = createStrategyService({
172
+ db,
173
+ configService: config,
174
+ strategyRegistry,
175
+ });
176
+
177
+ // Store for afterPluginsReady access
178
+ (
179
+ env as unknown as { strategyService: typeof strategyService }
180
+ ).strategyService = strategyService;
181
+
182
+ // Create and register the notification router with strategy registry
183
+ const router = createNotificationRouter(
184
+ db,
185
+ config,
186
+ signalService,
187
+ strategyRegistry,
188
+ rpcClient,
189
+ logger
190
+ );
191
+ rpc.registerRouter(router, notificationContract);
192
+
193
+ // Register OAuth callback handler for strategy OAuth flows
194
+ const oauthHandler = createOAuthCallbackHandler({
195
+ db,
196
+ configService: config,
197
+ strategyRegistry,
198
+ baseUrl,
199
+ });
200
+ rpc.registerHttpHandler(oauthHandler, "/oauth");
201
+
202
+ logger.debug("✅ Notification Backend initialized.");
203
+ },
204
+ afterPluginsReady: async ({ database, logger, onHook, emitHook }) => {
205
+ const db = database;
206
+
207
+ // Log registered strategies
208
+ const strategies = strategyRegistry.getStrategies();
209
+ logger.debug(
210
+ `📧 Registered ${
211
+ strategies.length
212
+ } notification strategies: ${strategies
213
+ .map((s) => s.qualifiedId)
214
+ .join(", ")}`
215
+ );
216
+
217
+ // Emit dynamic permissions for strategies
218
+ const newPermissions = strategyRegistry.getNewPermissions();
219
+ if (newPermissions.length > 0) {
220
+ logger.debug(
221
+ `🔐 Registering ${newPermissions.length} dynamic strategy permissions`
222
+ );
223
+
224
+ // Group permissions by owner plugin and emit hooks
225
+ const byPlugin = new Map<
226
+ string,
227
+ Array<{ id: string; description: string }>
228
+ >();
229
+ for (const perm of newPermissions) {
230
+ const existing = byPlugin.get(perm.ownerPluginId) ?? [];
231
+ existing.push({ id: perm.id, description: perm.description });
232
+ byPlugin.set(perm.ownerPluginId, existing);
233
+ }
234
+
235
+ // Emit permissions registered hook for each plugin's permissions
236
+ for (const [ownerPluginId, permissions] of byPlugin) {
237
+ await emitHook(coreHooks.permissionsRegistered, {
238
+ pluginId: ownerPluginId,
239
+ permissions: permissions.map((p) => ({
240
+ id: p.id,
241
+ description: p.description,
242
+ })),
243
+ });
244
+ }
245
+ }
246
+
247
+ // Subscribe to user deletion to clean up notifications and subscriptions
248
+ onHook(
249
+ authHooks.userDeleted,
250
+ async ({ userId }) => {
251
+ logger.debug(
252
+ `Cleaning up notifications for deleted user: ${userId}`
253
+ );
254
+ // Delete user notification preferences via ConfigService
255
+ const strategyService = (
256
+ env as unknown as {
257
+ strategyService: ReturnType<typeof createStrategyService>;
258
+ }
259
+ ).strategyService;
260
+ if (strategyService) {
261
+ await strategyService.deleteUserPreferences(userId);
262
+ }
263
+ // Delete subscriptions (has userId reference)
264
+ await db
265
+ .delete(schema.notificationSubscriptions)
266
+ .where(eq(schema.notificationSubscriptions.userId, userId));
267
+ // Delete notifications for this user
268
+ await db
269
+ .delete(schema.notifications)
270
+ .where(eq(schema.notifications.userId, userId));
271
+ logger.debug(`Cleaned up notifications for user: ${userId}`);
272
+ },
273
+ { mode: "work-queue", workerGroup: "user-cleanup" }
274
+ );
275
+
276
+ logger.debug("✅ Notification Backend afterPluginsReady complete.");
277
+ },
278
+ });
279
+ },
280
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * OAuth Callback Handler
3
+ *
4
+ * Handles OAuth callback redirects from external providers.
5
+ * Registered as HTTP handlers at `/api/notification/oauth/{strategyId}/callback`.
6
+ */
7
+
8
+ import type {
9
+ NotificationStrategyRegistry,
10
+ ConfigService,
11
+ } from "@checkstack/backend-api";
12
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
13
+ import { createStrategyService } from "./strategy-service";
14
+ import type * as schema from "./schema";
15
+
16
+ export interface OAuthCallbackDeps {
17
+ db: NodePgDatabase<typeof schema>;
18
+ configService: ConfigService;
19
+ strategyRegistry: NotificationStrategyRegistry;
20
+ baseUrl: string;
21
+ }
22
+
23
+ /**
24
+ * Create an OAuth callback handler that routes to the appropriate strategy.
25
+ *
26
+ * @param deps - Service dependencies
27
+ * @returns HTTP handler function for OAuth callbacks
28
+ */
29
+ export function createOAuthCallbackHandler(
30
+ deps: OAuthCallbackDeps
31
+ ): (req: Request) => Promise<Response> {
32
+ const { db, configService, strategyRegistry, baseUrl } = deps;
33
+
34
+ const strategyService = createStrategyService({
35
+ db,
36
+ configService,
37
+ strategyRegistry,
38
+ });
39
+
40
+ return async (req: Request): Promise<Response> => {
41
+ const url = new URL(req.url);
42
+
43
+ // Extract strategy ID from path: /oauth/{strategyId}/callback
44
+ const pathParts = url.pathname.split("/");
45
+ const oauthIndex = pathParts.indexOf("oauth");
46
+ if (oauthIndex === -1 || pathParts.length <= oauthIndex + 2) {
47
+ return new Response("Invalid OAuth path", { status: 400 });
48
+ }
49
+
50
+ const strategyId = pathParts[oauthIndex + 1];
51
+ const action = pathParts[oauthIndex + 2]; // "callback" or other actions
52
+
53
+ if (action !== "callback") {
54
+ return new Response(`Unknown OAuth action: ${action}`, { status: 400 });
55
+ }
56
+
57
+ // Find strategy
58
+ const strategy = strategyRegistry.getStrategy(strategyId);
59
+ if (!strategy) {
60
+ return new Response(`Strategy not found: ${strategyId}`, { status: 404 });
61
+ }
62
+
63
+ if (!strategy.oauth) {
64
+ return new Response(`Strategy ${strategyId} does not support OAuth`, {
65
+ status: 400,
66
+ });
67
+ }
68
+
69
+ const oauth = strategy.oauth;
70
+
71
+ // Get code and state from query params
72
+ const code = url.searchParams.get("code");
73
+ const state = url.searchParams.get("state");
74
+ const error = url.searchParams.get("error");
75
+
76
+ // Handle error from provider
77
+ if (error) {
78
+ const errorDescription =
79
+ url.searchParams.get("error_description") || error;
80
+ return Response.redirect(
81
+ `${baseUrl}/notification/settings?error=${encodeURIComponent(
82
+ errorDescription
83
+ )}`,
84
+ 302
85
+ );
86
+ }
87
+
88
+ if (!code || !state) {
89
+ return Response.redirect(
90
+ `${baseUrl}/notification/settings?error=${encodeURIComponent(
91
+ "Missing code or state parameter"
92
+ )}`,
93
+ 302
94
+ );
95
+ }
96
+
97
+ // Decode state
98
+ let stateData: { userId: string; returnUrl: string };
99
+ try {
100
+ const decoded = atob(state);
101
+ stateData = JSON.parse(decoded);
102
+ } catch {
103
+ return Response.redirect(
104
+ `${baseUrl}/notification/settings?error=${encodeURIComponent(
105
+ "Invalid state parameter"
106
+ )}`,
107
+ 302
108
+ );
109
+ }
110
+
111
+ const { userId, returnUrl } = stateData;
112
+ const defaultReturnUrl = "/notification/settings";
113
+ const finalReturnUrl = returnUrl || defaultReturnUrl;
114
+
115
+ try {
116
+ // Get strategy config to pass to OAuth functions
117
+ const strategyConfig = await strategyService.getStrategyConfig(
118
+ strategyId
119
+ );
120
+ if (!strategyConfig) {
121
+ return Response.redirect(
122
+ `${baseUrl}${finalReturnUrl}?error=${encodeURIComponent(
123
+ "Strategy not configured"
124
+ )}`,
125
+ 302
126
+ );
127
+ }
128
+
129
+ // Exchange code for tokens
130
+ const callbackUrl = `${baseUrl}/api/notification/oauth/${strategyId}/callback`;
131
+
132
+ // Call OAuth config functions with strategy config
133
+ const clientId = oauth.clientId(strategyConfig);
134
+ const clientSecret = oauth.clientSecret(strategyConfig);
135
+ const tokenUrl = oauth.tokenUrl(strategyConfig);
136
+
137
+ // Exchange authorization code for tokens
138
+ const tokenResponse = await fetch(tokenUrl, {
139
+ method: "POST",
140
+ headers: {
141
+ "Content-Type": "application/x-www-form-urlencoded",
142
+ Accept: "application/json",
143
+ },
144
+ body: new URLSearchParams({
145
+ grant_type: "authorization_code",
146
+ code,
147
+ redirect_uri: callbackUrl,
148
+ client_id: clientId,
149
+ client_secret: clientSecret,
150
+ }),
151
+ });
152
+
153
+ if (!tokenResponse.ok) {
154
+ const errorText = await tokenResponse.text();
155
+ console.error(`OAuth token exchange failed: ${errorText}`);
156
+ return Response.redirect(
157
+ `${baseUrl}${finalReturnUrl}?error=${encodeURIComponent(
158
+ "Token exchange failed"
159
+ )}`,
160
+ 302
161
+ );
162
+ }
163
+
164
+ const tokens = (await tokenResponse.json()) as Record<string, unknown>;
165
+
166
+ // Extract tokens using strategy's extractors or defaults
167
+ const accessToken =
168
+ oauth.extractAccessToken?.(tokens) || (tokens.access_token as string);
169
+ const refreshToken =
170
+ oauth.extractRefreshToken?.(tokens) ||
171
+ (tokens.refresh_token as string | undefined);
172
+ const expiresIn =
173
+ oauth.extractExpiresIn?.(tokens) ||
174
+ (typeof tokens.expires_in === "number" ? tokens.expires_in : undefined);
175
+
176
+ // Extract external ID using strategy's extractor
177
+ const externalId = oauth.extractExternalId(tokens);
178
+
179
+ // Calculate expiration date
180
+ const expiresAt = expiresIn
181
+ ? new Date(Date.now() + expiresIn * 1000)
182
+ : undefined;
183
+
184
+ // Store tokens via StrategyService
185
+ await strategyService.storeOAuthTokens({
186
+ userId,
187
+ strategyId,
188
+ externalId,
189
+ accessToken,
190
+ refreshToken,
191
+ expiresAt,
192
+ });
193
+
194
+ // Redirect to return URL with success
195
+ return Response.redirect(
196
+ `${baseUrl}${finalReturnUrl}?linked=${strategyId}`,
197
+ 302
198
+ );
199
+ } catch (error_) {
200
+ console.error("OAuth callback error:", error_);
201
+ return Response.redirect(
202
+ `${baseUrl}${finalReturnUrl}?error=${encodeURIComponent(
203
+ "OAuth processing failed"
204
+ )}`,
205
+ 302
206
+ );
207
+ }
208
+ };
209
+ }
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Plugin-level configuration for notification retention policy.
5
+ * This controls how long notifications are kept before automatic purging.
6
+ */
7
+ export const retentionConfigV1 = z.object({
8
+ /**
9
+ * Whether automatic purging of old notifications is enabled.
10
+ */
11
+ enabled: z
12
+ .boolean()
13
+ .default(false)
14
+ .describe("Enable auto-purging of old notifications"),
15
+
16
+ /**
17
+ * Number of days to retain notifications before purging.
18
+ */
19
+ retentionDays: z
20
+ .number()
21
+ .min(1)
22
+ .max(365)
23
+ .default(30)
24
+ .describe("Number of days to retain notifications before purging"),
25
+ });
26
+
27
+ export type RetentionConfig = z.infer<typeof retentionConfigV1>;
28
+
29
+ export const RETENTION_CONFIG_VERSION = 1;
30
+ export const RETENTION_CONFIG_ID = "notification.retention";
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from "bun:test";
2
+
3
+ /**
4
+ * Basic structural tests for notification-backend.
5
+ *
6
+ * Note: Full integration tests with mocked DB chains are complex due to
7
+ * oRPC middleware validation. These tests verify module exports and basic imports.
8
+ * More comprehensive testing should be done via integration tests with a real test DB.
9
+ */
10
+
11
+ describe("Notification Backend Module", () => {
12
+ it("exports createNotificationRouter", async () => {
13
+ const { createNotificationRouter } = await import("./router");
14
+ expect(createNotificationRouter).toBeDefined();
15
+ expect(typeof createNotificationRouter).toBe("function");
16
+ });
17
+
18
+ it("exports schema tables", async () => {
19
+ const schema = await import("./schema");
20
+ expect(schema.notifications).toBeDefined();
21
+ expect(schema.notificationGroups).toBeDefined();
22
+ expect(schema.notificationSubscriptions).toBeDefined();
23
+ });
24
+
25
+ it("exports plugin default", async () => {
26
+ const plugin = await import("./index");
27
+ expect(plugin.default).toBeDefined();
28
+ });
29
+
30
+ it("exports service functions", async () => {
31
+ const service = await import("./service");
32
+ expect(service.getUserNotifications).toBeDefined();
33
+ expect(service.getUnreadCount).toBeDefined();
34
+ expect(service.markAsRead).toBeDefined();
35
+ expect(service.subscribeToGroup).toBeDefined();
36
+ expect(service.unsubscribeFromGroup).toBeDefined();
37
+ });
38
+ });