easy-ping 0.3.0 → 0.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/README.md +15 -4
- package/dist/adapters/drizzle.cjs +3 -2
- package/dist/adapters/drizzle.cjs.map +1 -1
- package/dist/adapters/drizzle.d.cts +34 -0
- package/dist/adapters/drizzle.d.ts +34 -0
- package/dist/adapters/drizzle.js +4 -3
- package/dist/adapters/drizzle.js.map +1 -1
- package/dist/adapters/mongodb.cjs +6 -2
- package/dist/adapters/mongodb.cjs.map +1 -1
- package/dist/adapters/mongodb.d.cts +1 -1
- package/dist/adapters/mongodb.d.ts +1 -1
- package/dist/adapters/mongodb.js +5 -2
- package/dist/adapters/mongodb.js.map +1 -1
- package/dist/adapters/postgres.cjs +3 -2
- package/dist/adapters/postgres.cjs.map +1 -1
- package/dist/adapters/postgres.d.cts +1 -1
- package/dist/adapters/postgres.d.ts +1 -1
- package/dist/adapters/postgres.js +3 -3
- package/dist/{chunk-OZSOIX23.js → chunk-3Y2MBUAW.js} +53 -12
- package/dist/chunk-3Y2MBUAW.js.map +1 -0
- package/dist/chunk-CYKAM7LE.js +226 -0
- package/dist/chunk-CYKAM7LE.js.map +1 -0
- package/dist/chunk-DG3ZL5NP.js +315 -0
- package/dist/chunk-DG3ZL5NP.js.map +1 -0
- package/dist/{chunk-NYDVNMKG.js → chunk-PSJHXZPR.js} +4 -3
- package/dist/chunk-PSJHXZPR.js.map +1 -0
- package/dist/{chunk-BV3U542P.js → chunk-W6I3B2CI.js} +2 -2
- package/dist/chunk-W6I3B2CI.js.map +1 -0
- package/dist/{chunk-RBDW4S6F.js → chunk-YEMRRMJQ.js} +3 -3
- package/dist/{chunk-RBDW4S6F.js.map → chunk-YEMRRMJQ.js.map} +1 -1
- package/dist/index.cjs +360 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +84 -20
- package/dist/index.d.ts +84 -20
- package/dist/index.js +119 -357
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +27 -6
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +8 -2
- package/dist/node.d.ts +8 -2
- package/dist/node.js +26 -6
- package/dist/node.js.map +1 -1
- package/dist/{plugin-CmPOFVmn.d.ts → plugin-C9Hoxus2.d.ts} +37 -9
- package/dist/{plugin-f5KaFWSN.d.cts → plugin-CiMsGlQe.d.cts} +37 -9
- package/dist/plugins/digests.d.cts +1 -1
- package/dist/plugins/digests.d.ts +1 -1
- package/dist/plugins/preferences-client.cjs.map +1 -1
- package/dist/plugins/preferences-client.d.cts +1 -1
- package/dist/plugins/preferences-client.d.ts +1 -1
- package/dist/plugins/preferences-client.js +1 -1
- package/dist/plugins/preferences.cjs +100 -15
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.d.cts +3 -1
- package/dist/plugins/preferences.d.ts +3 -1
- package/dist/plugins/preferences.js +35 -7
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.cjs +197 -38
- package/dist/plugins/push.cjs.map +1 -1
- package/dist/plugins/push.d.cts +32 -10
- package/dist/plugins/push.d.ts +32 -10
- package/dist/plugins/push.js +140 -39
- package/dist/plugins/push.js.map +1 -1
- package/dist/providers/web-push.cjs +37 -11
- package/dist/providers/web-push.cjs.map +1 -1
- package/dist/providers/web-push.d.cts +8 -2
- package/dist/providers/web-push.d.ts +8 -2
- package/dist/providers/web-push.js +37 -12
- package/dist/providers/web-push.js.map +1 -1
- package/dist/schema.cjs +2 -1
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +6 -1
- package/dist/schema.d.ts +6 -1
- package/dist/schema.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-BV3U542P.js.map +0 -1
- package/dist/chunk-NYDVNMKG.js.map +0 -1
- package/dist/chunk-OZSOIX23.js.map +0 -1
- package/dist/chunk-W3BMEPPQ.js +0 -50
- package/dist/chunk-W3BMEPPQ.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/plugins/preferences/index.ts"],"names":[],"mappings":"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","file":"preferences.js","sourcesContent":["import type {\n ChannelDecision,\n EasyPingPlugin,\n PluginInitContext,\n PrepareContext,\n ResolveChannelsContext,\n} from \"../../core/plugin\";\nimport { DEFAULT_TOKEN_TTL_SECONDS, expiresIn, signToken } from \"../../core/tokens\";\nimport type { Channel, Frequency } from \"../../core/types\";\n\nexport const UNSUBSCRIBE_PURPOSE = \"unsubscribe\";\n\nconst TABLE = \"notification_preference\";\n\nexport type PreferenceRow = {\n userId: string;\n type: string;\n channel: Channel;\n enabled: boolean;\n frequency: Frequency;\n};\n\nexport type PreferencesOptions = {\n /** What an absent row means. Flip to false for opt-in-only notifications. */\n defaultEnabled?: boolean;\n /** Types that ignore preferences entirely: receipts, security alerts. */\n alwaysSend?: readonly string[];\n unsubscribeTtlSeconds?: number;\n};\n\nconst key = (userId: string, type: string, channel: string) => `${userId} ${type} ${channel}`;\n\ntype PreparedPreferences = Map<string, PreferenceRow>;\n\nexport function buildUnsubscribeToken(\n secret: string,\n userId: string,\n type: string,\n channel: Channel,\n ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS,\n): Promise<string> {\n return signToken(secret, {\n uid: userId,\n purpose: UNSUBSCRIBE_PURPOSE,\n exp: expiresIn(ttlSeconds),\n data: { type, channel },\n });\n}\n\nexport function preferences(options: PreferencesOptions = {}): EasyPingPlugin<\"preferences\"> {\n const defaultEnabled = options.defaultEnabled ?? true;\n const alwaysSend = new Set(options.alwaysSend ?? []);\n\n // Assigned by init(), which easyPing() runs before any hook or route.\n let ctx: PluginInitContext;\n\n const isAllowed = (userId: string, type: string, channel: Channel, rows: PreparedPreferences) => {\n const row = rows.get(key(userId, type, channel));\n return row ? row.enabled && row.frequency !== \"off\" : defaultEnabled;\n };\n\n const load = (userIds: readonly string[]) =>\n ctx.store.find<PreferenceRow>(TABLE, { userId: { in: [...userIds] } });\n\n const save = (row: PreferenceRow) =>\n ctx.store.upsert(TABLE, [row], { onConflict: [\"userId\", \"type\", \"channel\"] });\n\n return {\n id: \"preferences\",\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n notificationPreference: {\n tableName: TABLE,\n fields: {\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n channel: { type: \"string\", required: true },\n enabled: { type: \"boolean\", required: true, default: true },\n frequency: { type: \"string\", required: true, default: \"instant\" },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\n },\n\n hooks: {\n /** One query per send; doing this per recipient would be an N+1. */\n prepare: async (context: PrepareContext): Promise<PreparedPreferences> => {\n if (alwaysSend.has(context.type)) return new Map();\n\n const rows = await load(context.recipients.map((recipient) => recipient.userId));\n return new Map(rows.map((row) => [key(row.userId, row.type, row.channel), row]));\n },\n\n resolveChannels: (context: ResolveChannelsContext) => {\n if (alwaysSend.has(context.type)) return context.decisions;\n\n // prepare() failing leaves this undefined. Fail closed rather than\n // assuming everyone opted in: that is exactly how people who\n // unsubscribed get emailed anyway.\n if (!(context.prepared instanceof Map)) {\n return context.decisions.map((decision) => ({ ...decision, action: \"skip\" as const }));\n }\n\n const rows = context.prepared as PreparedPreferences;\n\n return context.decisions.map<ChannelDecision>((decision) =>\n isAllowed(context.recipient.userId, context.type, decision.channel, rows)\n ? decision\n : { ...decision, action: \"skip\" },\n );\n },\n },\n\n routes: [\n {\n path: \"/preferences\",\n method: \"GET\",\n scope: { type: \"user\" },\n handler: async ({ userId }) => {\n const rows = await load([userId ?? \"\"]);\n return Response.json({ preferences: rows, defaultEnabled });\n },\n },\n\n {\n path: \"/preferences\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const body = (await request.json().catch(() => null)) as Partial<PreferenceRow> | null;\n\n if (!body?.type || !body.channel) {\n return Response.json({ error: \"type and channel are required\" }, { status: 400 });\n }\n\n // userId comes from the resolved session, never from the body.\n await save({\n userId: userId ?? \"\",\n type: body.type,\n channel: body.channel,\n enabled: body.enabled ?? true,\n frequency: body.frequency ?? \"instant\",\n });\n\n return Response.json({ ok: true });\n },\n },\n\n {\n // Reached from a mail client with no session, so it is authenticated\n // by the signature on the link itself.\n path: \"/unsubscribe\",\n method: \"POST\",\n scope: { type: \"signed\", purpose: UNSUBSCRIBE_PURPOSE },\n handler: async ({ claims }) => {\n const type = claims?.data?.type;\n const channel = claims?.data?.channel as Channel | undefined;\n if (!claims || !type || !channel) {\n return Response.json({ error: \"invalid token\" }, { status: 400 });\n }\n\n await save({\n userId: claims.uid,\n type,\n channel,\n enabled: false,\n frequency: \"off\",\n });\n\n // Gmail and Yahoo one-click requires the POST itself to unsubscribe,\n // with no confirmation page.\n return Response.json({ ok: true, unsubscribed: { type, channel } });\n },\n },\n ],\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/preferences/index.ts"],"names":[],"mappings":"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","file":"preferences.js","sourcesContent":["import { readJsonBody } from \"../../core/handler\";\nimport type {\n ChannelDecision,\n EasyPingPlugin,\n PluginInitContext,\n PrepareContext,\n ResolveChannelsContext,\n} from \"../../core/plugin\";\nimport { DEFAULT_TOKEN_TTL_SECONDS, expiresIn, signToken } from \"../../core/tokens\";\nimport type { Channel, Frequency } from \"../../core/types\";\n\nexport const UNSUBSCRIBE_PURPOSE = \"unsubscribe\";\n\nconst TABLE = \"notification_preference\";\n\nconst CHANNELS: readonly Channel[] = [\"inApp\", \"email\", \"push\", \"sms\", \"slack\"];\nconst FREQUENCIES: readonly Frequency[] = [\"instant\", \"daily\", \"weekly\", \"off\"];\n\nconst isChannel = (value: unknown): value is Channel => CHANNELS.includes(value as Channel);\nconst isFrequency = (value: unknown): value is Frequency =>\n FREQUENCIES.includes(value as Frequency);\n\nexport type PreferenceRow = {\n userId: string;\n type: string;\n channel: Channel;\n enabled: boolean;\n frequency: Frequency;\n /** Last explicit change. An unsubscribe token issued before it is refused. */\n updatedAt?: Date;\n};\n\nexport type PreferencesOptions = {\n /** What an absent row means. Flip to false for opt-in-only notifications. */\n defaultEnabled?: boolean;\n /** Types that ignore preferences entirely: receipts, security alerts. */\n alwaysSend?: readonly string[];\n unsubscribeTtlSeconds?: number;\n};\n\nconst key = (userId: string, type: string, channel: string) => `${userId} ${type} ${channel}`;\n\ntype PreparedPreferences = Map<string, PreferenceRow>;\n\nexport function buildUnsubscribeToken(\n secret: string,\n userId: string,\n type: string,\n channel: Channel,\n ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS,\n): Promise<string> {\n return signToken(secret, {\n uid: userId,\n purpose: UNSUBSCRIBE_PURPOSE,\n exp: expiresIn(ttlSeconds),\n data: { type, channel },\n });\n}\n\nexport function preferences(options: PreferencesOptions = {}): EasyPingPlugin<\"preferences\"> {\n const defaultEnabled = options.defaultEnabled ?? true;\n const alwaysSend = new Set(options.alwaysSend ?? []);\n\n // Assigned by init(), which easyPing() runs before any hook or route.\n let ctx: PluginInitContext;\n\n const isAllowed = (userId: string, type: string, channel: Channel, rows: PreparedPreferences) => {\n const row = rows.get(key(userId, type, channel));\n return row ? row.enabled && row.frequency !== \"off\" : defaultEnabled;\n };\n\n const load = (userIds: readonly string[]) =>\n ctx.store.find<PreferenceRow>(TABLE, { userId: { in: [...userIds] } });\n\n const save = (row: PreferenceRow) =>\n ctx.store.upsert(TABLE, [{ ...row, updatedAt: new Date() }], {\n onConflict: [\"userId\", \"type\", \"channel\"],\n });\n\n return {\n id: \"preferences\",\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n notificationPreference: {\n tableName: TABLE,\n fields: {\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n channel: { type: \"string\", required: true },\n enabled: { type: \"boolean\", required: true, default: true },\n frequency: { type: \"string\", required: true, default: \"instant\" },\n updatedAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\n },\n\n hooks: {\n /** One query per send; doing this per recipient would be an N+1. */\n prepare: async (context: PrepareContext): Promise<PreparedPreferences> => {\n if (alwaysSend.has(context.type)) return new Map();\n\n const rows = await load(context.recipients.map((recipient) => recipient.userId));\n return new Map(rows.map((row) => [key(row.userId, row.type, row.channel), row]));\n },\n\n resolveChannels: (context: ResolveChannelsContext) => {\n if (alwaysSend.has(context.type)) return context.decisions;\n\n // prepare() failing leaves this undefined. Fail closed rather than\n // assuming everyone opted in: that is exactly how people who\n // unsubscribed get emailed anyway.\n if (!(context.prepared instanceof Map)) {\n return context.decisions.map((decision) => ({ ...decision, action: \"skip\" as const }));\n }\n\n const rows = context.prepared as PreparedPreferences;\n\n return context.decisions.map<ChannelDecision>((decision) =>\n isAllowed(context.recipient.userId, context.type, decision.channel, rows)\n ? decision\n : { ...decision, action: \"skip\" },\n );\n },\n },\n\n routes: [\n {\n path: \"/preferences\",\n method: \"GET\",\n scope: { type: \"user\" },\n handler: async ({ userId }) => {\n const rows = await load([userId ?? \"\"]);\n return Response.json({ preferences: rows, defaultEnabled });\n },\n },\n\n {\n path: \"/preferences\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n const body = parsed.body;\n\n const bad = (error: string) => Response.json({ error }, { status: 400 });\n\n // Only types the app defined: otherwise every unknown string the\n // client invents is a row that lives forever.\n if (typeof body.type !== \"string\" || !ctx.notificationTypes.includes(body.type)) {\n return bad(\"type must be a configured notification type\");\n }\n if (!isChannel(body.channel)) return bad(`channel must be one of ${CHANNELS.join(\", \")}`);\n if (body.enabled !== undefined && typeof body.enabled !== \"boolean\") {\n return bad(\"enabled must be a boolean\");\n }\n if (body.frequency !== undefined && !isFrequency(body.frequency)) {\n return bad(`frequency must be one of ${FREQUENCIES.join(\", \")}`);\n }\n\n // userId comes from the resolved session, never from the body.\n await save({\n userId: userId ?? \"\",\n type: body.type,\n channel: body.channel,\n enabled: body.enabled ?? true,\n frequency: body.frequency ?? \"instant\",\n });\n\n return Response.json({ ok: true });\n },\n },\n\n {\n // Reached from a mail client with no session, so it is authenticated\n // by the signature on the link itself.\n path: \"/unsubscribe\",\n method: \"POST\",\n scope: { type: \"signed\", purpose: UNSUBSCRIBE_PURPOSE },\n handler: async ({ claims }) => {\n const type = claims?.data?.type;\n const channel = claims?.data?.channel;\n if (!claims || !type || !isChannel(channel)) {\n return Response.json({ error: \"invalid token\" }, { status: 400 });\n }\n\n const [current] = await ctx.store.find<PreferenceRow>(\n TABLE,\n { userId: claims.uid, type, channel },\n { limit: 1 },\n );\n\n // Already off: a second click, or a mail client retrying, is fine.\n if (current && !current.enabled && current.frequency === \"off\") {\n return Response.json({ ok: true, unsubscribed: { type, channel } });\n }\n\n // The user changed this preference after the email went out. An old\n // link must not silently undo a newer decision made while logged in.\n if (\n current?.updatedAt &&\n claims.iat !== undefined &&\n current.updatedAt.getTime() > (claims.iat + 1) * 1000\n ) {\n return Response.json({ error: \"invalid token\" }, { status: 400 });\n }\n\n await save({\n userId: claims.uid,\n type,\n channel,\n enabled: false,\n frequency: \"off\",\n });\n\n // Gmail and Yahoo one-click requires the POST itself to unsubscribe,\n // with no confirmation page.\n return Response.json({ ok: true, unsubscribed: { type, channel } });\n },\n },\n ],\n };\n}\n"]}
|
package/dist/plugins/push.cjs
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// src/core/base64url.ts
|
|
4
|
+
function decodeBase64UrlBytes(value) {
|
|
5
|
+
try {
|
|
6
|
+
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
|
|
7
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/core/handler.ts
|
|
14
|
+
var MAX_BODY_BYTES = 64 * 1024;
|
|
15
|
+
var PRIVATE_HEADERS = {
|
|
16
|
+
"cache-control": "private, no-store",
|
|
17
|
+
"x-content-type-options": "nosniff"
|
|
18
|
+
};
|
|
19
|
+
var json = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
20
|
+
status,
|
|
21
|
+
headers: { "content-type": "application/json", ...PRIVATE_HEADERS }
|
|
22
|
+
});
|
|
23
|
+
new TextEncoder();
|
|
24
|
+
async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
|
|
25
|
+
const tooLarge = () => ({ error: json({ error: `body exceeds ${maxBytes} bytes` }, 413) });
|
|
26
|
+
const declared = Number(request.headers.get("content-length"));
|
|
27
|
+
if (Number.isFinite(declared) && declared > maxBytes) return tooLarge();
|
|
28
|
+
if (!request.body) return { body: {} };
|
|
29
|
+
const reader = request.body.getReader();
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let total = 0;
|
|
32
|
+
for (; ; ) {
|
|
33
|
+
const { done, value } = await reader.read();
|
|
34
|
+
if (done) break;
|
|
35
|
+
total += value.byteLength;
|
|
36
|
+
if (total > maxBytes) {
|
|
37
|
+
await reader.cancel();
|
|
38
|
+
return tooLarge();
|
|
39
|
+
}
|
|
40
|
+
chunks.push(value);
|
|
41
|
+
}
|
|
42
|
+
const bytes = new Uint8Array(total);
|
|
43
|
+
let offset = 0;
|
|
44
|
+
for (const chunk of chunks) {
|
|
45
|
+
bytes.set(chunk, offset);
|
|
46
|
+
offset += chunk.byteLength;
|
|
47
|
+
}
|
|
48
|
+
const text = new TextDecoder().decode(bytes);
|
|
49
|
+
if (text.trim() === "") return { body: {} };
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(text);
|
|
52
|
+
return {
|
|
53
|
+
body: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return { error: json({ error: "body must be a JSON object" }, 400) };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/core/runner.ts
|
|
61
|
+
var redactErrorMessage = (text) => text.replace(/[^\s"'<>()[\]{},;]+@[^\s"'<>()[\]{},;]+/g, "[redacted-email]");
|
|
62
|
+
|
|
3
63
|
// src/plugins/push/index.ts
|
|
4
64
|
var DEVICE = "notification_push_device";
|
|
5
65
|
var pushSchema = {
|
|
@@ -24,9 +84,58 @@ var pushSchema = {
|
|
|
24
84
|
]
|
|
25
85
|
}
|
|
26
86
|
};
|
|
87
|
+
var PRIVATE_IPV4 = /^(0\.|10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/;
|
|
88
|
+
function isInternalHost(hostname) {
|
|
89
|
+
const host = hostname.toLowerCase();
|
|
90
|
+
if (host === "localhost" || /\.(localhost|local|internal|lan)$/.test(host)) return true;
|
|
91
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return PRIVATE_IPV4.test(host);
|
|
92
|
+
if (host.startsWith("[")) {
|
|
93
|
+
const v6 = host.slice(1, -1);
|
|
94
|
+
return v6 === "::1" || v6 === "::" || /^(fc|fd|fe[89ab])/.test(v6) || v6.startsWith("::ffff:");
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
var hostAllowed = (host, allowed) => allowed.some(
|
|
99
|
+
(entry) => entry.startsWith("*.") ? host.endsWith(entry.slice(1)) : host === entry.toLowerCase()
|
|
100
|
+
);
|
|
101
|
+
function validateEndpoint(endpoint, allowedHosts, allowInsecure = false) {
|
|
102
|
+
let url;
|
|
103
|
+
try {
|
|
104
|
+
url = new URL(endpoint);
|
|
105
|
+
} catch {
|
|
106
|
+
return "endpoint is not a valid URL";
|
|
107
|
+
}
|
|
108
|
+
if (url.username || url.password) return "endpoint must not carry credentials";
|
|
109
|
+
if (!allowInsecure) {
|
|
110
|
+
if (url.protocol !== "https:") return "endpoint must use https";
|
|
111
|
+
if (isInternalHost(url.hostname)) return "endpoint must be a public host";
|
|
112
|
+
} else if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
113
|
+
return "endpoint must be an http(s) URL";
|
|
114
|
+
}
|
|
115
|
+
if (allowedHosts && !hostAllowed(url.hostname.toLowerCase(), allowedHosts)) {
|
|
116
|
+
return "endpoint host is not an allowed push service";
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
function validateSubscriptionKeys(keys) {
|
|
121
|
+
const p256dh = decodeBase64UrlBytes(keys.p256dh);
|
|
122
|
+
if (p256dh?.length !== 65 || p256dh[0] !== 4) {
|
|
123
|
+
return "keys.p256dh must be a 65-byte uncompressed EC point";
|
|
124
|
+
}
|
|
125
|
+
const auth = decodeBase64UrlBytes(keys.auth);
|
|
126
|
+
if (auth?.length !== 16) return "keys.auth must be 16 bytes";
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
var isDuplicate = (error) => {
|
|
130
|
+
const code = error?.code;
|
|
131
|
+
if (code === "23505" || code === 11e3) return true;
|
|
132
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
133
|
+
return /duplicate|unique/i.test(text);
|
|
134
|
+
};
|
|
27
135
|
function push(options) {
|
|
28
136
|
let ctx;
|
|
29
137
|
const staleAfterDays = options.staleAfterDays ?? 180;
|
|
138
|
+
const maxDevices = Math.max(1, options.maxDevicesPerUser ?? 20);
|
|
30
139
|
return {
|
|
31
140
|
id: "push",
|
|
32
141
|
channels: ["push"],
|
|
@@ -46,13 +155,9 @@ function push(options) {
|
|
|
46
155
|
type: context.notification.type,
|
|
47
156
|
payload: context.notification.payload
|
|
48
157
|
});
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
let lastError = "";
|
|
53
|
-
for (const device of devices) {
|
|
54
|
-
try {
|
|
55
|
-
const result = await options.provider.send({
|
|
158
|
+
const results = await Promise.allSettled(
|
|
159
|
+
devices.map(
|
|
160
|
+
(device) => options.provider.send({
|
|
56
161
|
subscription: {
|
|
57
162
|
endpoint: device.endpoint,
|
|
58
163
|
keys: { p256dh: device.p256dh, auth: device.auth }
|
|
@@ -60,20 +165,30 @@ function push(options) {
|
|
|
60
165
|
...content,
|
|
61
166
|
data: { notificationId: context.notification.id, type: context.notification.type },
|
|
62
167
|
signal: context.signal
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
168
|
+
})
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
const prune = [];
|
|
172
|
+
let delivered = 0;
|
|
173
|
+
let retryable = false;
|
|
174
|
+
let lastError = "";
|
|
175
|
+
results.forEach((result, index) => {
|
|
176
|
+
const device = devices[index];
|
|
177
|
+
if (!device) return;
|
|
178
|
+
if (result.status === "rejected") {
|
|
179
|
+
lastError = redactErrorMessage(
|
|
180
|
+
result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
181
|
+
);
|
|
71
182
|
retryable = true;
|
|
183
|
+
return;
|
|
72
184
|
}
|
|
73
|
-
|
|
74
|
-
|
|
185
|
+
if (result.value.expired || result.value.invalid) prune.push(device.id);
|
|
186
|
+
else if (result.value.retryable) retryable = true;
|
|
187
|
+
else delivered += 1;
|
|
188
|
+
});
|
|
189
|
+
if (prune.length > 0) await ctx.store.remove(DEVICE, { id: { in: prune } });
|
|
75
190
|
if (delivered > 0) return { result: "sent" };
|
|
76
|
-
if (
|
|
191
|
+
if (prune.length === devices.length) {
|
|
77
192
|
return { result: "failed", error: "all devices expired", retryable: false };
|
|
78
193
|
}
|
|
79
194
|
return {
|
|
@@ -89,26 +204,69 @@ function push(options) {
|
|
|
89
204
|
method: "POST",
|
|
90
205
|
scope: { type: "user" },
|
|
91
206
|
handler: async ({ request, userId }) => {
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
207
|
+
const parsed = await readJsonBody(request);
|
|
208
|
+
if ("error" in parsed) return parsed.error;
|
|
209
|
+
const { endpoint, keys } = parsed.body;
|
|
210
|
+
if (typeof endpoint !== "string" || typeof keys?.p256dh !== "string" || typeof keys?.auth !== "string") {
|
|
94
211
|
return Response.json({ error: "endpoint and keys are required" }, { status: 400 });
|
|
95
212
|
}
|
|
96
|
-
|
|
213
|
+
const problem = validateEndpoint(
|
|
214
|
+
endpoint,
|
|
215
|
+
options.allowedEndpointHosts,
|
|
216
|
+
options.allowInsecureEndpoints ?? false
|
|
217
|
+
) ?? validateSubscriptionKeys(keys);
|
|
218
|
+
if (problem) return Response.json({ error: problem }, { status: 400 });
|
|
219
|
+
const owner = userId ?? "";
|
|
220
|
+
const now = /* @__PURE__ */ new Date();
|
|
221
|
+
const userAgent = request.headers.get("user-agent")?.slice(0, 512) ?? null;
|
|
222
|
+
const [existing] = await ctx.store.find(DEVICE, { endpoint }, { limit: 1 });
|
|
223
|
+
if (existing && existing.userId !== owner) {
|
|
224
|
+
return Response.json(
|
|
225
|
+
{ error: "endpoint is registered to another account" },
|
|
226
|
+
{ status: 409 }
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (existing) {
|
|
230
|
+
await ctx.store.update(
|
|
231
|
+
DEVICE,
|
|
232
|
+
{ id: existing.id },
|
|
233
|
+
{ p256dh: keys.p256dh, auth: keys.auth, userAgent, lastSeenAt: now }
|
|
234
|
+
);
|
|
235
|
+
return Response.json({ ok: true });
|
|
236
|
+
}
|
|
237
|
+
const devices = await ctx.store.find(
|
|
97
238
|
DEVICE,
|
|
98
|
-
|
|
239
|
+
{ userId: owner },
|
|
240
|
+
{ orderBy: { field: "lastSeenAt", direction: "asc" } }
|
|
241
|
+
);
|
|
242
|
+
const excess = devices.length - maxDevices + 1;
|
|
243
|
+
if (excess > 0) {
|
|
244
|
+
await ctx.store.remove(DEVICE, {
|
|
245
|
+
id: { in: devices.slice(0, excess).map((device) => device.id) }
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await ctx.store.insert(DEVICE, [
|
|
99
250
|
{
|
|
100
251
|
id: crypto.randomUUID(),
|
|
101
|
-
userId:
|
|
102
|
-
endpoint
|
|
103
|
-
p256dh:
|
|
104
|
-
auth:
|
|
105
|
-
userAgent
|
|
106
|
-
createdAt:
|
|
107
|
-
lastSeenAt:
|
|
252
|
+
userId: owner,
|
|
253
|
+
endpoint,
|
|
254
|
+
p256dh: keys.p256dh,
|
|
255
|
+
auth: keys.auth,
|
|
256
|
+
userAgent,
|
|
257
|
+
createdAt: now,
|
|
258
|
+
lastSeenAt: now
|
|
108
259
|
}
|
|
109
|
-
]
|
|
110
|
-
|
|
111
|
-
|
|
260
|
+
]);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (isDuplicate(error)) {
|
|
263
|
+
return Response.json(
|
|
264
|
+
{ error: "endpoint is registered to another account" },
|
|
265
|
+
{ status: 409 }
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
112
270
|
return Response.json({ ok: true });
|
|
113
271
|
}
|
|
114
272
|
},
|
|
@@ -117,14 +275,13 @@ function push(options) {
|
|
|
117
275
|
method: "POST",
|
|
118
276
|
scope: { type: "user" },
|
|
119
277
|
handler: async ({ request, userId }) => {
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
278
|
+
const parsed = await readJsonBody(request);
|
|
279
|
+
if ("error" in parsed) return parsed.error;
|
|
280
|
+
const { endpoint } = parsed.body;
|
|
281
|
+
if (typeof endpoint !== "string" || endpoint === "") {
|
|
122
282
|
return Response.json({ error: "endpoint is required" }, { status: 400 });
|
|
123
283
|
}
|
|
124
|
-
const removed = await ctx.store.remove(DEVICE, {
|
|
125
|
-
userId: userId ?? "",
|
|
126
|
-
endpoint: body.endpoint
|
|
127
|
-
});
|
|
284
|
+
const removed = await ctx.store.remove(DEVICE, { userId: userId ?? "", endpoint });
|
|
128
285
|
return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });
|
|
129
286
|
}
|
|
130
287
|
},
|
|
@@ -144,5 +301,7 @@ function push(options) {
|
|
|
144
301
|
|
|
145
302
|
exports.push = push;
|
|
146
303
|
exports.pushSchema = pushSchema;
|
|
304
|
+
exports.validateEndpoint = validateEndpoint;
|
|
305
|
+
exports.validateSubscriptionKeys = validateSubscriptionKeys;
|
|
147
306
|
//# sourceMappingURL=push.cjs.map
|
|
148
307
|
//# sourceMappingURL=push.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/plugins/push/index.ts"],"names":[],"mappings":"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","file":"push.cjs","sourcesContent":["import type { DeliveryOutcome } from \"../../core/adapter\";\nimport type {\n DeliverContext,\n EasyPingPlugin,\n PluginInitContext,\n SchemaDeclaration,\n} from \"../../core/plugin\";\n\nconst DEVICE = \"notification_push_device\";\n\nexport type PushSubscription = {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n};\n\nexport type PushMessage = {\n subscription: PushSubscription;\n title: string;\n body: string;\n data?: Record<string, unknown> | undefined;\n signal?: AbortSignal | undefined;\n};\n\nexport type PushSendResult = {\n /** True when the endpoint is permanently gone (404/410) and must be pruned. */\n expired?: boolean;\n retryable?: boolean;\n};\n\nexport type PushProvider = {\n name: string;\n send(message: PushMessage): Promise<PushSendResult>;\n};\n\nexport type PushDevice = {\n id: string;\n userId: string;\n endpoint: string;\n p256dh: string;\n auth: string;\n userAgent: string | null;\n createdAt: Date;\n lastSeenAt: Date;\n};\n\nexport type PushOptions = {\n provider: PushProvider;\n /** Builds the notification shown on the device. */\n render: (input: { type: string; payload: unknown }) => { title: string; body: string };\n /** Devices untouched for this long are pruned on next delivery. */\n staleAfterDays?: number;\n};\n\n/**\n * Web push over the device registry.\n *\n * Tokens are per device, not per user, so a delivery fans out and succeeds if\n * any endpoint accepts. Endpoints that report themselves gone are deleted\n * immediately: an unpruned registry accumulates dead subscriptions forever and\n * every send slows down behind them.\n */\n/**\n * The tables this plugin owns, as a standalone value.\n *\n * Exported separately from `push()` so DDL can be rendered without building a\n * plugin instance — otherwise creating the tables means inventing a provider\n * and a render function you never intend to call.\n */\nexport const pushSchema = {\n pushDevice: {\n tableName: DEVICE,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n endpoint: { type: \"string\", required: true },\n p256dh: { type: \"string\", required: true },\n auth: { type: \"string\", required: true },\n userAgent: { type: \"string\" },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n lastSeenAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [\n { on: [\"userId\"], name: \"push_device_user_idx\" },\n // One row per endpoint: re-registering the same browser must update,\n // not duplicate, or every send goes out N times to one device.\n { on: [\"endpoint\"], unique: true, name: \"push_device_endpoint_idx\" },\n ],\n },\n} as const satisfies SchemaDeclaration;\n\nexport function push(options: PushOptions): EasyPingPlugin<\"push\"> {\n let ctx: PluginInitContext;\n const staleAfterDays = options.staleAfterDays ?? 180;\n\n return {\n id: \"push\",\n channels: [\"push\"],\n\n init: (context) => {\n ctx = context;\n },\n\n schema: pushSchema,\n\n hooks: {\n deliver: async (context: DeliverContext): Promise<DeliveryOutcome> => {\n const devices = await ctx.store.find<PushDevice>(DEVICE, {\n userId: context.notification.userId,\n });\n\n if (devices.length === 0) {\n // Not a failure: this person has simply not enabled push anywhere.\n return { result: \"skipped\", reason: \"no registered devices\" };\n }\n\n const content = options.render({\n type: context.notification.type,\n payload: context.notification.payload,\n });\n\n const expired: string[] = [];\n let delivered = 0;\n let retryable = false;\n let lastError = \"\";\n\n for (const device of devices) {\n try {\n const result = await options.provider.send({\n subscription: {\n endpoint: device.endpoint,\n keys: { p256dh: device.p256dh, auth: device.auth },\n },\n ...content,\n data: { notificationId: context.notification.id, type: context.notification.type },\n signal: context.signal,\n });\n\n if (result.expired) expired.push(device.id);\n else {\n delivered += 1;\n if (result.retryable) retryable = true;\n }\n } catch (error) {\n lastError = error instanceof Error ? error.message : String(error);\n retryable = true;\n }\n }\n\n if (expired.length > 0) await ctx.store.remove(DEVICE, { id: { in: expired } });\n\n if (delivered > 0) return { result: \"sent\" };\n\n // Every endpoint was gone: retrying cannot help, and the rows are\n // already deleted.\n if (expired.length === devices.length) {\n return { result: \"failed\", error: \"all devices expired\", retryable: false };\n }\n\n return {\n result: \"failed\",\n error: lastError || \"no device accepted the push\",\n retryable,\n };\n },\n },\n\n routes: [\n {\n path: \"/push/devices\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const body = (await request.json().catch(() => null)) as PushSubscription | null;\n if (!body?.endpoint || !body.keys?.p256dh || !body.keys?.auth) {\n return Response.json({ error: \"endpoint and keys are required\" }, { status: 400 });\n }\n\n // Upsert on endpoint: the same browser re-registering after a\n // service-worker update must not create a second row.\n await ctx.store.upsert(\n DEVICE,\n [\n {\n id: crypto.randomUUID(),\n userId: userId ?? \"\",\n endpoint: body.endpoint,\n p256dh: body.keys.p256dh,\n auth: body.keys.auth,\n userAgent: request.headers.get(\"user-agent\"),\n createdAt: new Date(),\n lastSeenAt: new Date(),\n },\n ],\n { onConflict: [\"endpoint\"] },\n );\n\n return Response.json({ ok: true });\n },\n },\n\n {\n path: \"/push/devices/remove\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const body = (await request.json().catch(() => null)) as { endpoint?: string } | null;\n if (!body?.endpoint) {\n return Response.json({ error: \"endpoint is required\" }, { status: 400 });\n }\n\n // Scoped by userId as well as endpoint: an endpoint string is not a\n // secret, and must not let one user unregister another's device.\n const removed = await ctx.store.remove(DEVICE, {\n userId: userId ?? \"\",\n endpoint: body.endpoint,\n });\n\n return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });\n },\n },\n\n {\n path: \"/push/prune\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => {\n const cutoff = new Date(Date.now() - staleAfterDays * 86_400_000);\n const removed = await ctx.store.remove(DEVICE, { lastSeenAt: { lt: cutoff } });\n return Response.json({ removed });\n },\n },\n ],\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/core/base64url.ts","../../src/core/handler.ts","../../src/core/runner.ts","../../src/plugins/push/index.ts"],"names":[],"mappings":"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","file":"push.cjs","sourcesContent":["// Web APIs, not Buffer: this runs on Workers and Vercel Edge.\n\nexport function encodeBase64Url(input: string | Uint8Array): string {\n const bytes = typeof input === \"string\" ? new TextEncoder().encode(input) : input;\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n// Uint8Array<ArrayBuffer>: bare Uint8Array widens and is not a BufferSource.\nexport function decodeBase64UrlBytes(value: string): Uint8Array<ArrayBuffer> | null {\n try {\n const binary = atob(value.replace(/-/g, \"+\").replace(/_/g, \"/\"));\n return Uint8Array.from(binary, (char) => char.charCodeAt(0));\n } catch {\n return null;\n }\n}\n\nexport function decodeBase64Url(value: string): string | null {\n const bytes = decodeBase64UrlBytes(value);\n return bytes ? new TextDecoder().decode(bytes) : null;\n}\n","import type { DatabaseAdapter } from \"./adapter\";\nimport type { SessionConfig } from \"./config\";\nimport type { Logger } from \"./errors\";\nimport type { Promisable, RouteDefinition } from \"./plugin\";\nimport type { Runner } from \"./runner\";\nimport { verifyToken } from \"./tokens\";\n\nexport type HandlerDeps = {\n adapter: DatabaseAdapter;\n session: SessionConfig;\n runner: Runner;\n logger: Logger;\n basePath: string;\n secret: string;\n cronSecret?: string | undefined;\n /** Guards plugin machine routes. The instance defaults it to cronSecret. */\n machineSecret?: string | undefined;\n cronMaxSweeps?: number | undefined;\n pluginRoutes?: readonly RouteDefinition[] | undefined;\n /** Returns a 429 to send, or null to proceed. Runs after onRequest, before routing. */\n rateLimit?: ((request: Request) => Response | null) | undefined;\n /** Origins other than the request's own host that may POST. `*.example.com` allowed. */\n trustedOrigins?: readonly string[] | undefined;\n /** Runs before routing. Returning a Response short-circuits; use it for rate limiting. */\n // biome-ignore lint/suspicious/noConfusingVoidType: a hook that returns nothing is the common case\n onRequest?: ((request: Request) => Promisable<Response | undefined | void>) | undefined;\n maxBodyBytes?: number | undefined;\n};\n\n/** Every JSON body is capped here. Nothing legitimate the routes accept is bigger. */\nexport const MAX_BODY_BYTES = 64 * 1024;\n\nconst MAX_READ_IDS = 200;\n\n// The inbox is per-user data: never let a shared cache keep it, never let a\n// browser sniff it into something executable.\nconst PRIVATE_HEADERS: Record<string, string> = {\n \"cache-control\": \"private, no-store\",\n \"x-content-type-options\": \"nosniff\",\n};\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json\", ...PRIVATE_HEADERS },\n });\n\nconst empty = (status: number) => new Response(null, { status, headers: PRIVATE_HEADERS });\n\n/** Plugin responses get the same headers, without touching their body. */\nfunction harden(response: Response): Response {\n const headers = new Headers(response.headers);\n for (const [name, value] of Object.entries(PRIVATE_HEADERS)) {\n if (!headers.has(name)) headers.set(name, value);\n }\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n\nconst encoder = new TextEncoder();\n\n/**\n * Constant-time comparison over SHA-256 digests. Node's timingSafeEqual is\n * unavailable on edge runtimes, `===` leaks through timing, and comparing the\n * raw strings would still reveal the secret's length.\n */\nasync function timingSafeEqual(a: string, b: string): Promise<boolean> {\n const [left, right] = await Promise.all(\n [a, b].map(\n async (value) => new Uint8Array(await crypto.subtle.digest(\"SHA-256\", encoder.encode(value))),\n ),\n );\n let diff = 0;\n for (let i = 0; i < 32; i += 1) diff |= (left?.[i] ?? 0) ^ (right?.[i] ?? 0);\n return diff === 0;\n}\n\n/** Anchored at a segment boundary: `/foo/api/notifications/cron` is not a cron request. */\nfunction routePath(url: URL, basePath: string): string {\n const { pathname } = url;\n if (pathname === basePath) return \"/\";\n if (pathname.startsWith(`${basePath}/`)) return pathname.slice(basePath.length);\n return pathname;\n}\n\nexport type JsonBody = { body: Record<string, unknown> } | { error: Response };\n\n/**\n * Reads a JSON object body with a byte cap enforced while streaming, so an\n * oversized body is refused before it is buffered. Malformed JSON is 400; an\n * empty body is `{}`. Plugin routes should use this rather than\n * `request.json()`, which has no limit.\n */\nexport async function readJsonBody(\n request: Request,\n maxBytes: number = MAX_BODY_BYTES,\n): Promise<JsonBody> {\n const tooLarge = () => ({ error: json({ error: `body exceeds ${maxBytes} bytes` }, 413) });\n\n const declared = Number(request.headers.get(\"content-length\"));\n if (Number.isFinite(declared) && declared > maxBytes) return tooLarge();\n if (!request.body) return { body: {} };\n\n const reader = request.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > maxBytes) {\n await reader.cancel();\n return tooLarge();\n }\n chunks.push(value);\n }\n\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.byteLength;\n }\n\n const text = new TextDecoder().decode(bytes);\n if (text.trim() === \"\") return { body: {} };\n\n try {\n const parsed: unknown = JSON.parse(text);\n return {\n body:\n parsed && typeof parsed === \"object\" && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {},\n };\n } catch {\n return { error: json({ error: \"body must be a JSON object\" }, 400) };\n }\n}\n\nconst isJsonContentType = (request: Request) =>\n /^application\\/json\\s*(;|$)/i.test((request.headers.get(\"content-type\") ?? \"\").trim());\n\nfunction originAllowed(origin: string, url: URL, request: Request, trusted: readonly string[]) {\n let host: string;\n try {\n host = new URL(origin).host;\n } catch {\n return false;\n }\n\n if (host === url.host) return true;\n // Behind a proxy that rewrites Host, the public host arrives here instead.\n const forwarded = request.headers.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim();\n if (forwarded && host === forwarded) return true;\n\n return trusted.some((entry) =>\n entry.startsWith(\"*.\") ? host.endsWith(entry.slice(1)) : entry === origin,\n );\n}\n\n/**\n * Two cheap CSRF defences for cookie-authenticated POSTs. A cross-site form\n * cannot send `application/json` without a preflight the browser will fail,\n * and it cannot forge `Origin`. Neither depends on the host app's SameSite\n * policy, which the library cannot see.\n */\nfunction csrfCheck(request: Request, url: URL, trusted: readonly string[]): Response | null {\n if (!isJsonContentType(request)) {\n return json({ error: \"content-type must be application/json\" }, 415);\n }\n\n const origin = request.headers.get(\"origin\");\n if (origin === null) return null;\n if (origin === \"null\" || !originAllowed(origin, url, request, trusted)) return empty(403);\n\n return null;\n}\n\nexport function createHandler(deps: HandlerDeps) {\n const trustedOrigins = deps.trustedOrigins ?? [];\n const maxBodyBytes = deps.maxBodyBytes ?? MAX_BODY_BYTES;\n\n async function resolveUserId(request: Request): Promise<string | null | \"error\"> {\n try {\n return await deps.session.getUserId(request);\n } catch (error) {\n // A broken session lookup is a 500, never a 401. Collapsing them makes\n // an outage look like a mass logout. RFC 0002 §1.\n deps.logger.error(\"session.getUserId threw\", { error });\n return \"error\";\n }\n }\n\n async function bearerMatches(request: Request, secret: string | undefined): Promise<boolean> {\n if (!secret) return false;\n const header = request.headers.get(\"authorization\") ?? \"\";\n const presented = header.startsWith(\"Bearer \") ? header.slice(7) : header;\n return timingSafeEqual(presented, secret);\n }\n\n async function handleCron(request: Request): Promise<Response> {\n if (!deps.cronSecret) return empty(404);\n if (!(await bearerMatches(request, deps.cronSecret))) return empty(401);\n\n // Bounded: an overlapping scheduler must not hold a request open for as\n // long as the backlog lasts.\n const result = await deps.runner.drain(\n deps.cronMaxSweeps === undefined ? {} : { maxSweeps: deps.cronMaxSweeps },\n );\n return json(result);\n }\n\n /**\n * Plugin routes are dispatched before core ones and carry their scope with\n * them. Authentication happens here rather than in the plugin handler, so a\n * plugin physically cannot forget to check a signature or scope a query.\n */\n async function handlePluginRoute(\n route: RouteDefinition,\n request: Request,\n url: URL,\n ): Promise<Response> {\n if (route.scope.type === \"user\") {\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === \"error\") return empty(500);\n if (userId === null) return empty(401);\n return harden(await route.handler({ request, userId, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"machine\") {\n if (!deps.machineSecret) return empty(404);\n if (!(await bearerMatches(request, deps.machineSecret))) return empty(401);\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"signed\") {\n const token =\n url.searchParams.get(\"token\") ??\n (request.headers.get(\"authorization\") ?? \"\").replace(/^Bearer /, \"\");\n\n const claims = await verifyToken(deps.secret, token, route.scope.purpose);\n // One response for every failure mode. Distinguishing expired from\n // forged tells an attacker which part to fix. RFC 0002 §5.\n if (!claims) return empty(400);\n\n return harden(await route.handler({ request, userId: claims.uid, claims, params: {} }));\n }\n\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n async function route(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const path = routePath(url, deps.basePath);\n\n if (deps.onRequest) {\n const early = await deps.onRequest(request);\n if (early instanceof Response) return early;\n }\n\n if (deps.rateLimit) {\n const limited = deps.rateLimit(request);\n if (limited) return limited;\n }\n\n if (path === \"/cron\") {\n if (request.method !== \"POST\") return empty(405);\n return handleCron(request);\n }\n\n const pluginRoute = deps.pluginRoutes?.find(\n (candidate) => candidate.path === path && candidate.method === request.method,\n );\n if (pluginRoute) return handlePluginRoute(pluginRoute, request, url);\n\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === \"error\") return empty(500);\n if (userId === null) return empty(401);\n\n if (request.method === \"GET\" && path === \"/\") {\n const limitParam = Number(url.searchParams.get(\"limit\") ?? 20);\n const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;\n const cursor = url.searchParams.get(\"cursor\");\n\n // The badge count rides along with the first page, so a polling client\n // needs one request per tick instead of two. Paginating past the first\n // page omits it: a cursor request is scrollback, not a poll.\n const [page, unseenCount] = await Promise.all([\n deps.adapter.listNotifications({\n userId,\n limit,\n ...(cursor ? { cursor } : {}),\n ...(url.searchParams.get(\"unreadOnly\") === \"true\" ? { unreadOnly: true } : {}),\n }),\n cursor ? Promise.resolve(null) : deps.adapter.countUnseen(userId),\n ]);\n\n return json(unseenCount === null ? page : { ...page, unseenCount });\n }\n\n if (request.method === \"GET\" && path === \"/count\") {\n return json({ unseen: await deps.adapter.countUnseen(userId) });\n }\n\n if (request.method === \"POST\" && path === \"/seen\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const before = typeof parsed.body.before === \"string\" ? new Date(parsed.body.before) : null;\n\n // Defaults to now, but a client should send the timestamp of the newest\n // notification it has actually rendered. Otherwise anything that arrived\n // between the last poll and the click is marked seen without ever having\n // produced a badge — the user silently misses it.\n //\n // +1ms because Postgres keeps microseconds while an ISO string carries\n // only milliseconds. Without it the truncated cutoff falls *before* the\n // very row it was taken from, and markSeen matches nothing at all.\n const cutoff =\n before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : new Date();\n\n await deps.adapter.markSeen(userId, cutoff);\n return json({ ok: true });\n }\n\n if (request.method === \"POST\" && path === \"/read\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const ids = Array.isArray(parsed.body.ids)\n ? parsed.body.ids.filter((id): id is string => typeof id === \"string\")\n : [];\n if (ids.length === 0) return json({ error: \"ids must be a non-empty string array\" }, 400);\n\n // Unbounded, this builds a query with as many bind parameters as the\n // caller cares to send. Postgres caps at 65535 and degrades long before.\n if (ids.length > MAX_READ_IDS) {\n return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);\n }\n\n // Scoped by userId in the adapter. A miss is 404, not 403 — a 403 would\n // confirm the row exists and belongs to someone else. RFC 0002 §2.\n const updated = await deps.adapter.markRead(userId, ids);\n return updated === 0 ? empty(404) : json({ updated });\n }\n\n if (request.method === \"POST\" && path === \"/read-all\") {\n return json({ updated: await deps.adapter.markAllRead(userId) });\n }\n\n return empty(404);\n }\n\n async function handle(request: Request): Promise<Response> {\n try {\n return await route(request);\n } catch (error) {\n // Never let a driver error escape: on Node it becomes an unhandled\n // rejection, on other hosts a stack trace in the response body.\n deps.logger.error(\"request failed\", { url: request.url, error });\n return empty(500);\n }\n }\n\n return {\n handle,\n GET: handle,\n POST: handle,\n };\n}\n","import type { ClaimedDelivery, DatabaseAdapter, DeliveryOutcome, DeliveryRelease } from \"./adapter\";\nimport { type Backoff, nextAttemptAt } from \"./backoff\";\nimport type { ChannelsConfig, Worker } from \"./config\";\nimport type { NotificationDefinitions } from \"./definition\";\nimport type { Logger } from \"./errors\";\nimport type { AnyPlugin } from \"./plugin\";\nimport type { Channel, Recipient } from \"./types\";\n\nexport type RunnerDeps = {\n adapter: DatabaseAdapter;\n definitions: NotificationDefinitions;\n channels: ChannelsConfig;\n plugins: readonly AnyPlugin[];\n getRecipients: (userIds: readonly string[]) => Promise<readonly Recipient[]>;\n logger: Logger;\n leaseMs: number;\n batchSize: number;\n backoff: Backoff;\n};\n\nexport type SweepResult = {\n claimed: number;\n sent: number;\n failed: number;\n /** Deliveries that had nothing to do. Not errors; see DeliveryOutcome. */\n skipped: number;\n};\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nasync function withTimeout<T>(\n run: (signal: AbortSignal) => Promise<T>,\n ms: number,\n label: string,\n): Promise<T> {\n const controller = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n const timeout = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n // Abort first: racing a promise leaves the original request running.\n controller.abort();\n reject(new Error(`${label} timed out after ${ms}ms`));\n }, ms);\n });\n\n try {\n return await Promise.race([run(controller.signal), timeout]);\n } finally {\n clearTimeout(timer);\n }\n}\n\n/**\n * What gets persisted to `last_error`. Provider responses quote the offending\n * field, which for a bad recipient is the address itself; that column outlives\n * the notification and is read by admin tooling, so addresses do not go in.\n */\nexport const redactErrorMessage = (text: string) =>\n text.replace(/[^\\s\"'<>()[\\]{},;]+@[^\\s\"'<>()[\\]{},;]+/g, \"[redacted-email]\");\n\nconst message = (error: unknown) =>\n redactErrorMessage(error instanceof Error ? error.message : String(error));\n\nexport function createRunner(deps: RunnerDeps) {\n async function deliverOne(\n delivery: ClaimedDelivery,\n recipient: Recipient | undefined,\n ): Promise<DeliveryOutcome> {\n if (!recipient) {\n return { result: \"failed\", error: \"recipient could not be resolved\", retryable: false };\n }\n\n if (delivery.channel === \"inApp\") {\n // The notification row is the delivery. Nothing to transmit.\n return { result: \"sent\" };\n }\n\n if (delivery.channel === \"email\") {\n const provider = deps.channels.email?.provider;\n if (!provider) {\n return { result: \"failed\", error: \"no email provider configured\", retryable: false };\n }\n // Captured before the closure below: narrowing does not survive into it.\n const to = recipient.email;\n if (!to) {\n return { result: \"failed\", error: \"recipient has no email address\", retryable: false };\n }\n\n const email = deps.definitions[delivery.notification.type]?.email;\n if (!email) {\n return {\n result: \"failed\",\n error: `notification type \"${delivery.notification.type}\" has no email template`,\n retryable: false,\n };\n }\n\n try {\n const payload = delivery.notification.payload as never;\n const html = await email.template(payload);\n\n await withTimeout(\n (signal) =>\n provider.send({\n to,\n subject: email.subject(payload),\n html,\n // Delivery is at-least-once; the provider dedupes on this.\n idempotencyKey: delivery.id,\n signal,\n }),\n provider.timeoutMs ?? 30_000,\n `${provider.name} send`,\n );\n\n return { result: \"sent\" };\n } catch (error) {\n return {\n result: \"failed\",\n error: message(error),\n retryable: provider.isRetryable(error),\n };\n }\n }\n\n const owner = deps.plugins.find(\n (plugin) => plugin.channels?.includes(delivery.channel) && plugin.hooks?.deliver,\n );\n if (!owner?.hooks?.deliver) {\n return {\n result: \"failed\",\n error: `no plugin delivers channel \"${delivery.channel}\"`,\n retryable: false,\n };\n }\n\n try {\n return await withTimeout(\n (signal) =>\n Promise.resolve(\n // biome-ignore lint/style/noNonNullAssertion: guarded directly above\n owner.hooks!.deliver!({\n channel: delivery.channel,\n deliveryId: delivery.id,\n attempt: delivery.attempts + 1,\n recipient,\n notification: {\n id: delivery.notificationId,\n userId: delivery.notification.userId,\n type: delivery.notification.type,\n payload: delivery.notification.payload,\n },\n signal,\n }),\n ),\n 30_000,\n `plugin \"${owner.id}\" deliver`,\n );\n } catch (error) {\n return { result: \"failed\", error: message(error), retryable: true };\n }\n }\n\n async function notifyAfterDeliver(delivery: ClaimedDelivery, outcome: DeliveryOutcome) {\n const isTerminal =\n outcome.result !== \"failed\" ||\n !outcome.retryable ||\n delivery.attempts + 1 >= delivery.maxAttempts;\n\n for (const plugin of deps.plugins) {\n const hook = plugin.hooks?.afterDeliver;\n if (!hook) continue;\n try {\n await hook({\n deliveryId: delivery.id,\n notificationId: delivery.notificationId,\n userId: delivery.notification.userId,\n channel: delivery.channel,\n outcome,\n attempt: delivery.attempts + 1,\n isTerminal,\n });\n } catch (error) {\n // Fail open. The message is already sent; a broken analytics hook must\n // not mark it failed and trigger a retry. RFC 0004 §5.\n deps.logger.error(`plugin \"${plugin.id}\" afterDeliver threw; ignoring`, {\n deliveryId: delivery.id,\n error,\n });\n }\n }\n }\n\n async function runOnce(\n options: { limit?: number; channels?: readonly Channel[]; ids?: readonly string[] } = {},\n ): Promise<SweepResult> {\n const claimToken = crypto.randomUUID();\n\n const claimed = await deps.adapter.claimPendingDeliveries({\n limit: options.limit ?? deps.batchSize,\n leaseMs: deps.leaseMs,\n claimToken,\n ...(options.channels ? { channels: options.channels } : {}),\n ...(options.ids ? { ids: options.ids } : {}),\n });\n\n if (claimed.length === 0) return { claimed: 0, sent: 0, failed: 0, skipped: 0 };\n\n const userIds = [...new Set(claimed.map((row) => row.notification.userId))];\n const recipients = await deps.getRecipients(userIds);\n const byUserId = new Map(recipients.map((recipient) => [recipient.userId, recipient]));\n\n const releases: DeliveryRelease[] = [];\n let sent = 0;\n let failed = 0;\n let skipped = 0;\n\n for (const delivery of claimed) {\n const outcome = await deliverOne(delivery, byUserId.get(delivery.notification.userId));\n\n if (outcome.result === \"sent\") sent += 1;\n else if (outcome.result === \"skipped\") skipped += 1;\n else failed += 1;\n\n releases.push({\n id: delivery.id,\n claimToken,\n outcome,\n ...(outcome.result === \"failed\" && outcome.retryable\n ? { nextAttemptAt: nextAttemptAt(delivery.attempts + 1, deps.backoff) }\n : {}),\n });\n\n await notifyAfterDeliver(delivery, outcome);\n }\n\n await deps.adapter.releaseDeliveries(releases);\n return { claimed: claimed.length, sent, failed, skipped };\n }\n\n /** Drains until empty. Pass `ids` to bound it to one send. */\n async function drain(\n options: { maxSweeps?: number; ids?: readonly string[] } = {},\n ): Promise<SweepResult> {\n const maxSweeps = options.maxSweeps ?? 50;\n const total: SweepResult = { claimed: 0, sent: 0, failed: 0, skipped: 0 };\n\n for (let sweep = 0; sweep < maxSweeps; sweep += 1) {\n const result = await runOnce(options.ids ? { ids: options.ids } : {});\n total.claimed += result.claimed;\n total.sent += result.sent;\n total.failed += result.failed;\n total.skipped += result.skipped;\n if (result.claimed === 0) break;\n }\n\n return total;\n }\n\n let warnedMissingWaitUntil = false;\n\n function scheduleDeferred(\n waitUntil?: (promise: Promise<unknown>) => void,\n ids?: readonly string[],\n ): void {\n if (waitUntil) {\n waitUntil(\n drain({ maxSweeps: 10, ...(ids ? { ids } : {}) }).catch((error) =>\n deps.logger.error(\"deferred sweep failed\", { error }),\n ),\n );\n return;\n }\n\n if (!warnedMissingWaitUntil) {\n warnedMissingWaitUntil = true;\n deps.logger.warn(\n \"delivery.mode is 'deferred' but no delivery.waitUntil was supplied — \" +\n \"falling back to the cron sweep. Notifications are not lost, but they \" +\n \"will not be delivered until POST /cron runs.\",\n );\n }\n }\n\n function startWorker(options: { intervalMs?: number; batchSize?: number } = {}): Worker {\n const intervalMs = options.intervalMs ?? 1_000;\n let stopped = false;\n\n const loop = (async () => {\n while (!stopped) {\n try {\n const result = await runOnce({ limit: options.batchSize ?? deps.batchSize });\n // Only idle when there was nothing to do; otherwise keep draining.\n if (result.claimed === 0) await sleep(intervalMs);\n } catch (error) {\n deps.logger.error(\"worker sweep failed\", { error });\n await sleep(intervalMs);\n }\n }\n })();\n\n return {\n stop: async () => {\n stopped = true;\n // Awaiting the loop lets the in-flight sweep finish and release its\n // leases, rather than leaving rows claimed until they expire.\n await loop;\n },\n };\n }\n\n return { runOnce, drain, scheduleDeferred, startWorker };\n}\n\nexport type Runner = ReturnType<typeof createRunner>;\n","import type { DeliveryOutcome } from \"../../core/adapter\";\nimport { decodeBase64UrlBytes } from \"../../core/base64url\";\nimport { readJsonBody } from \"../../core/handler\";\nimport type {\n DeliverContext,\n EasyPingPlugin,\n PluginInitContext,\n SchemaDeclaration,\n} from \"../../core/plugin\";\nimport { redactErrorMessage } from \"../../core/runner\";\n\nconst DEVICE = \"notification_push_device\";\n\nexport type PushSubscription = {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n};\n\nexport type PushMessage = {\n subscription: PushSubscription;\n title: string;\n body: string;\n data?: Record<string, unknown> | undefined;\n signal?: AbortSignal | undefined;\n};\n\nexport type PushSendResult = {\n /** True when the endpoint is permanently gone (404/410) and must be pruned. */\n expired?: boolean;\n retryable?: boolean;\n /** True when the subscription can never work — bad keys, unusable endpoint. Pruned like expired. */\n invalid?: boolean;\n};\n\nexport type PushProvider = {\n name: string;\n send(message: PushMessage): Promise<PushSendResult>;\n};\n\nexport type PushDevice = {\n id: string;\n userId: string;\n endpoint: string;\n p256dh: string;\n auth: string;\n userAgent: string | null;\n createdAt: Date;\n lastSeenAt: Date;\n};\n\nexport type PushOptions = {\n provider: PushProvider;\n /** Builds the notification shown on the device. */\n render: (input: { type: string; payload: unknown }) => { title: string; body: string };\n /** Devices untouched for this long are pruned on next delivery. */\n staleAfterDays?: number;\n /** Registering past this evicts the user's least recently seen device. Default 20. */\n maxDevicesPerUser?: number;\n /**\n * Hostnames an endpoint may point at, e.g. `[\"fcm.googleapis.com\",\n * \"*.notify.windows.com\"]`. Unset accepts any public https host, which is\n * what self-hosted UnifiedPush distributors need.\n */\n allowedEndpointHosts?: readonly string[];\n /** Accepts http and private hosts. For a local fake push service only; never in production. */\n allowInsecureEndpoints?: boolean;\n};\n\n/**\n * The tables this plugin owns, as a standalone value.\n *\n * Exported separately from `push()` so DDL can be rendered without building a\n * plugin instance — otherwise creating the tables means inventing a provider\n * and a render function you never intend to call.\n */\nexport const pushSchema = {\n pushDevice: {\n tableName: DEVICE,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n endpoint: { type: \"string\", required: true },\n p256dh: { type: \"string\", required: true },\n auth: { type: \"string\", required: true },\n userAgent: { type: \"string\" },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n lastSeenAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [\n { on: [\"userId\"], name: \"push_device_user_idx\" },\n // One row per endpoint: re-registering the same browser must update,\n // not duplicate, or every send goes out N times to one device.\n { on: [\"endpoint\"], unique: true, name: \"push_device_endpoint_idx\" },\n ],\n },\n} as const satisfies SchemaDeclaration;\n\nconst PRIVATE_IPV4 =\n /^(0\\.|10\\.|127\\.|169\\.254\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.|100\\.(6[4-9]|[7-9]\\d|1[01]\\d|12[0-7])\\.)/;\n\nfunction isInternalHost(hostname: string): boolean {\n const host = hostname.toLowerCase();\n if (host === \"localhost\" || /\\.(localhost|local|internal|lan)$/.test(host)) return true;\n if (/^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(host)) return PRIVATE_IPV4.test(host);\n if (host.startsWith(\"[\")) {\n const v6 = host.slice(1, -1);\n return v6 === \"::1\" || v6 === \"::\" || /^(fc|fd|fe[89ab])/.test(v6) || v6.startsWith(\"::ffff:\");\n }\n return false;\n}\n\nconst hostAllowed = (host: string, allowed: readonly string[]) =>\n allowed.some((entry) =>\n entry.startsWith(\"*.\") ? host.endsWith(entry.slice(1)) : host === entry.toLowerCase(),\n );\n\n/**\n * The server will POST to whatever is stored here, so this is the SSRF\n * boundary: public https only, optionally pinned to known push services.\n */\nexport function validateEndpoint(\n endpoint: string,\n allowedHosts?: readonly string[] | undefined,\n allowInsecure = false,\n): string | null {\n let url: URL;\n try {\n url = new URL(endpoint);\n } catch {\n return \"endpoint is not a valid URL\";\n }\n if (url.username || url.password) return \"endpoint must not carry credentials\";\n if (!allowInsecure) {\n if (url.protocol !== \"https:\") return \"endpoint must use https\";\n if (isInternalHost(url.hostname)) return \"endpoint must be a public host\";\n } else if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n return \"endpoint must be an http(s) URL\";\n }\n if (allowedHosts && !hostAllowed(url.hostname.toLowerCase(), allowedHosts)) {\n return \"endpoint host is not an allowed push service\";\n }\n return null;\n}\n\n/** RFC 8291: an uncompressed P-256 point and a 16-byte auth secret. */\nexport function validateSubscriptionKeys(keys: { p256dh: string; auth: string }): string | null {\n const p256dh = decodeBase64UrlBytes(keys.p256dh);\n if (p256dh?.length !== 65 || p256dh[0] !== 0x04) {\n return \"keys.p256dh must be a 65-byte uncompressed EC point\";\n }\n const auth = decodeBase64UrlBytes(keys.auth);\n if (auth?.length !== 16) return \"keys.auth must be 16 bytes\";\n return null;\n}\n\nconst isDuplicate = (error: unknown): boolean => {\n const code = (error as { code?: unknown })?.code;\n if (code === \"23505\" || code === 11000) return true;\n const text = error instanceof Error ? error.message : String(error);\n return /duplicate|unique/i.test(text);\n};\n\n/**\n * Web push over the device registry.\n *\n * Tokens are per device, not per user, so a delivery fans out and succeeds if\n * any endpoint accepts. Endpoints that report themselves gone are deleted\n * immediately: an unpruned registry accumulates dead subscriptions forever and\n * every send slows down behind them.\n */\nexport function push(options: PushOptions): EasyPingPlugin<\"push\"> {\n let ctx: PluginInitContext;\n const staleAfterDays = options.staleAfterDays ?? 180;\n const maxDevices = Math.max(1, options.maxDevicesPerUser ?? 20);\n\n return {\n id: \"push\",\n channels: [\"push\"],\n\n init: (context) => {\n ctx = context;\n },\n\n schema: pushSchema,\n\n hooks: {\n deliver: async (context: DeliverContext): Promise<DeliveryOutcome> => {\n const devices = await ctx.store.find<PushDevice>(DEVICE, {\n userId: context.notification.userId,\n });\n\n if (devices.length === 0) {\n // Not a failure: this person has simply not enabled push anywhere.\n return { result: \"skipped\", reason: \"no registered devices\" };\n }\n\n const content = options.render({\n type: context.notification.type,\n payload: context.notification.payload,\n });\n\n // In parallel: one dead endpoint must not hold the others, or the\n // sweep, up to the runner's timeout.\n const results = await Promise.allSettled(\n devices.map((device) =>\n options.provider.send({\n subscription: {\n endpoint: device.endpoint,\n keys: { p256dh: device.p256dh, auth: device.auth },\n },\n ...content,\n data: { notificationId: context.notification.id, type: context.notification.type },\n signal: context.signal,\n }),\n ),\n );\n\n const prune: string[] = [];\n let delivered = 0;\n let retryable = false;\n let lastError = \"\";\n\n results.forEach((result, index) => {\n const device = devices[index];\n if (!device) return;\n\n if (result.status === \"rejected\") {\n lastError = redactErrorMessage(\n result.reason instanceof Error ? result.reason.message : String(result.reason),\n );\n retryable = true;\n return;\n }\n\n if (result.value.expired || result.value.invalid) prune.push(device.id);\n else if (result.value.retryable) retryable = true;\n else delivered += 1;\n });\n\n if (prune.length > 0) await ctx.store.remove(DEVICE, { id: { in: prune } });\n\n if (delivered > 0) return { result: \"sent\" };\n\n // Every endpoint was gone: retrying cannot help, and the rows are\n // already deleted.\n if (prune.length === devices.length) {\n return { result: \"failed\", error: \"all devices expired\", retryable: false };\n }\n\n return {\n result: \"failed\",\n error: lastError || \"no device accepted the push\",\n retryable,\n };\n },\n },\n\n routes: [\n {\n path: \"/push/devices\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n\n const { endpoint, keys } = parsed.body as Partial<PushSubscription>;\n if (\n typeof endpoint !== \"string\" ||\n typeof keys?.p256dh !== \"string\" ||\n typeof keys?.auth !== \"string\"\n ) {\n return Response.json({ error: \"endpoint and keys are required\" }, { status: 400 });\n }\n\n const problem =\n validateEndpoint(\n endpoint,\n options.allowedEndpointHosts,\n options.allowInsecureEndpoints ?? false,\n ) ?? validateSubscriptionKeys(keys);\n if (problem) return Response.json({ error: problem }, { status: 400 });\n\n const owner = userId ?? \"\";\n const now = new Date();\n const userAgent = request.headers.get(\"user-agent\")?.slice(0, 512) ?? null;\n\n // An endpoint belongs to exactly one account for its whole life.\n // Upserting on endpoint alone let any caller re-home another user's\n // device by knowing its URL.\n const [existing] = await ctx.store.find<PushDevice>(DEVICE, { endpoint }, { limit: 1 });\n\n if (existing && existing.userId !== owner) {\n return Response.json(\n { error: \"endpoint is registered to another account\" },\n { status: 409 },\n );\n }\n\n if (existing) {\n await ctx.store.update(\n DEVICE,\n { id: existing.id },\n { p256dh: keys.p256dh, auth: keys.auth, userAgent, lastSeenAt: now },\n );\n return Response.json({ ok: true });\n }\n\n const devices = await ctx.store.find<PushDevice>(\n DEVICE,\n { userId: owner },\n { orderBy: { field: \"lastSeenAt\", direction: \"asc\" } },\n );\n const excess = devices.length - maxDevices + 1;\n if (excess > 0) {\n await ctx.store.remove(DEVICE, {\n id: { in: devices.slice(0, excess).map((device) => device.id) },\n });\n }\n\n try {\n await ctx.store.insert(DEVICE, [\n {\n id: crypto.randomUUID(),\n userId: owner,\n endpoint,\n p256dh: keys.p256dh,\n auth: keys.auth,\n userAgent,\n createdAt: now,\n lastSeenAt: now,\n },\n ]);\n } catch (error) {\n // Lost the race to the unique index: someone registered it first.\n if (isDuplicate(error)) {\n return Response.json(\n { error: \"endpoint is registered to another account\" },\n { status: 409 },\n );\n }\n throw error;\n }\n\n return Response.json({ ok: true });\n },\n },\n\n {\n path: \"/push/devices/remove\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n\n const { endpoint } = parsed.body;\n if (typeof endpoint !== \"string\" || endpoint === \"\") {\n return Response.json({ error: \"endpoint is required\" }, { status: 400 });\n }\n\n // Scoped by userId as well as endpoint: an endpoint string is not a\n // secret, and must not let one user unregister another's device.\n const removed = await ctx.store.remove(DEVICE, { userId: userId ?? \"\", endpoint });\n\n return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });\n },\n },\n\n {\n path: \"/push/prune\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => {\n const cutoff = new Date(Date.now() - staleAfterDays * 86_400_000);\n const removed = await ctx.store.remove(DEVICE, { lastSeenAt: { lt: cutoff } });\n return Response.json({ removed });\n },\n },\n ],\n };\n}\n"]}
|
package/dist/plugins/push.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { h as EasyPingPlugin } from '../plugin-
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-CiMsGlQe.cjs';
|
|
2
2
|
import '../adapter-BfLe2J0t.cjs';
|
|
3
3
|
|
|
4
4
|
type PushSubscription = {
|
|
@@ -19,6 +19,8 @@ type PushSendResult = {
|
|
|
19
19
|
/** True when the endpoint is permanently gone (404/410) and must be pruned. */
|
|
20
20
|
expired?: boolean;
|
|
21
21
|
retryable?: boolean;
|
|
22
|
+
/** True when the subscription can never work — bad keys, unusable endpoint. Pruned like expired. */
|
|
23
|
+
invalid?: boolean;
|
|
22
24
|
};
|
|
23
25
|
type PushProvider = {
|
|
24
26
|
name: string;
|
|
@@ -46,15 +48,17 @@ type PushOptions = {
|
|
|
46
48
|
};
|
|
47
49
|
/** Devices untouched for this long are pruned on next delivery. */
|
|
48
50
|
staleAfterDays?: number;
|
|
51
|
+
/** Registering past this evicts the user's least recently seen device. Default 20. */
|
|
52
|
+
maxDevicesPerUser?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Hostnames an endpoint may point at, e.g. `["fcm.googleapis.com",
|
|
55
|
+
* "*.notify.windows.com"]`. Unset accepts any public https host, which is
|
|
56
|
+
* what self-hosted UnifiedPush distributors need.
|
|
57
|
+
*/
|
|
58
|
+
allowedEndpointHosts?: readonly string[];
|
|
59
|
+
/** Accepts http and private hosts. For a local fake push service only; never in production. */
|
|
60
|
+
allowInsecureEndpoints?: boolean;
|
|
49
61
|
};
|
|
50
|
-
/**
|
|
51
|
-
* Web push over the device registry.
|
|
52
|
-
*
|
|
53
|
-
* Tokens are per device, not per user, so a delivery fans out and succeeds if
|
|
54
|
-
* any endpoint accepts. Endpoints that report themselves gone are deleted
|
|
55
|
-
* immediately: an unpruned registry accumulates dead subscriptions forever and
|
|
56
|
-
* every send slows down behind them.
|
|
57
|
-
*/
|
|
58
62
|
/**
|
|
59
63
|
* The tables this plugin owns, as a standalone value.
|
|
60
64
|
*
|
|
@@ -111,6 +115,24 @@ declare const pushSchema: {
|
|
|
111
115
|
}];
|
|
112
116
|
};
|
|
113
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* The server will POST to whatever is stored here, so this is the SSRF
|
|
120
|
+
* boundary: public https only, optionally pinned to known push services.
|
|
121
|
+
*/
|
|
122
|
+
declare function validateEndpoint(endpoint: string, allowedHosts?: readonly string[] | undefined, allowInsecure?: boolean): string | null;
|
|
123
|
+
/** RFC 8291: an uncompressed P-256 point and a 16-byte auth secret. */
|
|
124
|
+
declare function validateSubscriptionKeys(keys: {
|
|
125
|
+
p256dh: string;
|
|
126
|
+
auth: string;
|
|
127
|
+
}): string | null;
|
|
128
|
+
/**
|
|
129
|
+
* Web push over the device registry.
|
|
130
|
+
*
|
|
131
|
+
* Tokens are per device, not per user, so a delivery fans out and succeeds if
|
|
132
|
+
* any endpoint accepts. Endpoints that report themselves gone are deleted
|
|
133
|
+
* immediately: an unpruned registry accumulates dead subscriptions forever and
|
|
134
|
+
* every send slows down behind them.
|
|
135
|
+
*/
|
|
114
136
|
declare function push(options: PushOptions): EasyPingPlugin<"push">;
|
|
115
137
|
|
|
116
|
-
export { type PushDevice, type PushMessage, type PushOptions, type PushProvider, type PushSendResult, type PushSubscription, push, pushSchema };
|
|
138
|
+
export { type PushDevice, type PushMessage, type PushOptions, type PushProvider, type PushSendResult, type PushSubscription, push, pushSchema, validateEndpoint, validateSubscriptionKeys };
|
package/dist/plugins/push.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { h as EasyPingPlugin } from '../plugin-
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-C9Hoxus2.js';
|
|
2
2
|
import '../adapter-BfLe2J0t.js';
|
|
3
3
|
|
|
4
4
|
type PushSubscription = {
|
|
@@ -19,6 +19,8 @@ type PushSendResult = {
|
|
|
19
19
|
/** True when the endpoint is permanently gone (404/410) and must be pruned. */
|
|
20
20
|
expired?: boolean;
|
|
21
21
|
retryable?: boolean;
|
|
22
|
+
/** True when the subscription can never work — bad keys, unusable endpoint. Pruned like expired. */
|
|
23
|
+
invalid?: boolean;
|
|
22
24
|
};
|
|
23
25
|
type PushProvider = {
|
|
24
26
|
name: string;
|
|
@@ -46,15 +48,17 @@ type PushOptions = {
|
|
|
46
48
|
};
|
|
47
49
|
/** Devices untouched for this long are pruned on next delivery. */
|
|
48
50
|
staleAfterDays?: number;
|
|
51
|
+
/** Registering past this evicts the user's least recently seen device. Default 20. */
|
|
52
|
+
maxDevicesPerUser?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Hostnames an endpoint may point at, e.g. `["fcm.googleapis.com",
|
|
55
|
+
* "*.notify.windows.com"]`. Unset accepts any public https host, which is
|
|
56
|
+
* what self-hosted UnifiedPush distributors need.
|
|
57
|
+
*/
|
|
58
|
+
allowedEndpointHosts?: readonly string[];
|
|
59
|
+
/** Accepts http and private hosts. For a local fake push service only; never in production. */
|
|
60
|
+
allowInsecureEndpoints?: boolean;
|
|
49
61
|
};
|
|
50
|
-
/**
|
|
51
|
-
* Web push over the device registry.
|
|
52
|
-
*
|
|
53
|
-
* Tokens are per device, not per user, so a delivery fans out and succeeds if
|
|
54
|
-
* any endpoint accepts. Endpoints that report themselves gone are deleted
|
|
55
|
-
* immediately: an unpruned registry accumulates dead subscriptions forever and
|
|
56
|
-
* every send slows down behind them.
|
|
57
|
-
*/
|
|
58
62
|
/**
|
|
59
63
|
* The tables this plugin owns, as a standalone value.
|
|
60
64
|
*
|
|
@@ -111,6 +115,24 @@ declare const pushSchema: {
|
|
|
111
115
|
}];
|
|
112
116
|
};
|
|
113
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* The server will POST to whatever is stored here, so this is the SSRF
|
|
120
|
+
* boundary: public https only, optionally pinned to known push services.
|
|
121
|
+
*/
|
|
122
|
+
declare function validateEndpoint(endpoint: string, allowedHosts?: readonly string[] | undefined, allowInsecure?: boolean): string | null;
|
|
123
|
+
/** RFC 8291: an uncompressed P-256 point and a 16-byte auth secret. */
|
|
124
|
+
declare function validateSubscriptionKeys(keys: {
|
|
125
|
+
p256dh: string;
|
|
126
|
+
auth: string;
|
|
127
|
+
}): string | null;
|
|
128
|
+
/**
|
|
129
|
+
* Web push over the device registry.
|
|
130
|
+
*
|
|
131
|
+
* Tokens are per device, not per user, so a delivery fans out and succeeds if
|
|
132
|
+
* any endpoint accepts. Endpoints that report themselves gone are deleted
|
|
133
|
+
* immediately: an unpruned registry accumulates dead subscriptions forever and
|
|
134
|
+
* every send slows down behind them.
|
|
135
|
+
*/
|
|
114
136
|
declare function push(options: PushOptions): EasyPingPlugin<"push">;
|
|
115
137
|
|
|
116
|
-
export { type PushDevice, type PushMessage, type PushOptions, type PushProvider, type PushSendResult, type PushSubscription, push, pushSchema };
|
|
138
|
+
export { type PushDevice, type PushMessage, type PushOptions, type PushProvider, type PushSendResult, type PushSubscription, push, pushSchema, validateEndpoint, validateSubscriptionKeys };
|