@meith/notifications 0.16.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/src/push.ts ADDED
@@ -0,0 +1,236 @@
1
+ import { createCipheriv, hkdfSync, randomBytes } from 'node:crypto'
2
+
3
+ export const PUSH_PAYLOAD_LIMIT = 3000
4
+
5
+ export const PUSH_RECORD_SIZE = 4096
6
+
7
+ export const PUSH_REQUEST_TIMEOUT_MS = 10_000
8
+
9
+ export const PUSH_TTL_SECONDS = 86_400
10
+
11
+ const VAPID_EXPIRY_SECONDS = 12 * 60 * 60
12
+
13
+ const CURVE: EcKeyImportParams = { name: 'ECDH', namedCurve: 'P-256' }
14
+
15
+ const SIGNING: EcKeyImportParams = { name: 'ECDSA', namedCurve: 'P-256' }
16
+
17
+ export interface VapidKeyPair {
18
+ readonly publicKey: string
19
+ readonly privateKey: string
20
+ }
21
+
22
+ export interface VapidDetails extends VapidKeyPair {
23
+ readonly subject: string
24
+ }
25
+
26
+ export interface PushSubscriptionKeys {
27
+ readonly endpoint: string
28
+ readonly p256dh: string
29
+ readonly auth: string
30
+ }
31
+
32
+ export type PushSendOutcome = 'sent' | 'gone' | 'failed'
33
+
34
+ export interface PushSendResult {
35
+ readonly outcome: PushSendOutcome
36
+ readonly status: number | null
37
+ readonly error: string | null
38
+ }
39
+
40
+ function decode(value: string): Buffer {
41
+ return Buffer.from(value, 'base64url')
42
+ }
43
+
44
+ function encode(value: ArrayBuffer | Uint8Array): string {
45
+ return Buffer.from(value instanceof Uint8Array ? value : new Uint8Array(value)).toString(
46
+ 'base64url',
47
+ )
48
+ }
49
+
50
+ function bytes(value: Buffer): Uint8Array<ArrayBuffer> {
51
+ return new Uint8Array(value)
52
+ }
53
+
54
+ export async function generateVapidKeys(): Promise<VapidKeyPair> {
55
+ const pair = await crypto.subtle.generateKey(SIGNING, true, ['sign', 'verify'])
56
+ const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
57
+ const raw = await crypto.subtle.exportKey('raw', pair.publicKey)
58
+
59
+ if (jwk.d === undefined) throw new Error('The generated key carries no private scalar.')
60
+
61
+ return { publicKey: encode(raw), privateKey: jwk.d }
62
+ }
63
+
64
+ export function isVapidPublicKey(value: string): boolean {
65
+ const raw = decode(value)
66
+ return raw.length === 65 && raw[0] === 4
67
+ }
68
+
69
+ export function isVapidPrivateKey(value: string): boolean {
70
+ return decode(value).length === 32
71
+ }
72
+
73
+ function jwkFor(keys: VapidKeyPair): JsonWebKey {
74
+ const raw = decode(keys.publicKey)
75
+ if (raw.length !== 65 || raw[0] !== 4) {
76
+ throw new Error('A VAPID public key is a 65-byte uncompressed P-256 point.')
77
+ }
78
+
79
+ return {
80
+ kty: 'EC',
81
+ crv: 'P-256',
82
+ x: raw.subarray(1, 33).toString('base64url'),
83
+ y: raw.subarray(33, 65).toString('base64url'),
84
+ d: keys.privateKey,
85
+ ext: true,
86
+ }
87
+ }
88
+
89
+ export function pushAudience(endpoint: string): string {
90
+ const url = new URL(endpoint)
91
+ return `${url.protocol}//${url.host}`
92
+ }
93
+
94
+ export async function vapidAuthorization(
95
+ vapid: VapidDetails,
96
+ endpoint: string,
97
+ now: Date = new Date(),
98
+ ): Promise<string> {
99
+ const header = encode(Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'ES256' })))
100
+ const claims = encode(
101
+ Buffer.from(
102
+ JSON.stringify({
103
+ aud: pushAudience(endpoint),
104
+ exp: Math.floor(now.getTime() / 1000) + VAPID_EXPIRY_SECONDS,
105
+ sub: vapid.subject,
106
+ }),
107
+ ),
108
+ )
109
+
110
+ const key = await crypto.subtle.importKey('jwk', jwkFor(vapid), SIGNING, false, ['sign'])
111
+ const signature = await crypto.subtle.sign(
112
+ { name: 'ECDSA', hash: 'SHA-256' },
113
+ key,
114
+ bytes(Buffer.from(`${header}.${claims}`)),
115
+ )
116
+
117
+ return `vapid t=${header}.${claims}.${encode(signature)}, k=${vapid.publicKey}`
118
+ }
119
+
120
+ export interface EncryptedPushPayload {
121
+ readonly body: Buffer
122
+ readonly salt: Buffer
123
+ readonly serverPublicKey: Buffer
124
+ }
125
+
126
+ export async function encryptPushPayload(
127
+ subscription: Pick<PushSubscriptionKeys, 'p256dh' | 'auth'>,
128
+ payload: string,
129
+ salt: Buffer = randomBytes(16),
130
+ ): Promise<EncryptedPushPayload> {
131
+ const plaintext = Buffer.from(payload, 'utf8')
132
+ if (plaintext.byteLength > PUSH_PAYLOAD_LIMIT) {
133
+ throw new Error(`A push payload may not exceed ${PUSH_PAYLOAD_LIMIT} bytes.`)
134
+ }
135
+
136
+ const clientPublic = decode(subscription.p256dh)
137
+ const authSecret = decode(subscription.auth)
138
+
139
+ const client = await crypto.subtle.importKey('raw', bytes(clientPublic), CURVE, false, [])
140
+ const server = await crypto.subtle.generateKey(CURVE, true, ['deriveBits'])
141
+ const serverPublic = Buffer.from(await crypto.subtle.exportKey('raw', server.publicKey))
142
+
143
+ const shared = Buffer.from(
144
+ await crypto.subtle.deriveBits({ name: 'ECDH', public: client }, server.privateKey, 256),
145
+ )
146
+
147
+ const keyInfo = Buffer.concat([
148
+ Buffer.from('WebPush: info\0', 'utf8'),
149
+ clientPublic,
150
+ serverPublic,
151
+ ])
152
+
153
+ const ikm = Buffer.from(hkdfSync('sha256', shared, authSecret, keyInfo, 32))
154
+ const key = Buffer.from(
155
+ hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: aes128gcm\0', 'utf8'), 16),
156
+ )
157
+ const nonce = Buffer.from(
158
+ hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: nonce\0', 'utf8'), 12),
159
+ )
160
+
161
+ const cipher = createCipheriv('aes-128-gcm', key, nonce)
162
+ const sealed = Buffer.concat([
163
+ cipher.update(Buffer.concat([plaintext, Buffer.from([2])])),
164
+ cipher.final(),
165
+ cipher.getAuthTag(),
166
+ ])
167
+
168
+ const header = Buffer.alloc(5)
169
+ header.writeUInt32BE(PUSH_RECORD_SIZE, 0)
170
+ header.writeUInt8(serverPublic.length, 4)
171
+
172
+ return {
173
+ body: Buffer.concat([salt, header, serverPublic, sealed]),
174
+ salt,
175
+ serverPublicKey: serverPublic,
176
+ }
177
+ }
178
+
179
+ export function pushOutcomeFor(status: number): PushSendOutcome {
180
+ if (status >= 200 && status < 300) return 'sent'
181
+ if (status === 404 || status === 410) return 'gone'
182
+ return 'failed'
183
+ }
184
+
185
+ export async function sendWebPush(input: {
186
+ readonly subscription: PushSubscriptionKeys
187
+ readonly payload: string
188
+ readonly vapid: VapidDetails
189
+ readonly ttlSeconds?: number
190
+ readonly now?: Date
191
+ readonly fetchImpl?: typeof fetch
192
+ }): Promise<PushSendResult> {
193
+ const doFetch = input.fetchImpl ?? fetch
194
+
195
+ let encrypted: EncryptedPushPayload
196
+ let authorization: string
197
+ try {
198
+ encrypted = await encryptPushPayload(input.subscription, input.payload)
199
+ authorization = await vapidAuthorization(
200
+ input.vapid,
201
+ input.subscription.endpoint,
202
+ input.now ?? new Date(),
203
+ )
204
+ } catch (err) {
205
+ return { outcome: 'failed', status: null, error: message(err) }
206
+ }
207
+
208
+ try {
209
+ const response = await doFetch(input.subscription.endpoint, {
210
+ method: 'POST',
211
+ headers: {
212
+ authorization,
213
+ 'content-encoding': 'aes128gcm',
214
+ 'content-type': 'application/octet-stream',
215
+ ttl: String(input.ttlSeconds ?? PUSH_TTL_SECONDS),
216
+ urgency: 'normal',
217
+ },
218
+ body: bytes(encrypted.body),
219
+ redirect: 'manual',
220
+ signal: AbortSignal.timeout(PUSH_REQUEST_TIMEOUT_MS),
221
+ })
222
+
223
+ const outcome = pushOutcomeFor(response.status)
224
+ return {
225
+ outcome,
226
+ status: response.status,
227
+ error: outcome === 'sent' ? null : `HTTP ${response.status}`,
228
+ }
229
+ } catch (err) {
230
+ return { outcome: 'failed', status: null, error: message(err) }
231
+ }
232
+ }
233
+
234
+ function message(err: unknown): string {
235
+ return err instanceof Error ? err.message : String(err)
236
+ }
package/src/render.ts ADDED
@@ -0,0 +1,275 @@
1
+ import { EN_CATALOG, sourceTranslator, type Translator } from '@meith/i18n'
2
+
3
+ import { notificationKind } from './kinds'
4
+ import type { NotificationData, NotificationRecord } from './types'
5
+
6
+ export interface NotificationView {
7
+ readonly id: number
8
+ readonly subject: string
9
+ readonly body: string
10
+ readonly href: string | null
11
+ readonly kind: string
12
+ readonly occurrences: number
13
+ readonly createdAt: Date
14
+ readonly updatedAt: Date
15
+ readonly isRead: boolean
16
+ readonly unsubscribeToken: string | null
17
+ }
18
+
19
+ function objects(data: NotificationData, key: string): readonly NotificationData[] {
20
+ const value = data[key]
21
+ if (!Array.isArray(value)) return []
22
+ return value.filter(
23
+ (entry): entry is NotificationData =>
24
+ entry !== null && typeof entry === 'object' && !Array.isArray(entry),
25
+ )
26
+ }
27
+
28
+ function str(data: NotificationData, key: string, fallback = ''): string {
29
+ const value = data[key]
30
+ if (typeof value === 'string') return value
31
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
32
+ return fallback
33
+ }
34
+
35
+ function num(data: NotificationData, key: string, fallback = 0): number {
36
+ const value = data[key]
37
+ if (typeof value === 'number' && Number.isFinite(value)) return value
38
+ if (typeof value === 'string') {
39
+ const parsed = Number(value)
40
+ if (Number.isFinite(parsed)) return parsed
41
+ }
42
+ return fallback
43
+ }
44
+
45
+ function args(data: NotificationData, key: string): Readonly<Record<string, string | number>> {
46
+ const raw = str(data, key)
47
+ if (raw === '') return {}
48
+
49
+ try {
50
+ const value: unknown = JSON.parse(raw)
51
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
52
+ return Object.fromEntries(
53
+ Object.entries(value).filter(
54
+ (entry): entry is [string, string | number] =>
55
+ typeof entry[1] === 'string' || typeof entry[1] === 'number',
56
+ ),
57
+ )
58
+ } catch {
59
+ return {}
60
+ }
61
+ }
62
+
63
+ function pluginMessage(
64
+ data: NotificationData,
65
+ textKey: string,
66
+ argsKey: string,
67
+ legacyKey: string,
68
+ t: Translator,
69
+ ): string {
70
+ const key = str(data, textKey)
71
+ return key === '' ? str(data, legacyKey) : t.t(key, args(data, argsKey))
72
+ }
73
+
74
+ function points(t: Translator, value: number): string {
75
+ return t.t('notification.render.points', { count: value })
76
+ }
77
+
78
+ const DIGEST_CADENCE_KEYS = {
79
+ daily: 'notification.render.digest.daily',
80
+ weekly: 'notification.render.digest.weekly',
81
+ } as const
82
+
83
+ const TEMPLATES: Readonly<
84
+ Record<string, (data: NotificationData, t: Translator) => { subject: string; body: string }>
85
+ > = {
86
+ 'warning.received': (data, t) => {
87
+ const title = str(data, 'title', t.t('notification.render.warning.fallbackTitle'))
88
+ const reason = str(data, 'reason')
89
+ const total = num(data, 'totalPoints')
90
+ const restriction = str(data, 'restriction')
91
+
92
+ const consequence =
93
+ restriction === 'suspend_posting'
94
+ ? ` ${t.t('notification.render.warning.suspendPosting')}`
95
+ : restriction === 'moderate_posting'
96
+ ? ` ${t.t('notification.render.warning.moderatePosting')}`
97
+ : restriction === 'ban'
98
+ ? ` ${t.t('notification.render.warning.banned')}`
99
+ : ''
100
+
101
+ return {
102
+ subject: t.t('notification.render.warning.subject', { title }),
103
+ body:
104
+ t.t('notification.render.warning.body', {
105
+ points: points(t, num(data, 'points')),
106
+ total: points(t, total),
107
+ }) +
108
+ consequence +
109
+ (reason === '' ? '' : `\n\n${t.t('notification.render.warning.reason', { reason })}`),
110
+ }
111
+ },
112
+
113
+ 'report.actioned': (data, t) => {
114
+ const outcome = str(data, 'outcome')
115
+ const label = str(data, 'targetLabel', t.t('notification.render.report.fallbackLabel'))
116
+ return {
117
+ subject:
118
+ outcome === 'rejected'
119
+ ? t.t('notification.render.report.rejectedSubject')
120
+ : t.t('notification.render.report.actionedSubject'),
121
+ body:
122
+ outcome === 'rejected'
123
+ ? t.t('notification.render.report.rejectedBody', { label })
124
+ : t.t('notification.render.report.actionedBody', { label }),
125
+ }
126
+ },
127
+
128
+ 'subscription.reply': (data, t) => {
129
+ const title = str(data, 'threadTitle', t.t('notification.render.reply.fallbackTitle'))
130
+ const posts = num(data, 'posts', 1)
131
+ const author = str(data, 'lastAuthor')
132
+
133
+ return {
134
+ subject: t.t('notification.render.reply.subject', { count: posts, title }),
135
+ body:
136
+ author === ''
137
+ ? t.t('notification.render.reply.noAuthor')
138
+ : t.t('notification.render.reply.author', { author }),
139
+ }
140
+ },
141
+
142
+ 'subscription.digest': (data, t) => {
143
+ const cadence = str(data, 'cadence') === 'weekly' ? 'weekly' : 'daily'
144
+ const threadCount = num(data, 'threadCount')
145
+ const postCount = num(data, 'postCount')
146
+ const more = num(data, 'more')
147
+
148
+ const lines = objects(data, 'threads').map((thread) => {
149
+ const posts = num(thread, 'posts', 1)
150
+ const author = str(thread, 'lastAuthor')
151
+ return t.t('notification.render.digest.entry', {
152
+ title: str(thread, 'title', t.t('notification.render.digest.fallbackTitle')),
153
+ count: posts,
154
+ author: author === '' ? '' : t.t('notification.render.digest.entryAuthor', { author }),
155
+ })
156
+ })
157
+
158
+ if (more > 0) lines.push(t.t('notification.render.digest.more', { count: more }))
159
+
160
+ return {
161
+ subject: t.t('notification.render.digest.subject', {
162
+ cadence: t.t(DIGEST_CADENCE_KEYS[cadence]),
163
+ posts: postCount,
164
+ threads: threadCount,
165
+ }),
166
+ body: lines.join('\n'),
167
+ }
168
+ },
169
+
170
+ 'post.mentioned': (data, t) => {
171
+ const by = str(data, 'byUsername', t.t('notification.render.somebody'))
172
+ const title = str(data, 'threadTitle', t.t('notification.render.threadFallback'))
173
+ return {
174
+ subject: t.t('notification.render.mentioned.subject', { by, title }),
175
+ body: t.t('notification.render.mentioned.body', { by, title }),
176
+ }
177
+ },
178
+
179
+ 'post.quoted': (data, t) => {
180
+ const by = str(data, 'byUsername', t.t('notification.render.somebody'))
181
+ const title = str(data, 'threadTitle', t.t('notification.render.threadFallback'))
182
+ return {
183
+ subject: t.t('notification.render.quoted.subject', { by, title }),
184
+ body: t.t('notification.render.quoted.body', { by, title }),
185
+ }
186
+ },
187
+
188
+ 'pm.received': (data, t) => {
189
+ const from = str(data, 'fromUsername', t.t('notification.render.somebody'))
190
+ const subject = str(data, 'subject', t.t('notification.render.pm.fallbackSubject'))
191
+ return {
192
+ subject: t.t('notification.render.pm.receivedSubject', { from }),
193
+ body: `“${subject}”`,
194
+ }
195
+ },
196
+
197
+ 'pm.receipt': (data, t) => {
198
+ const by = str(data, 'byUsername', t.t('notification.render.pm.recipientFallback'))
199
+ const subject = str(data, 'subject', t.t('notification.render.pm.messageFallback'))
200
+ return {
201
+ subject: t.t('notification.render.pm.receiptSubject', { by }),
202
+ body: t.t('notification.render.pm.receiptBody', { subject }),
203
+ }
204
+ },
205
+
206
+ 'system.task_failed': (data, t) => {
207
+ const taskId = str(data, 'taskId', t.t('notification.render.task.fallbackId'))
208
+ const error = str(data, 'error')
209
+ return {
210
+ subject: t.t('notification.render.task.subject', { taskId }),
211
+ body:
212
+ t.t('notification.render.task.body', { taskId }) +
213
+ (error === '' ? '' : `\n\n${t.t('notification.render.task.error', { error })}`),
214
+ }
215
+ },
216
+
217
+ 'marketplace.update_available': (data, t) => {
218
+ const name = str(data, 'name', str(data, 'key'))
219
+ const version = str(data, 'version')
220
+ const packageName = str(data, 'package')
221
+ return {
222
+ subject: t.t('notification.render.marketplace.subject', { name, version }),
223
+ body: t.t('notification.render.marketplace.body', { package: packageName, version }),
224
+ }
225
+ },
226
+ }
227
+
228
+ export function renderNotification(
229
+ record: NotificationRecord,
230
+ t: Translator = sourceTranslator(EN_CATALOG),
231
+ ): NotificationView {
232
+ const template = TEMPLATES[record.kind]
233
+ const spec = notificationKind(record.kind)
234
+
235
+ const rendered =
236
+ template !== undefined
237
+ ? template(record.data, t)
238
+ : record.kind.startsWith('plugin.')
239
+ ? {
240
+ subject:
241
+ pluginMessage(record.data, 'subjectKey', 'subjectArgs', 'subject', t) ||
242
+ t.t('notification.render.pluginFallback'),
243
+ body: pluginMessage(record.data, 'bodyKey', 'bodyArgs', 'body', t),
244
+ }
245
+ : {
246
+ subject:
247
+ spec?.titleKey === undefined
248
+ ? (spec?.title ?? t.t('notification.render.generic', { kind: record.kind }))
249
+ : t.t(spec.titleKey),
250
+ body: '',
251
+ }
252
+
253
+ const subject =
254
+ record.occurrences > 1
255
+ ? t.t('notification.render.occurrences', {
256
+ subject: rendered.subject,
257
+ count: record.occurrences,
258
+ })
259
+ : rendered.subject
260
+
261
+ const unsubscribe = record.data.unsubscribe
262
+
263
+ return {
264
+ id: record.id,
265
+ subject,
266
+ body: rendered.body,
267
+ unsubscribeToken: typeof unsubscribe === 'string' && unsubscribe !== '' ? unsubscribe : null,
268
+ href: record.href,
269
+ kind: record.kind,
270
+ occurrences: record.occurrences,
271
+ createdAt: record.createdAt,
272
+ updatedAt: record.updatedAt,
273
+ isRead: record.readAt !== null,
274
+ }
275
+ }