-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpreferences-api.ts
More file actions
462 lines (424 loc) · 17.8 KB
/
Copy pathpreferences-api.ts
File metadata and controls
462 lines (424 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
/**
* Express router for notification preference endpoints.
*
* Mount on the main notification app:
* app.use("/preferences", createPreferencesRouter());
*
* Routes
* ──────
* GET /preferences/:address fetch current preferences (returns defaults if never set)
* PUT /preferences/:address replace all preferences
* PATCH /preferences/:address partial update
* DELETE /preferences/:address reset to defaults
* POST /preferences/:address/unsubscribe one-click unsubscribe (clear all channels, takes effect immediately)
* GET /preferences/:address/export export stored contact / preference data for an address (GDPR-style data export)
*/
import crypto from 'node:crypto';
import { Router, type Request, type Response } from 'express';
import { preferencesService } from './preferences';
import type { NotificationFrequency, QuietHours, TriggerPreference } from './preferences';
import type { SubscriptionChannel, NotificationTrigger } from './types';
import { getSubscriptionsByAddress, getWebhookDeliveryLogs, getDb } from './db';
// Stellar public-key shape: `G` + 55 base32 chars (RFC 4648 alphabet
// A-Z, 2-7). Used to validate address-shaped URL params before any DB
// query or header composition, so a malformed value cannot reach the
// `Content-Disposition` header or the export route's SQL.
const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/;
/**
* Read the configured HMAC secret for unsubscribe tokens. Strict — we
* require an explicit env var because the secret cannot be derived from any
* other deploy-time value (a SQLite DB path, for example, is not a secret).
*/
function unsubscribeSecret(): string {
const s = process.env.PREFERENCES_UNSUBSCRIBE_SECRET;
if (!s || s.length < 16) {
throw new Error(
'PREFERENCES_UNSUBSCRIBE_SECRET must be set to a high-entropy value ' +
'(>=16 chars) before minting or redeeming unsubscribe tokens. ' +
'Set this in your deployment environment; the service refuses to ' +
'operate with a guessed/derived secret.'
);
}
return s;
}
/**
* Mint a single-use, signed, one-click unsubscribe token for `address`.
* The token takes effect immediately on redemption (no email confirmation loop).
*/
export function mintUnsubscribeToken(address: string): string {
const secret = unsubscribeSecret();
const nonce = crypto.randomBytes(16).toString('hex');
const sig = crypto.createHmac('sha256', secret).update(`${address}.${nonce}`).digest('hex');
return `${Buffer.from(address).toString('base64url')}.${nonce}.${sig}`;
}
/**
* Verify an unsubscribe token and return the address it is bound to.
* Does **not** mark the nonce as consumed — callers must additionally
* `INSERT OR IGNORE` into `redeemed_unsubscribe_tokens` and reject on conflict
* to actually enforce single-use semantics.
*/
export function verifyUnsubscribeToken(token: string): string | null {
const secret = unsubscribeSecret();
const parts = token.split('.');
if (parts.length !== 3) return null;
const [addressB64, nonce, sig] = parts;
let address: string;
try {
address = Buffer.from(addressB64, 'base64url').toString('utf8');
} catch {
return null;
}
const expected = crypto.createHmac('sha256', secret).update(`${address}.${nonce}`).digest('hex');
// Hex decode — `Buffer.from(str, "hex")` silently truncates on bad input,
// so we guard the length before the constant-time compare (timingSafeEqual
// would otherwise throw on length-mismatched buffers).
const a = Buffer.from(sig, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length || a.length === 0) return null;
if (!crypto.timingSafeEqual(a, b)) return null;
return address;
}
/**
* Helper for the single-use check: returns `true` if `nonce` was inserted
* now, `false` if it had already been redeemed.
*/
export function tryMarkNonceConsumed(nonce: string): boolean {
try {
const result = getDb()
.prepare(
`INSERT OR IGNORE INTO redeemed_unsubscribe_tokens (nonce, redeemed_at)
VALUES (?, ?)`
)
.run(nonce, Date.now());
return result.changes > 0;
} catch {
// If the table hasn't been created yet (e.g. legacy deployments), fall
// back to accepting the redeem so we don't accidentally break the link.
return true;
}
}
/**
* Fetch all data the notifications service has stored for the given Stellar
* address. Used to fulfill a user-initiated GDPR-style data export request.
*
* Returns:
* - preferences: the user's current notification preference record (or defaults)
* - subscriptions: every active notification subscription row for the address
* - sentLog: chronologically recent deliveries to this address (capped at `limit`)
* - sentLogUnavailable: `true` if the `sent_notifications` table could not be queried;
* callers can decide whether to treat the partial response as a fail
* - webhookLogsAvailable: `false` if the `webhook_delivery_logs` query failed for any reason
* - exportedAt: ISO 8601 timestamp of when the export was generated
* - schemaVersion: bump this when the export payload shape changes
*/
function exportAddressData(address: string, limit = 100) {
const preferences = preferencesService.get(address);
// Delivery history straight from the DB. Surface a `sentLogUnavailable`
// flag rather than swallowing an empty array so callers can see when the
// export is partial.
let sentLog: Array<{
invoice_id: number;
trigger: string;
channel: string;
destination: string;
sent_at: number;
}> = [];
let sentLogUnavailable = false;
try {
const rows = getDb()
.prepare(
`SELECT invoice_id, trigger, channel, destination, sent_at
FROM sent_notifications
WHERE recipient_address = ?
OR destination IN (
SELECT destination FROM subscriptions WHERE stellar_address = ?
)
ORDER BY sent_at DESC
LIMIT ?`
)
.all(address, address, limit);
sentLog = rows as typeof sentLog;
} catch {
sentLogUnavailable = true;
}
// Active subscriptions.
const subscriptions = getSubscriptionsByAddress(address).map((s) => ({
id: s.id,
channel: s.channel,
destination: s.destination,
triggers: s.triggers,
created_at: s.created_at,
}));
return {
schemaVersion: 1,
exportedAt: new Date().toISOString(),
address,
preferences,
subscriptions,
sentLog,
sentLogUnavailable,
};
}
/**
* Read the persisted record of webhook delivery logs for a subscription
* belonging to `address`. Surfaces `available: false` if any underlying
* query fails instead of silently returning an empty array.
*/
function exportWebhookLogsForAddress(
address: string,
limit = 100
): {
available: boolean;
entries: Array<{
subscription_id: number;
destination: string;
logs: ReturnType<typeof getWebhookDeliveryLogs>;
}>;
} {
try {
const subs = getSubscriptionsByAddress(address).filter((s) => s.channel === 'webhook');
return {
available: true,
entries: subs.map((s) => ({
subscription_id: s.id,
destination: s.destination,
logs: getWebhookDeliveryLogs(s.id).slice(0, limit),
})),
};
} catch {
return { available: false, entries: [] };
}
}
// ── Validation helpers ─────────────────────────────────────────────────────
const VALID_CHANNELS: SubscriptionChannel[] = ['email', 'sms', 'webhook', 'websocket'];
const VALID_FREQUENCIES: NotificationFrequency[] = ['realtime', 'daily', 'weekly'];
const VALID_TRIGGERS: NotificationTrigger[] = [
'invoice_funded',
'invoice_paid',
'invoice_defaulted',
'invoice_due_soon',
'invoice_overdue',
];
function isValidChannels(v: unknown): v is SubscriptionChannel[] {
return Array.isArray(v) && v.every((c) => VALID_CHANNELS.includes(c as SubscriptionChannel));
}
function isValidFrequency(v: unknown): v is NotificationFrequency {
return VALID_FREQUENCIES.includes(v as NotificationFrequency);
}
function isValidQuietHours(v: unknown): v is QuietHours | null {
if (v === null) return true;
if (typeof v !== 'object' || v === null) return false;
const q = v as Record<string, unknown>;
return (
typeof q.startHour === 'number' &&
q.startHour >= 0 &&
q.startHour <= 23 &&
typeof q.endHour === 'number' &&
q.endHour >= 0 &&
q.endHour <= 23 &&
typeof q.timezone === 'string' &&
q.timezone.length > 0
);
}
function isValidTriggerPreferences(v: unknown): v is TriggerPreference[] {
if (!Array.isArray(v)) return false;
return v.every((item) => {
if (typeof item !== 'object' || item === null) return false;
const p = item as Record<string, unknown>;
return (
VALID_TRIGGERS.includes(p.trigger as NotificationTrigger) &&
typeof p.enabled === 'boolean' &&
isValidChannels(p.channels)
);
});
}
function reject(res: Response, msg: string): Response {
return res.status(400).json({ error: msg });
}
// ── Router ─────────────────────────────────────────────────────────────────
export function createPreferencesRouter(): Router {
const router = Router();
// GET /preferences/:address
router.get('/:address', (req: Request, res: Response) => {
const prefs = preferencesService.get(req.params.address);
res.json({ preferences: prefs });
});
// PUT /preferences/:address — full replacement
router.put('/:address', (req: Request, res: Response) => {
const body = req.body as Record<string, unknown>;
const { enabledChannels, frequency, quietHours = null, triggerPreferences = [] } = body;
if (!isValidChannels(enabledChannels))
return reject(res, `enabledChannels must be an array of: ${VALID_CHANNELS.join(', ')}`);
if (!isValidFrequency(frequency))
return reject(res, `frequency must be one of: ${VALID_FREQUENCIES.join(', ')}`);
if (!isValidQuietHours(quietHours))
return reject(res, 'quietHours must be null or { startHour, endHour, timezone }');
if (!isValidTriggerPreferences(triggerPreferences))
return reject(res, 'triggerPreferences must be an array of { trigger, enabled, channels }');
const updated = preferencesService.upsert(req.params.address, {
enabledChannels,
frequency,
quietHours: quietHours as QuietHours | null,
triggerPreferences: triggerPreferences as TriggerPreference[],
});
res.json({ preferences: updated });
});
// PATCH /preferences/:address — partial update
router.patch('/:address', (req: Request, res: Response) => {
const body = req.body as Record<string, unknown>;
const patch: Parameters<typeof preferencesService.upsert>[1] = {};
if ('enabledChannels' in body) {
if (!isValidChannels(body.enabledChannels))
return reject(res, `enabledChannels must be an array of: ${VALID_CHANNELS.join(', ')}`);
patch.enabledChannels = body.enabledChannels as SubscriptionChannel[];
}
if ('frequency' in body) {
if (!isValidFrequency(body.frequency))
return reject(res, `frequency must be one of: ${VALID_FREQUENCIES.join(', ')}`);
patch.frequency = body.frequency as NotificationFrequency;
}
if ('quietHours' in body) {
if (!isValidQuietHours(body.quietHours))
return reject(res, 'quietHours must be null or { startHour, endHour, timezone }');
patch.quietHours = body.quietHours as QuietHours | null;
}
if ('triggerPreferences' in body) {
if (!isValidTriggerPreferences(body.triggerPreferences))
return reject(res, 'triggerPreferences must be an array of { trigger, enabled, channels }');
patch.triggerPreferences = body.triggerPreferences as TriggerPreference[];
}
const updated = preferencesService.upsert(req.params.address, patch);
res.json({ preferences: updated });
});
// DELETE /preferences/:address — reset to defaults
router.delete('/:address', (req: Request, res: Response) => {
preferencesService.delete(req.params.address);
res.status(204).end();
});
// POST /preferences/:address/unsubscribe — one-click unsubscribe.
// Immediately disables every delivery channel and disables every trigger so
// no further notifications will be sent to this address until preferences
// are explicitly re-enabled via PUT/PATCH. This satisfies the requirement
// that unsubscribe takes effect immediately, not on the next digest window.
router.post('/:address/unsubscribe', (req: Request, res: Response) => {
const address = req.params.address;
if (!STELLAR_ADDRESS_RE.test(address)) {
return res.status(400).json({
error: 'address must be a valid Stellar G-address (56 base32 chars).',
});
}
// Idempotent: re-setting enabledChannels=[] is a safe no-op. Doing this
// BEFORE any DB nonce write means that a crash between steps leaves state
// already consistent (channels off) instead of burning a single-use token.
const updated = preferencesService.upsert(address, {
enabledChannels: [],
triggerPreferences: [],
});
return res.json({
success: true,
preferences: updated,
message:
'All notification channels disabled. You will not receive further notifications at this address until you re-enable them.',
});
});
// POST /preferences/unsubscribe/token/:token — token-based one-click link.
// This is the endpoint that the unsubscribe link embedded in every outbound
// email hits. The token is minted with mintUnsubscribeToken() and signed
// with HMAC so it cannot be forged. Each token is single-use: the (nonce)
// is recorded in `redeemed_unsubscribe_tokens` after a successful verify,
// and a replay returns HTTP 409.
router.post('/unsubscribe/token/:token', (req: Request, res: Response) => {
let address: string | null = null;
let nonce: string | null = null;
try {
const parts = req.params.token.split('.');
if (parts.length === 3) {
address = verifyUnsubscribeToken(req.params.token);
nonce = parts[1];
}
} catch (e) {
// Missing PREFERENCES_UNSUBSCRIBE_SECRET in this deploy — surface 503
// so operators know the link won't take effect rather than fail open.
return res.status(503).json({
success: false,
error:
'Unsubscribe service is not configured. Set PREFERENCES_UNSUBSCRIBE_SECRET on the deployment.',
});
}
if (!address || !nonce) {
return res.status(400).json({
success: false,
error: 'Invalid or expired unsubscribe token.',
});
}
if (!tryMarkNonceConsumed(nonce)) {
return res.status(409).json({
success: false,
error:
'This unsubscribe link has already been redeemed. Use the preferences page to update your settings directly.',
});
}
const updated = preferencesService.upsert(address, {
enabledChannels: [],
triggerPreferences: [],
});
// Return a minimal HTML acknowledgement so an `Accept: text/html`
// browser click on the unsubscribe link shows a real status page rather
// than raw JSON. The JSON body is still correct for API consumers.
if (req.accepts(['html', 'json']) === 'html') {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
return res.send(
`<!doctype html><html lang="en"><head><meta charset="utf-8" />` +
`<title>Unsubscribed — ILN</title></head><body style="font-family:Arial,sans-serif;max-width:520px;margin:80px auto;padding:24px;color:#1f2937">` +
`<h1 style="margin:0 0 12px">You have been unsubscribed</h1>` +
`<p>The Stellar address <strong>${escapeHtml(
address
)}</strong> will not receive further ILN notifications.</p>` +
`<p>You can <a href="https://iln.finance/preferences/${encodeURIComponent(
address
)}">re-enable notifications</a> at any time.</p>` +
`</body></html>`
);
}
return res.json({
success: true,
preferences: updated,
message: 'Unsubscribed. Token has been redeemed.',
});
});
// GET /preferences/:address/export — full data export for an address.
// Returns preferences, active subscriptions, and the recent delivery log.
// Designed to satisfy GDPR / data-export requests in under one API call.
router.get('/:address/export', (req: Request, res: Response) => {
const address = req.params.address;
// Defense in depth — Stellar addresses are tightly constrained.
// Reject malformed inputs before composing the Content-Disposition
// header so an attacker cannot inject CRLF or quotes into response
// headers.
if (!STELLAR_ADDRESS_RE.test(address)) {
return res.status(400).json({
error: 'address must be a valid Stellar G-address (56 base32 chars).',
});
}
const rawLimit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 100;
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 1000) : 100;
const payload = exportAddressData(address, limit);
const webhookLogs = exportWebhookLogsForAddress(address, limit);
res.setHeader('Content-Disposition', `attachment; filename="iln-preferences-${address}.json"`);
return res.json({
...payload,
webhookLogsAvailable: webhookLogs.available,
webhookLogs: webhookLogs.entries,
});
});
return router;
}
/** Minimal HTML escape for the unsubscribe acknowledgement page. */
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}