@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/CHANGELOG.md +133 -0
- package/drizzle/0000_tan_stryfe.sql +28 -0
- package/drizzle/0001_futuristic_apocalypse.sql +11 -0
- package/drizzle/0002_early_the_spike.sql +3 -0
- package/drizzle/0003_tiny_wendell_vaughn.sql +1 -0
- package/drizzle/0004_regular_corsair.sql +4 -0
- package/drizzle/meta/0000_snapshot.json +188 -0
- package/drizzle/meta/0001_snapshot.json +260 -0
- package/drizzle/meta/0002_snapshot.json +278 -0
- package/drizzle/meta/0003_snapshot.json +188 -0
- package/drizzle/meta/0004_snapshot.json +188 -0
- package/drizzle/meta/_journal.json +41 -0
- package/drizzle.config.ts +8 -0
- package/package.json +37 -0
- package/src/index.ts +280 -0
- package/src/oauth-callback-handler.ts +209 -0
- package/src/retention-config.ts +30 -0
- package/src/router.test.ts +38 -0
- package/src/router.ts +1090 -0
- package/src/schema.ts +54 -0
- package/src/service.ts +216 -0
- package/src/strategy-service.test.ts +478 -0
- package/src/strategy-service.ts +551 -0
- package/tsconfig.json +6 -0
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
|
+
});
|