@girardmedia/bootspring 3.3.2 → 3.4.0
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/assets/agents/accessibility-auditor.md +39 -0
- package/assets/agents/api-designer.md +40 -0
- package/assets/agents/auth-implementer.md +64 -0
- package/assets/agents/bug-hunter.md +42 -0
- package/assets/agents/bundle-analyzer.md +40 -0
- package/assets/agents/cache-optimizer.md +55 -0
- package/assets/agents/changelog-writer.md +55 -0
- package/assets/agents/ci-cd-builder.md +40 -0
- package/assets/agents/code-explainer.md +39 -0
- package/assets/agents/code-reviewer.md +39 -0
- package/assets/agents/cost-optimizer.md +57 -0
- package/assets/agents/cron-scheduler.md +51 -0
- package/assets/agents/data-seeder.md +56 -0
- package/assets/agents/database-architect.md +40 -0
- package/assets/agents/dependency-updater.md +40 -0
- package/assets/agents/deploy-checker.md +40 -0
- package/assets/agents/docker-optimizer.md +40 -0
- package/assets/agents/documentation-writer.md +40 -0
- package/assets/agents/email-builder.md +55 -0
- package/assets/agents/env-setup.md +40 -0
- package/assets/agents/error-handler.md +40 -0
- package/assets/agents/eslint-fixer.md +46 -0
- package/assets/agents/feature-flagger.md +69 -0
- package/assets/agents/git-detective.md +39 -0
- package/assets/agents/graphql-builder.md +60 -0
- package/assets/agents/incident-responder.md +59 -0
- package/assets/agents/log-analyzer.md +39 -0
- package/assets/agents/migration-planner.md +41 -0
- package/assets/agents/monorepo-navigator.md +39 -0
- package/assets/agents/nextjs-expert.md +57 -0
- package/assets/agents/notification-builder.md +56 -0
- package/assets/agents/onboarding-guide.md +39 -0
- package/assets/agents/performance-profiler.md +40 -0
- package/assets/agents/prisma-expert.md +57 -0
- package/assets/agents/rate-limiter.md +58 -0
- package/assets/agents/react-expert.md +58 -0
- package/assets/agents/refactorer.md +42 -0
- package/assets/agents/regex-builder.md +46 -0
- package/assets/agents/release-manager.md +40 -0
- package/assets/agents/s3-manager.md +58 -0
- package/assets/agents/schema-validator.md +40 -0
- package/assets/agents/search-builder.md +62 -0
- package/assets/agents/security-auditor.md +39 -0
- package/assets/agents/sitemap-generator.md +53 -0
- package/assets/agents/stripe-integrator.md +59 -0
- package/assets/agents/tailwind-expert.md +55 -0
- package/assets/agents/tech-debt-tracker.md +39 -0
- package/assets/agents/test-writer.md +42 -0
- package/assets/agents/type-fixer.md +45 -0
- package/assets/agents/webhook-builder.md +54 -0
- package/assets/rules/cpp.md +53 -0
- package/assets/rules/css.md +52 -0
- package/assets/rules/go.md +50 -0
- package/assets/rules/html.md +52 -0
- package/assets/rules/java.md +51 -0
- package/assets/rules/kotlin.md +50 -0
- package/assets/rules/php.md +51 -0
- package/assets/rules/python.md +51 -0
- package/assets/rules/ruby.md +51 -0
- package/assets/rules/rust.md +49 -0
- package/assets/rules/shell.md +52 -0
- package/assets/rules/sql.md +49 -0
- package/assets/rules/swift.md +50 -0
- package/assets/rules/typescript.md +52 -0
- package/assets/rules/yaml-json.md +51 -0
- package/assets/skills/accessibility.md +210 -0
- package/assets/skills/agent-patterns.md +387 -0
- package/assets/skills/ai-integration.md +263 -0
- package/assets/skills/animation-patterns.md +224 -0
- package/assets/skills/api-design.md +218 -0
- package/assets/skills/api-gateway.md +341 -0
- package/assets/skills/api-versioning.md +226 -0
- package/assets/skills/astro-patterns.md +233 -0
- package/assets/skills/auth-patterns.md +248 -0
- package/assets/skills/aws-patterns.md +171 -0
- package/assets/skills/background-jobs.md +162 -0
- package/assets/skills/browser-extensions.md +309 -0
- package/assets/skills/caching-patterns.md +253 -0
- package/assets/skills/ci-cd.md +251 -0
- package/assets/skills/cli-development.md +296 -0
- package/assets/skills/code-review.md +185 -0
- package/assets/skills/cron-patterns.md +327 -0
- package/assets/skills/data-fetching.md +231 -0
- package/assets/skills/database-migrations.md +346 -0
- package/assets/skills/database-patterns.md +219 -0
- package/assets/skills/debugging.md +281 -0
- package/assets/skills/design-system.md +289 -0
- package/assets/skills/django-patterns.md +182 -0
- package/assets/skills/docker-patterns.md +235 -0
- package/assets/skills/e2e-testing.md +287 -0
- package/assets/skills/edge-computing.md +268 -0
- package/assets/skills/electron-patterns.md +266 -0
- package/assets/skills/email-templates.md +206 -0
- package/assets/skills/error-handling.md +265 -0
- package/assets/skills/event-driven.md +232 -0
- package/assets/skills/express-patterns.md +239 -0
- package/assets/skills/fastapi-patterns.md +198 -0
- package/assets/skills/feature-flags.md +212 -0
- package/assets/skills/figma-to-code.md +298 -0
- package/assets/skills/file-upload.md +228 -0
- package/assets/skills/forms-patterns.md +264 -0
- package/assets/skills/gcp-patterns.md +189 -0
- package/assets/skills/git-workflow.md +187 -0
- package/assets/skills/golang-patterns.md +185 -0
- package/assets/skills/graphql-patterns.md +244 -0
- package/assets/skills/i18n-patterns.md +172 -0
- package/assets/skills/image-processing.md +350 -0
- package/assets/skills/java-springboot.md +226 -0
- package/assets/skills/kotlin-patterns.md +207 -0
- package/assets/skills/kubernetes-patterns.md +326 -0
- package/assets/skills/laravel-patterns.md +261 -0
- package/assets/skills/llm-fine-tuning.md +335 -0
- package/assets/skills/load-testing.md +303 -0
- package/assets/skills/logging-observability.md +228 -0
- package/assets/skills/markdown-processing.md +318 -0
- package/assets/skills/mcp-server-patterns.md +292 -0
- package/assets/skills/microservices.md +272 -0
- package/assets/skills/migration-patterns.md +239 -0
- package/assets/skills/mongodb-patterns.md +189 -0
- package/assets/skills/monorepo-patterns.md +287 -0
- package/assets/skills/nextjs-app-router.md +237 -0
- package/assets/skills/notification-patterns.md +348 -0
- package/assets/skills/oauth-patterns.md +246 -0
- package/assets/skills/payment-integration.md +222 -0
- package/assets/skills/pdf-generation.md +307 -0
- package/assets/skills/performance-optimization.md +277 -0
- package/assets/skills/php-patterns.md +210 -0
- package/assets/skills/prisma-patterns.md +241 -0
- package/assets/skills/prompt-engineering.md +193 -0
- package/assets/skills/pwa-patterns.md +247 -0
- package/assets/skills/python-patterns.md +158 -0
- package/assets/skills/python-testing.md +172 -0
- package/assets/skills/queue-patterns.md +295 -0
- package/assets/skills/rag-patterns.md +159 -0
- package/assets/skills/rate-limiting.md +319 -0
- package/assets/skills/react-components.md +201 -0
- package/assets/skills/react-native-patterns.md +299 -0
- package/assets/skills/real-time-patterns.md +181 -0
- package/assets/skills/redis-patterns.md +188 -0
- package/assets/skills/refactoring.md +218 -0
- package/assets/skills/regex-patterns.md +191 -0
- package/assets/skills/remix-patterns.md +262 -0
- package/assets/skills/responsive-design.md +199 -0
- package/assets/skills/ruby-rails-patterns.md +178 -0
- package/assets/skills/rust-patterns.md +211 -0
- package/assets/skills/search-patterns.md +227 -0
- package/assets/skills/security-hardening.md +237 -0
- package/assets/skills/seo-patterns.md +179 -0
- package/assets/skills/serverless-patterns.md +223 -0
- package/assets/skills/sql-optimization.md +154 -0
- package/assets/skills/state-management.md +254 -0
- package/assets/skills/storybook-patterns.md +330 -0
- package/assets/skills/svelte-patterns.md +258 -0
- package/assets/skills/swift-patterns.md +227 -0
- package/assets/skills/tailwind-patterns.md +272 -0
- package/assets/skills/tdd-workflow.md +199 -0
- package/assets/skills/terraform-patterns.md +270 -0
- package/assets/skills/testing-react.md +240 -0
- package/assets/skills/testing-vitest.md +232 -0
- package/assets/skills/typescript-strict.md +159 -0
- package/assets/skills/video-processing.md +340 -0
- package/assets/skills/vue-patterns.md +247 -0
- package/assets/skills/web-workers.md +327 -0
- package/assets/skills/webhooks-patterns.md +283 -0
- package/assets/skills/websocket-patterns.md +306 -0
- package/dist/cli/index.js +941 -958
- package/dist/core/index.d.ts +341 -11
- package/dist/core.js +58 -95
- package/dist/mcp/index.d.ts +33 -1
- package/dist/mcp-server.js +177 -255
- package/package.json +4 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: notification-patterns
|
|
3
|
+
description: Notification patterns for push, email, SMS, in-app notifications, user preference management, and batching strategies.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Notification Patterns
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
Implement a notification system when your application needs to inform users about events through multiple channels: push notifications, email, SMS, and in-app alerts. These patterns cover channel abstraction, user preference management, batching to prevent notification fatigue, and reliable delivery with retry logic. Apply these patterns early to avoid building channel-specific silos that are hard to unify later.
|
|
10
|
+
|
|
11
|
+
## How It Works
|
|
12
|
+
|
|
13
|
+
### Notification Service Architecture
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// src/notifications/types.ts
|
|
17
|
+
export interface Notification {
|
|
18
|
+
id: string;
|
|
19
|
+
userId: string;
|
|
20
|
+
type: NotificationType;
|
|
21
|
+
title: string;
|
|
22
|
+
body: string;
|
|
23
|
+
data?: Record<string, unknown>;
|
|
24
|
+
channels: Channel[];
|
|
25
|
+
priority: 'urgent' | 'high' | 'normal' | 'low';
|
|
26
|
+
groupKey?: string; // for batching related notifications
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type NotificationType =
|
|
30
|
+
| 'order.confirmed' | 'order.shipped' | 'order.delivered'
|
|
31
|
+
| 'comment.reply' | 'comment.mention'
|
|
32
|
+
| 'billing.invoice' | 'billing.failed'
|
|
33
|
+
| 'security.login' | 'security.password_changed';
|
|
34
|
+
|
|
35
|
+
export type Channel = 'push' | 'email' | 'sms' | 'in_app';
|
|
36
|
+
|
|
37
|
+
export interface ChannelProvider {
|
|
38
|
+
send(notification: Notification, recipient: Recipient): Promise<DeliveryResult>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DeliveryResult {
|
|
42
|
+
channel: Channel;
|
|
43
|
+
success: boolean;
|
|
44
|
+
externalId?: string;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Recipient {
|
|
49
|
+
userId: string;
|
|
50
|
+
email?: string;
|
|
51
|
+
phone?: string;
|
|
52
|
+
pushTokens?: string[];
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Channel Providers
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// src/notifications/channels/email.ts
|
|
60
|
+
import { Resend } from 'resend';
|
|
61
|
+
|
|
62
|
+
const resend = new Resend(process.env.RESEND_API_KEY);
|
|
63
|
+
|
|
64
|
+
export const emailProvider: ChannelProvider = {
|
|
65
|
+
async send(notification, recipient) {
|
|
66
|
+
if (!recipient.email) {
|
|
67
|
+
return { channel: 'email', success: false, error: 'No email address' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const result = await resend.emails.send({
|
|
72
|
+
from: 'MyApp <notifications@myapp.com>',
|
|
73
|
+
to: recipient.email,
|
|
74
|
+
subject: notification.title,
|
|
75
|
+
html: renderEmailTemplate(notification),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return { channel: 'email', success: true, externalId: result.id };
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { channel: 'email', success: false, error: (err as Error).message };
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/notifications/channels/push.ts
|
|
86
|
+
import admin from 'firebase-admin';
|
|
87
|
+
|
|
88
|
+
export const pushProvider: ChannelProvider = {
|
|
89
|
+
async send(notification, recipient) {
|
|
90
|
+
if (!recipient.pushTokens?.length) {
|
|
91
|
+
return { channel: 'push', success: false, error: 'No push tokens' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const response = await admin.messaging().sendEachForMulticast({
|
|
96
|
+
tokens: recipient.pushTokens,
|
|
97
|
+
notification: { title: notification.title, body: notification.body },
|
|
98
|
+
data: notification.data as Record<string, string> ?? {},
|
|
99
|
+
android: { priority: notification.priority === 'urgent' ? 'high' : 'normal' },
|
|
100
|
+
apns: {
|
|
101
|
+
payload: { aps: { sound: notification.priority === 'urgent' ? 'default' : undefined } },
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
channel: 'push',
|
|
107
|
+
success: response.successCount > 0,
|
|
108
|
+
error: response.failureCount > 0 ? `${response.failureCount} tokens failed` : undefined,
|
|
109
|
+
};
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { channel: 'push', success: false, error: (err as Error).message };
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// src/notifications/channels/sms.ts
|
|
117
|
+
import twilio from 'twilio';
|
|
118
|
+
|
|
119
|
+
const client = twilio(process.env.TWILIO_SID, process.env.TWILIO_AUTH_TOKEN);
|
|
120
|
+
|
|
121
|
+
export const smsProvider: ChannelProvider = {
|
|
122
|
+
async send(notification, recipient) {
|
|
123
|
+
if (!recipient.phone) {
|
|
124
|
+
return { channel: 'sms', success: false, error: 'No phone number' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const msg = await client.messages.create({
|
|
129
|
+
body: `${notification.title}: ${notification.body}`,
|
|
130
|
+
from: process.env.TWILIO_PHONE_NUMBER,
|
|
131
|
+
to: recipient.phone,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return { channel: 'sms', success: true, externalId: msg.sid };
|
|
135
|
+
} catch (err) {
|
|
136
|
+
return { channel: 'sms', success: false, error: (err as Error).message };
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### User Preference Management
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// src/notifications/preferences.ts
|
|
146
|
+
interface NotificationPreferences {
|
|
147
|
+
userId: string;
|
|
148
|
+
channels: {
|
|
149
|
+
email: boolean;
|
|
150
|
+
push: boolean;
|
|
151
|
+
sms: boolean;
|
|
152
|
+
in_app: boolean;
|
|
153
|
+
};
|
|
154
|
+
types: Partial<Record<NotificationType, {
|
|
155
|
+
enabled: boolean;
|
|
156
|
+
channels?: Channel[];
|
|
157
|
+
}>>;
|
|
158
|
+
quietHours?: { start: string; end: string; timezone: string };
|
|
159
|
+
batchDigest?: 'none' | 'hourly' | 'daily';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function getEffectiveChannels(
|
|
163
|
+
userId: string,
|
|
164
|
+
type: NotificationType,
|
|
165
|
+
requestedChannels: Channel[]
|
|
166
|
+
): Promise<Channel[]> {
|
|
167
|
+
const prefs = await getPreferences(userId);
|
|
168
|
+
|
|
169
|
+
return requestedChannels.filter((channel) => {
|
|
170
|
+
// Global channel toggle
|
|
171
|
+
if (!prefs.channels[channel]) return false;
|
|
172
|
+
|
|
173
|
+
// Per-type override
|
|
174
|
+
const typePrefs = prefs.types[type];
|
|
175
|
+
if (typePrefs?.enabled === false) return false;
|
|
176
|
+
if (typePrefs?.channels && !typePrefs.channels.includes(channel)) return false;
|
|
177
|
+
|
|
178
|
+
// Quiet hours (skip push/sms, keep email/in_app)
|
|
179
|
+
if (prefs.quietHours && (channel === 'push' || channel === 'sms')) {
|
|
180
|
+
if (isInQuietHours(prefs.quietHours)) return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isInQuietHours(qh: { start: string; end: string; timezone: string }): boolean {
|
|
188
|
+
const now = new Date().toLocaleTimeString('en-US', {
|
|
189
|
+
hour12: false, timeZone: qh.timezone,
|
|
190
|
+
});
|
|
191
|
+
return now >= qh.start || now <= qh.end;
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Notification Dispatcher
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
// src/notifications/dispatcher.ts
|
|
199
|
+
import { emailProvider, pushProvider, smsProvider } from './channels';
|
|
200
|
+
|
|
201
|
+
const providers: Record<Channel, ChannelProvider> = {
|
|
202
|
+
email: emailProvider,
|
|
203
|
+
push: pushProvider,
|
|
204
|
+
sms: smsProvider,
|
|
205
|
+
in_app: inAppProvider,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export async function dispatch(notification: Notification): Promise<DeliveryResult[]> {
|
|
209
|
+
const recipient = await getRecipient(notification.userId);
|
|
210
|
+
const channels = await getEffectiveChannels(
|
|
211
|
+
notification.userId,
|
|
212
|
+
notification.type,
|
|
213
|
+
notification.channels
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (channels.length === 0) {
|
|
217
|
+
await logDelivery(notification.id, [{ channel: 'none' as Channel, success: false, error: 'All channels filtered by preferences' }]);
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const results = await Promise.allSettled(
|
|
222
|
+
channels.map(async (channel) => {
|
|
223
|
+
const provider = providers[channel];
|
|
224
|
+
const result = await provider.send(notification, recipient);
|
|
225
|
+
|
|
226
|
+
await logDelivery(notification.id, [result]);
|
|
227
|
+
return result;
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return results.map((r) =>
|
|
232
|
+
r.status === 'fulfilled' ? r.value : { channel: 'unknown' as Channel, success: false, error: (r.reason as Error).message }
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### In-App Notifications with Real-Time
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
// src/notifications/channels/in-app.ts
|
|
241
|
+
export const inAppProvider: ChannelProvider = {
|
|
242
|
+
async send(notification, recipient) {
|
|
243
|
+
// Store in database
|
|
244
|
+
const stored = await db.query(
|
|
245
|
+
`INSERT INTO notifications (id, user_id, type, title, body, data, read, created_at)
|
|
246
|
+
VALUES ($1, $2, $3, $4, $5, $6, false, NOW()) RETURNING id`,
|
|
247
|
+
[notification.id, recipient.userId, notification.type, notification.title, notification.body, JSON.stringify(notification.data)]
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Push via WebSocket/SSE
|
|
251
|
+
emitToUser(recipient.userId, 'notification:new', {
|
|
252
|
+
id: stored.rows[0].id,
|
|
253
|
+
type: notification.type,
|
|
254
|
+
title: notification.title,
|
|
255
|
+
body: notification.body,
|
|
256
|
+
createdAt: new Date().toISOString(),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return { channel: 'in_app', success: true, externalId: stored.rows[0].id };
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// API endpoints
|
|
264
|
+
app.get('/api/notifications', async (req, res) => {
|
|
265
|
+
const userId = req.userId;
|
|
266
|
+
const { cursor, limit = 20 } = req.query;
|
|
267
|
+
|
|
268
|
+
const notifications = await db.query(
|
|
269
|
+
`SELECT * FROM notifications WHERE user_id = $1
|
|
270
|
+
${cursor ? 'AND created_at < $3' : ''}
|
|
271
|
+
ORDER BY created_at DESC LIMIT $2`,
|
|
272
|
+
cursor ? [userId, limit, cursor] : [userId, limit]
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
res.json({
|
|
276
|
+
items: notifications.rows,
|
|
277
|
+
unreadCount: await getUnreadCount(userId),
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
app.post('/api/notifications/:id/read', async (req, res) => {
|
|
282
|
+
await db.query('UPDATE notifications SET read = true WHERE id = $1 AND user_id = $2',
|
|
283
|
+
[req.params.id, req.userId]);
|
|
284
|
+
res.json({ success: true });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
app.post('/api/notifications/read-all', async (req, res) => {
|
|
288
|
+
await db.query('UPDATE notifications SET read = true WHERE user_id = $1 AND read = false',
|
|
289
|
+
[req.userId]);
|
|
290
|
+
res.json({ success: true });
|
|
291
|
+
});
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Notification Batching
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
// src/notifications/batcher.ts
|
|
298
|
+
const pendingBatches = new Map<string, Notification[]>();
|
|
299
|
+
|
|
300
|
+
export function addToBatch(notification: Notification) {
|
|
301
|
+
const key = `${notification.userId}:${notification.groupKey}`;
|
|
302
|
+
const batch = pendingBatches.get(key) ?? [];
|
|
303
|
+
batch.push(notification);
|
|
304
|
+
pendingBatches.set(key, batch);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Flush batches periodically
|
|
308
|
+
setInterval(async () => {
|
|
309
|
+
for (const [key, batch] of pendingBatches.entries()) {
|
|
310
|
+
if (batch.length === 0) continue;
|
|
311
|
+
|
|
312
|
+
pendingBatches.delete(key);
|
|
313
|
+
|
|
314
|
+
if (batch.length === 1) {
|
|
315
|
+
await dispatch(batch[0]);
|
|
316
|
+
} else {
|
|
317
|
+
// Merge into digest
|
|
318
|
+
const digest: Notification = {
|
|
319
|
+
...batch[0],
|
|
320
|
+
title: `${batch.length} new ${batch[0].groupKey} notifications`,
|
|
321
|
+
body: batch.map((n) => n.title).join('\n'),
|
|
322
|
+
data: { count: batch.length, items: batch.map((n) => n.data) },
|
|
323
|
+
};
|
|
324
|
+
await dispatch(digest);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}, 5 * 60_000); // flush every 5 minutes
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Examples
|
|
331
|
+
|
|
332
|
+
| Notification Type | Channels | Priority | Batching |
|
|
333
|
+
|------------------|----------|----------|----------|
|
|
334
|
+
| `security.login` | push, email | urgent | Never |
|
|
335
|
+
| `order.shipped` | push, email, sms | high | Never |
|
|
336
|
+
| `comment.reply` | push, in_app | normal | 5-minute window |
|
|
337
|
+
| `comment.mention` | push, email, in_app | normal | 5-minute window |
|
|
338
|
+
| `billing.invoice` | email | normal | Never |
|
|
339
|
+
|
|
340
|
+
## Checklist
|
|
341
|
+
- [ ] Channel abstraction allows adding new providers without changing dispatch logic
|
|
342
|
+
- [ ] User preferences control which channels and notification types are enabled
|
|
343
|
+
- [ ] Quiet hours suppress push and SMS during configured periods
|
|
344
|
+
- [ ] In-app notifications stored in database with real-time WebSocket delivery
|
|
345
|
+
- [ ] Batching groups related notifications to prevent alert fatigue
|
|
346
|
+
- [ ] Delivery results logged for debugging and analytics
|
|
347
|
+
- [ ] Failed deliveries retried via queue with exponential backoff
|
|
348
|
+
- [ ] Unsubscribe links included in all email notifications
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oauth-patterns
|
|
3
|
+
description: OAuth 2.0 patterns with authorization code flow, PKCE, scopes, token refresh, and social login.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OAuth 2.0 Patterns
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
Apply when implementing "Sign in with Google/GitHub/etc." social login, building an API that third parties will authenticate against, or integrating with any service that uses OAuth 2.0. Always use Authorization Code flow with PKCE for web and mobile apps. Never use the Implicit flow -- it is deprecated and insecure.
|
|
10
|
+
|
|
11
|
+
## How It Works
|
|
12
|
+
|
|
13
|
+
### Authorization Code Flow with PKCE
|
|
14
|
+
|
|
15
|
+
The standard flow for web apps and SPAs:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import crypto from "crypto";
|
|
19
|
+
|
|
20
|
+
// Step 1: Generate PKCE verifier and challenge
|
|
21
|
+
function generatePKCE() {
|
|
22
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
23
|
+
const challenge = crypto
|
|
24
|
+
.createHash("sha256")
|
|
25
|
+
.update(verifier)
|
|
26
|
+
.digest("base64url");
|
|
27
|
+
return { verifier, challenge };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Step 2: Redirect user to authorization endpoint
|
|
31
|
+
app.get("/auth/login/google", (req, res) => {
|
|
32
|
+
const { verifier, challenge } = generatePKCE();
|
|
33
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
34
|
+
|
|
35
|
+
// Store verifier and state in session (not in URL)
|
|
36
|
+
req.session.pkceVerifier = verifier;
|
|
37
|
+
req.session.oauthState = state;
|
|
38
|
+
|
|
39
|
+
const params = new URLSearchParams({
|
|
40
|
+
client_id: process.env.GOOGLE_CLIENT_ID!,
|
|
41
|
+
redirect_uri: `${process.env.APP_URL}/auth/callback/google`,
|
|
42
|
+
response_type: "code",
|
|
43
|
+
scope: "openid email profile",
|
|
44
|
+
state,
|
|
45
|
+
code_challenge: challenge,
|
|
46
|
+
code_challenge_method: "S256",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Step 3: Handle the callback
|
|
53
|
+
app.get("/auth/callback/google", async (req, res) => {
|
|
54
|
+
const { code, state } = req.query;
|
|
55
|
+
|
|
56
|
+
// Verify state to prevent CSRF
|
|
57
|
+
if (state !== req.session.oauthState) {
|
|
58
|
+
return res.status(403).send("Invalid state parameter");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Exchange code for tokens
|
|
62
|
+
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
65
|
+
body: new URLSearchParams({
|
|
66
|
+
client_id: process.env.GOOGLE_CLIENT_ID!,
|
|
67
|
+
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
68
|
+
code: code as string,
|
|
69
|
+
grant_type: "authorization_code",
|
|
70
|
+
redirect_uri: `${process.env.APP_URL}/auth/callback/google`,
|
|
71
|
+
code_verifier: req.session.pkceVerifier,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const tokens = await tokenResponse.json();
|
|
76
|
+
// tokens: { access_token, refresh_token, id_token, expires_in }
|
|
77
|
+
|
|
78
|
+
// Step 4: Get user info from ID token or userinfo endpoint
|
|
79
|
+
const userInfo = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", {
|
|
80
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
81
|
+
}).then((r) => r.json());
|
|
82
|
+
|
|
83
|
+
// Step 5: Find or create user in your database
|
|
84
|
+
let user = await db.users.findByEmail(userInfo.email);
|
|
85
|
+
if (!user) {
|
|
86
|
+
user = await db.users.create({
|
|
87
|
+
email: userInfo.email,
|
|
88
|
+
name: userInfo.name,
|
|
89
|
+
avatarUrl: userInfo.picture,
|
|
90
|
+
provider: "google",
|
|
91
|
+
providerId: userInfo.sub,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Step 6: Create your own session
|
|
96
|
+
req.session.userId = user.id;
|
|
97
|
+
res.redirect("/dashboard");
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Social Login with NextAuth.js (Auth.js)
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// app/api/auth/[...nextauth]/route.ts
|
|
105
|
+
import NextAuth from "next-auth";
|
|
106
|
+
import GoogleProvider from "next-auth/providers/google";
|
|
107
|
+
import GitHubProvider from "next-auth/providers/github";
|
|
108
|
+
import { PrismaAdapter } from "@auth/prisma-adapter";
|
|
109
|
+
import { prisma } from "@/lib/prisma";
|
|
110
|
+
|
|
111
|
+
export const { handlers, auth, signIn, signOut } = NextAuth({
|
|
112
|
+
adapter: PrismaAdapter(prisma),
|
|
113
|
+
providers: [
|
|
114
|
+
GoogleProvider({
|
|
115
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
116
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
117
|
+
}),
|
|
118
|
+
GitHubProvider({
|
|
119
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
120
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
121
|
+
}),
|
|
122
|
+
],
|
|
123
|
+
callbacks: {
|
|
124
|
+
async session({ session, user }) {
|
|
125
|
+
session.user.id = user.id;
|
|
126
|
+
session.user.role = user.role;
|
|
127
|
+
return session;
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Token Refresh
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
async function refreshAccessToken(refreshToken: string): Promise<TokenSet> {
|
|
137
|
+
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
140
|
+
body: new URLSearchParams({
|
|
141
|
+
client_id: process.env.GOOGLE_CLIENT_ID!,
|
|
142
|
+
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
143
|
+
grant_type: "refresh_token",
|
|
144
|
+
refresh_token: refreshToken,
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
throw new Error("Token refresh failed -- user must re-authenticate");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return response.json();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Middleware that auto-refreshes expired tokens
|
|
156
|
+
async function ensureValidToken(req: Request): Promise<string> {
|
|
157
|
+
const session = await getSession(req);
|
|
158
|
+
if (session.accessTokenExpires > Date.now()) {
|
|
159
|
+
return session.accessToken;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const newTokens = await refreshAccessToken(session.refreshToken);
|
|
163
|
+
await updateSession(req, {
|
|
164
|
+
accessToken: newTokens.access_token,
|
|
165
|
+
accessTokenExpires: Date.now() + newTokens.expires_in * 1000,
|
|
166
|
+
refreshToken: newTokens.refresh_token ?? session.refreshToken,
|
|
167
|
+
});
|
|
168
|
+
return newTokens.access_token;
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Scopes -- Request Only What You Need
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// Minimal scopes for login
|
|
176
|
+
const LOGIN_SCOPES = "openid email profile";
|
|
177
|
+
|
|
178
|
+
// Additional scopes requested later (progressive consent)
|
|
179
|
+
const CALENDAR_SCOPES = "https://www.googleapis.com/auth/calendar.readonly";
|
|
180
|
+
const DRIVE_SCOPES = "https://www.googleapis.com/auth/drive.file";
|
|
181
|
+
|
|
182
|
+
// Request additional scopes when the user needs the feature
|
|
183
|
+
app.get("/auth/connect/calendar", (req, res) => {
|
|
184
|
+
const params = new URLSearchParams({
|
|
185
|
+
client_id: process.env.GOOGLE_CLIENT_ID!,
|
|
186
|
+
redirect_uri: `${process.env.APP_URL}/auth/callback/google`,
|
|
187
|
+
response_type: "code",
|
|
188
|
+
scope: `${LOGIN_SCOPES} ${CALENDAR_SCOPES}`,
|
|
189
|
+
access_type: "offline",
|
|
190
|
+
prompt: "consent", // force consent screen for new scopes
|
|
191
|
+
login_hint: req.user.email,
|
|
192
|
+
});
|
|
193
|
+
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Building Your Own OAuth Provider
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
// Issue authorization codes and access tokens for your API
|
|
201
|
+
import { randomBytes } from "crypto";
|
|
202
|
+
|
|
203
|
+
app.get("/oauth/authorize", async (req, res) => {
|
|
204
|
+
const { client_id, redirect_uri, scope, state, code_challenge } = req.query;
|
|
205
|
+
|
|
206
|
+
const client = await db.oauthClients.findById(client_id as string);
|
|
207
|
+
if (!client || !client.redirectUris.includes(redirect_uri as string)) {
|
|
208
|
+
return res.status(400).json({ error: "invalid_client" });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Show consent screen, then on approval:
|
|
212
|
+
const code = randomBytes(32).toString("hex");
|
|
213
|
+
await db.authCodes.create({
|
|
214
|
+
code,
|
|
215
|
+
clientId: client_id as string,
|
|
216
|
+
userId: req.user.id,
|
|
217
|
+
scope: scope as string,
|
|
218
|
+
codeChallenge: code_challenge as string,
|
|
219
|
+
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const callbackUrl = new URL(redirect_uri as string);
|
|
223
|
+
callbackUrl.searchParams.set("code", code);
|
|
224
|
+
callbackUrl.searchParams.set("state", state as string);
|
|
225
|
+
res.redirect(callbackUrl.toString());
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Examples
|
|
230
|
+
|
|
231
|
+
| Flow | When | Security |
|
|
232
|
+
|------|------|----------|
|
|
233
|
+
| Authorization Code + PKCE | Web apps, SPAs, mobile | Best -- prevents code interception |
|
|
234
|
+
| Client Credentials | Server-to-server (no user) | Service accounts |
|
|
235
|
+
| Device Authorization | Smart TV, CLI tools | Limited input devices |
|
|
236
|
+
| Refresh Token | Long-lived sessions | Silent re-auth without login |
|
|
237
|
+
|
|
238
|
+
## Checklist
|
|
239
|
+
- [ ] Authorization Code flow with PKCE used for all user-facing auth
|
|
240
|
+
- [ ] State parameter validated to prevent CSRF attacks
|
|
241
|
+
- [ ] PKCE verifier stored server-side (session), never in URL
|
|
242
|
+
- [ ] Refresh tokens stored securely (encrypted, httpOnly cookie or DB)
|
|
243
|
+
- [ ] Token refresh happens automatically before expiration
|
|
244
|
+
- [ ] Scopes requested incrementally (progressive consent)
|
|
245
|
+
- [ ] OAuth client secrets never exposed to the browser
|
|
246
|
+
- [ ] Redirect URIs validated against registered allowlist
|