easy-ping 0.3.0 → 0.5.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.
Files changed (127) hide show
  1. package/README.md +174 -82
  2. package/dist/adapter-B7R4rXtJ.d.cts +17 -0
  3. package/dist/adapter-B7R4rXtJ.d.ts +17 -0
  4. package/dist/{adapter-BfLe2J0t.d.cts → adapter-D3lTy-4E.d.cts} +6 -32
  5. package/dist/{adapter-BfLe2J0t.d.ts → adapter-D3lTy-4E.d.ts} +6 -32
  6. package/dist/adapters/drizzle.cjs +4 -4
  7. package/dist/adapters/drizzle.cjs.map +1 -1
  8. package/dist/adapters/drizzle.d.cts +35 -1
  9. package/dist/adapters/drizzle.d.ts +35 -1
  10. package/dist/adapters/drizzle.js +5 -5
  11. package/dist/adapters/drizzle.js.map +1 -1
  12. package/dist/adapters/mongodb.cjs +8 -7
  13. package/dist/adapters/mongodb.cjs.map +1 -1
  14. package/dist/adapters/mongodb.d.cts +4 -17
  15. package/dist/adapters/mongodb.d.ts +4 -17
  16. package/dist/adapters/mongodb.js +7 -7
  17. package/dist/adapters/mongodb.js.map +1 -1
  18. package/dist/adapters/mysql.cjs +605 -0
  19. package/dist/adapters/mysql.cjs.map +1 -0
  20. package/dist/adapters/mysql.d.cts +38 -0
  21. package/dist/adapters/mysql.d.ts +38 -0
  22. package/dist/adapters/mysql.js +51 -0
  23. package/dist/adapters/mysql.js.map +1 -0
  24. package/dist/adapters/postgres.cjs +19 -11
  25. package/dist/adapters/postgres.cjs.map +1 -1
  26. package/dist/adapters/postgres.d.cts +9 -54
  27. package/dist/adapters/postgres.d.ts +9 -54
  28. package/dist/adapters/postgres.js +6 -41
  29. package/dist/adapters/postgres.js.map +1 -1
  30. package/dist/adapters/sqlite.cjs +593 -0
  31. package/dist/adapters/sqlite.cjs.map +1 -0
  32. package/dist/adapters/sqlite.d.cts +37 -0
  33. package/dist/adapters/sqlite.d.ts +37 -0
  34. package/dist/adapters/sqlite.js +53 -0
  35. package/dist/adapters/sqlite.js.map +1 -0
  36. package/dist/browser.cjs.map +1 -1
  37. package/dist/browser.d.cts +2 -14
  38. package/dist/browser.d.ts +2 -14
  39. package/dist/browser.js.map +1 -1
  40. package/dist/chunk-2EYKPF5A.js +393 -0
  41. package/dist/chunk-2EYKPF5A.js.map +1 -0
  42. package/dist/{chunk-OZSOIX23.js → chunk-3VOQOGDR.js} +54 -12
  43. package/dist/chunk-3VOQOGDR.js.map +1 -0
  44. package/dist/{chunk-NYDVNMKG.js → chunk-AEYYTE5I.js} +4 -3
  45. package/dist/chunk-AEYYTE5I.js.map +1 -0
  46. package/dist/chunk-BECMTNK7.js +315 -0
  47. package/dist/chunk-BECMTNK7.js.map +1 -0
  48. package/dist/chunk-CK5UPVYP.js +48 -0
  49. package/dist/chunk-CK5UPVYP.js.map +1 -0
  50. package/dist/{chunk-BV3U542P.js → chunk-E4IZ2CQC.js} +2 -2
  51. package/dist/chunk-E4IZ2CQC.js.map +1 -0
  52. package/dist/chunk-GJNLOQJS.js +226 -0
  53. package/dist/chunk-GJNLOQJS.js.map +1 -0
  54. package/dist/chunk-HY47TQMY.js +52 -0
  55. package/dist/chunk-HY47TQMY.js.map +1 -0
  56. package/dist/chunk-M4R77BJD.js +64 -0
  57. package/dist/chunk-M4R77BJD.js.map +1 -0
  58. package/dist/{chunk-RBDW4S6F.js → chunk-XIDVLYNQ.js} +14 -4
  59. package/dist/chunk-XIDVLYNQ.js.map +1 -0
  60. package/dist/client.cjs.map +1 -1
  61. package/dist/client.js.map +1 -1
  62. package/dist/index.cjs +362 -64
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +56 -37
  65. package/dist/index.d.ts +56 -37
  66. package/dist/index.js +120 -359
  67. package/dist/index.js.map +1 -1
  68. package/dist/node.cjs +27 -6
  69. package/dist/node.cjs.map +1 -1
  70. package/dist/node.d.cts +8 -2
  71. package/dist/node.d.ts +8 -2
  72. package/dist/node.js +26 -6
  73. package/dist/node.js.map +1 -1
  74. package/dist/{plugin-CmPOFVmn.d.ts → plugin-CHd2ZiGC.d.cts} +37 -25
  75. package/dist/{plugin-f5KaFWSN.d.cts → plugin-Cg9usuUi.d.ts} +37 -25
  76. package/dist/plugins/digests.cjs +1 -4
  77. package/dist/plugins/digests.cjs.map +1 -1
  78. package/dist/plugins/digests.d.cts +6 -31
  79. package/dist/plugins/digests.d.ts +6 -31
  80. package/dist/plugins/digests.js +1 -4
  81. package/dist/plugins/digests.js.map +1 -1
  82. package/dist/plugins/preferences-client.cjs.map +1 -1
  83. package/dist/plugins/preferences-client.d.cts +2 -2
  84. package/dist/plugins/preferences-client.d.ts +2 -2
  85. package/dist/plugins/preferences-client.js +1 -1
  86. package/dist/plugins/preferences-react.cjs.map +1 -1
  87. package/dist/plugins/preferences-react.js.map +1 -1
  88. package/dist/plugins/preferences.cjs +101 -17
  89. package/dist/plugins/preferences.cjs.map +1 -1
  90. package/dist/plugins/preferences.d.cts +4 -2
  91. package/dist/plugins/preferences.d.ts +4 -2
  92. package/dist/plugins/preferences.js +36 -9
  93. package/dist/plugins/preferences.js.map +1 -1
  94. package/dist/plugins/push.cjs +198 -40
  95. package/dist/plugins/push.cjs.map +1 -1
  96. package/dist/plugins/push.d.cts +20 -18
  97. package/dist/plugins/push.d.ts +20 -18
  98. package/dist/plugins/push.js +141 -41
  99. package/dist/plugins/push.js.map +1 -1
  100. package/dist/providers/web-push.cjs +37 -11
  101. package/dist/providers/web-push.cjs.map +1 -1
  102. package/dist/providers/web-push.d.cts +10 -11
  103. package/dist/providers/web-push.d.ts +10 -11
  104. package/dist/providers/web-push.js +37 -12
  105. package/dist/providers/web-push.js.map +1 -1
  106. package/dist/react.cjs.map +1 -1
  107. package/dist/react.d.cts +1 -4
  108. package/dist/react.d.ts +1 -4
  109. package/dist/react.js.map +1 -1
  110. package/dist/schema.cjs +121 -2
  111. package/dist/schema.cjs.map +1 -1
  112. package/dist/schema.d.cts +25 -14
  113. package/dist/schema.d.ts +25 -14
  114. package/dist/schema.js +4 -2
  115. package/dist/testing.cjs +3 -19
  116. package/dist/testing.cjs.map +1 -1
  117. package/dist/testing.d.cts +4 -22
  118. package/dist/testing.d.ts +4 -22
  119. package/dist/testing.js +3 -19
  120. package/dist/testing.js.map +1 -1
  121. package/package.json +12 -1
  122. package/dist/chunk-BV3U542P.js.map +0 -1
  123. package/dist/chunk-NYDVNMKG.js.map +0 -1
  124. package/dist/chunk-OZSOIX23.js.map +0 -1
  125. package/dist/chunk-RBDW4S6F.js.map +0 -1
  126. package/dist/chunk-W3BMEPPQ.js +0 -50
  127. package/dist/chunk-W3BMEPPQ.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,367 +1,60 @@
1
- import { verifyToken } from './chunk-W3BMEPPQ.js';
2
- export { DEFAULT_TOKEN_TTL_SECONDS, expiresIn, signToken, verifyToken } from './chunk-W3BMEPPQ.js';
3
- export { defineClientPlugin, definePlugin } from './chunk-BV3U542P.js';
4
- import { consoleLogger, createPluginStore, ConfigError, ValidationError } from './chunk-OZSOIX23.js';
5
- export { ConfigError, EasyPingError, ValidationError, consoleLogger } from './chunk-OZSOIX23.js';
1
+ export { defineClientPlugin, definePlugin } from './chunk-E4IZ2CQC.js';
2
+ import { createRunner } from './chunk-GJNLOQJS.js';
3
+ export { createRunner, redactErrorMessage } from './chunk-GJNLOQJS.js';
4
+ import { createHandler, createScopedSigner } from './chunk-BECMTNK7.js';
5
+ export { DEFAULT_TOKEN_TTL_SECONDS, MAX_BODY_BYTES, MAX_TOKEN_TTL_SECONDS, expiresIn, readJsonBody, signToken, verifyToken } from './chunk-BECMTNK7.js';
6
+ import { consoleLogger, ConfigError, createPluginStore, ValidationError } from './chunk-3VOQOGDR.js';
7
+ export { ConfigError, EasyPingError, ValidationError, consoleLogger } from './chunk-3VOQOGDR.js';
8
+ import './chunk-AEYYTE5I.js';
6
9
  export { decodeBase64Url, encodeBase64Url } from './chunk-PTGHMCCG.js';
7
- import './chunk-NYDVNMKG.js';
8
10
 
9
11
  // src/core/definition.ts
10
12
  function defineNotification(definition) {
11
13
  return definition;
12
14
  }
13
15
 
14
- // src/core/handler.ts
15
- var json = (body, status = 200) => new Response(JSON.stringify(body), {
16
- status,
17
- headers: { "content-type": "application/json" }
18
- });
19
- var empty = (status) => new Response(null, { status });
20
- var MAX_READ_IDS = 200;
21
- function timingSafeEqual(a, b) {
22
- const encoder = new TextEncoder();
23
- const left = encoder.encode(a);
24
- const right = encoder.encode(b);
25
- let diff = left.length ^ right.length;
26
- const length = Math.max(left.length, right.length);
27
- for (let i = 0; i < length; i += 1) {
28
- diff |= (left[i] ?? 0) ^ (right[i] ?? 0);
29
- }
30
- return diff === 0;
31
- }
32
- function routePath(url, basePath) {
33
- const index = url.pathname.indexOf(basePath);
34
- if (index < 0) return url.pathname;
35
- return url.pathname.slice(index + basePath.length) || "/";
36
- }
37
- async function readJson(request) {
38
- try {
39
- const body = await request.json();
40
- return body && typeof body === "object" ? body : {};
41
- } catch {
42
- return {};
43
- }
44
- }
45
- function createHandler(deps) {
46
- async function resolveUserId(request) {
47
- try {
48
- return await deps.session.getUserId(request);
49
- } catch (error) {
50
- deps.logger.error("session.getUserId threw", { error });
51
- return "error";
52
- }
53
- }
54
- async function handleCron(request) {
55
- if (!deps.cronSecret) return empty(404);
56
- const header = request.headers.get("authorization") ?? "";
57
- const presented = header.startsWith("Bearer ") ? header.slice(7) : header;
58
- if (!timingSafeEqual(presented, deps.cronSecret)) return empty(401);
59
- const result = await deps.runner.drain();
60
- return json(result);
61
- }
62
- async function handlePluginRoute(route, request, url) {
63
- if (route.scope.type === "user") {
64
- const userId = await resolveUserId(request);
65
- if (userId === "error") return empty(500);
66
- if (userId === null) return empty(401);
67
- return route.handler({ request, userId, claims: null, params: {} });
68
- }
69
- if (route.scope.type === "machine") {
70
- if (!deps.cronSecret) return empty(404);
71
- const header = request.headers.get("authorization") ?? "";
72
- const presented = header.startsWith("Bearer ") ? header.slice(7) : header;
73
- if (!timingSafeEqual(presented, deps.cronSecret)) return empty(401);
74
- return route.handler({ request, userId: null, claims: null, params: {} });
75
- }
76
- if (route.scope.type === "signed") {
77
- const token = url.searchParams.get("token") ?? (request.headers.get("authorization") ?? "").replace(/^Bearer /, "");
78
- const claims = await verifyToken(deps.secret, token, route.scope.purpose);
79
- if (!claims) return empty(400);
80
- return route.handler({ request, userId: claims.uid, claims, params: {} });
81
- }
82
- return route.handler({ request, userId: null, claims: null, params: {} });
83
- }
84
- async function handle(request) {
85
- const url = new URL(request.url);
86
- const path = routePath(url, deps.basePath);
87
- if (path === "/cron") {
88
- if (request.method !== "POST") return empty(405);
89
- return handleCron(request);
90
- }
91
- const pluginRoute = deps.pluginRoutes?.find(
92
- (route) => route.path === path && route.method === request.method
93
- );
94
- if (pluginRoute) return handlePluginRoute(pluginRoute, request, url);
95
- const userId = await resolveUserId(request);
96
- if (userId === "error") return empty(500);
97
- if (userId === null) return empty(401);
98
- if (request.method === "GET" && path === "/") {
99
- const limitParam = Number(url.searchParams.get("limit") ?? 20);
100
- const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;
101
- const cursor = url.searchParams.get("cursor");
102
- const [page, unseenCount] = await Promise.all([
103
- deps.adapter.listNotifications({
104
- userId,
105
- limit,
106
- ...cursor ? { cursor } : {},
107
- ...url.searchParams.get("unreadOnly") === "true" ? { unreadOnly: true } : {}
108
- }),
109
- cursor ? Promise.resolve(null) : deps.adapter.countUnseen(userId)
110
- ]);
111
- return json(unseenCount === null ? page : { ...page, unseenCount });
112
- }
113
- if (request.method === "GET" && path === "/count") {
114
- return json({ unseen: await deps.adapter.countUnseen(userId) });
115
- }
116
- if (request.method === "POST" && path === "/seen") {
117
- const body = await readJson(request);
118
- const before = typeof body.before === "string" ? new Date(body.before) : null;
119
- const cutoff = before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : /* @__PURE__ */ new Date();
120
- await deps.adapter.markSeen(userId, cutoff);
121
- return json({ ok: true });
122
- }
123
- if (request.method === "POST" && path === "/read") {
124
- const body = await readJson(request);
125
- const ids = Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === "string") : [];
126
- if (ids.length === 0) return json({ error: "ids must be a non-empty string array" }, 400);
127
- if (ids.length > MAX_READ_IDS) {
128
- return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);
129
- }
130
- const updated = await deps.adapter.markRead(userId, ids);
131
- return updated === 0 ? empty(404) : json({ updated });
132
- }
133
- if (request.method === "POST" && path === "/read-all") {
134
- return json({ updated: await deps.adapter.markAllRead(userId) });
135
- }
136
- return empty(404);
137
- }
138
- return {
139
- handle,
140
- GET: handle,
141
- POST: handle
142
- };
143
- }
144
-
145
- // src/core/backoff.ts
146
- var SCHEDULE_MS = [3e4, 12e4, 48e4, 192e4];
147
- function backoffMs(attempt, strategy = "exponential") {
148
- if (typeof strategy === "function") return Math.max(0, strategy(attempt));
149
- const index = Math.min(Math.max(attempt, 1), SCHEDULE_MS.length) - 1;
150
- const base = SCHEDULE_MS[index] ?? SCHEDULE_MS[SCHEDULE_MS.length - 1] ?? 3e4;
151
- return Math.round(base * (0.8 + Math.random() * 0.4));
152
- }
153
- var nextAttemptAt = (attempt, strategy, now = /* @__PURE__ */ new Date()) => new Date(now.getTime() + backoffMs(attempt, strategy));
16
+ // src/core/html.ts
17
+ var ESCAPES = {
18
+ "&": "&amp;",
19
+ "<": "&lt;",
20
+ ">": "&gt;",
21
+ '"': "&quot;",
22
+ "'": "&#39;"
23
+ };
24
+ var escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
154
25
 
155
- // src/core/runner.ts
156
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
157
- async function withTimeout(run, ms, label) {
158
- const controller = new AbortController();
159
- let timer;
160
- const timeout = new Promise((_, reject) => {
161
- timer = setTimeout(() => {
162
- controller.abort();
163
- reject(new Error(`${label} timed out after ${ms}ms`));
164
- }, ms);
165
- });
166
- try {
167
- return await Promise.race([run(controller.signal), timeout]);
168
- } finally {
169
- clearTimeout(timer);
170
- }
171
- }
172
- var message = (error) => error instanceof Error ? error.message : String(error);
173
- function createRunner(deps) {
174
- async function deliverOne(delivery, recipient) {
175
- if (!recipient) {
176
- return { result: "failed", error: "recipient could not be resolved", retryable: false };
177
- }
178
- if (delivery.channel === "inApp") {
179
- return { result: "sent" };
180
- }
181
- if (delivery.channel === "email") {
182
- const provider = deps.channels.email?.provider;
183
- if (!provider) {
184
- return { result: "failed", error: "no email provider configured", retryable: false };
185
- }
186
- const to = recipient.email;
187
- if (!to) {
188
- return { result: "failed", error: "recipient has no email address", retryable: false };
189
- }
190
- const email = deps.definitions[delivery.notification.type]?.email;
191
- if (!email) {
192
- return {
193
- result: "failed",
194
- error: `notification type "${delivery.notification.type}" has no email template`,
195
- retryable: false
196
- };
197
- }
198
- try {
199
- const payload = delivery.notification.payload;
200
- const html = await email.template(payload);
201
- await withTimeout(
202
- (signal) => provider.send({
203
- to,
204
- subject: email.subject(payload),
205
- html,
206
- // Delivery is at-least-once; the provider dedupes on this.
207
- idempotencyKey: delivery.id,
208
- signal
209
- }),
210
- provider.timeoutMs ?? 3e4,
211
- `${provider.name} send`
212
- );
213
- return { result: "sent" };
214
- } catch (error) {
215
- return {
216
- result: "failed",
217
- error: message(error),
218
- retryable: provider.isRetryable(error)
219
- };
220
- }
221
- }
222
- const owner = deps.plugins.find(
223
- (plugin) => plugin.channels?.includes(delivery.channel) && plugin.hooks?.deliver
224
- );
225
- if (!owner?.hooks?.deliver) {
226
- return {
227
- result: "failed",
228
- error: `no plugin delivers channel "${delivery.channel}"`,
229
- retryable: false
230
- };
231
- }
232
- try {
233
- return await withTimeout(
234
- (signal) => Promise.resolve(
235
- // biome-ignore lint/style/noNonNullAssertion: guarded directly above
236
- owner.hooks.deliver({
237
- channel: delivery.channel,
238
- deliveryId: delivery.id,
239
- attempt: delivery.attempts + 1,
240
- recipient,
241
- notification: {
242
- id: delivery.notificationId,
243
- userId: delivery.notification.userId,
244
- type: delivery.notification.type,
245
- payload: delivery.notification.payload
246
- },
247
- signal
248
- })
249
- ),
250
- 3e4,
251
- `plugin "${owner.id}" deliver`
252
- );
253
- } catch (error) {
254
- return { result: "failed", error: message(error), retryable: true };
255
- }
256
- }
257
- async function notifyAfterDeliver(delivery, outcome) {
258
- const isTerminal = outcome.result !== "failed" || !outcome.retryable || delivery.attempts + 1 >= delivery.maxAttempts;
259
- for (const plugin of deps.plugins) {
260
- const hook = plugin.hooks?.afterDeliver;
261
- if (!hook) continue;
262
- try {
263
- await hook({
264
- deliveryId: delivery.id,
265
- notificationId: delivery.notificationId,
266
- userId: delivery.notification.userId,
267
- channel: delivery.channel,
268
- outcome,
269
- attempt: delivery.attempts + 1,
270
- isTerminal
271
- });
272
- } catch (error) {
273
- deps.logger.error(`plugin "${plugin.id}" afterDeliver threw; ignoring`, {
274
- deliveryId: delivery.id,
275
- error
276
- });
26
+ // src/core/rate-limit.ts
27
+ var clientAddress = (request) => {
28
+ const headers = request.headers;
29
+ return headers.get("cf-connecting-ip") ?? headers.get("x-real-ip") ?? headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
30
+ };
31
+ function createRateLimiter(config) {
32
+ const keyOf = config.key ?? clientAddress;
33
+ const buckets = /* @__PURE__ */ new Map();
34
+ let lastSweep = 0;
35
+ return (request) => {
36
+ const key = keyOf(request);
37
+ if (key === null) return null;
38
+ const now = Date.now();
39
+ if (now - lastSweep > config.windowMs) {
40
+ for (const [id, bucket2] of buckets) if (bucket2.resetAt <= now) buckets.delete(id);
41
+ lastSweep = now;
42
+ }
43
+ let bucket = buckets.get(key);
44
+ if (!bucket || bucket.resetAt <= now) {
45
+ bucket = { count: 0, resetAt: now + config.windowMs };
46
+ buckets.set(key, bucket);
47
+ }
48
+ bucket.count += 1;
49
+ if (bucket.count <= config.max) return null;
50
+ return new Response(null, {
51
+ status: 429,
52
+ headers: {
53
+ "retry-after": String(Math.max(1, Math.ceil((bucket.resetAt - now) / 1e3))),
54
+ "cache-control": "private, no-store"
277
55
  }
278
- }
279
- }
280
- async function runOnce(options = {}) {
281
- const claimToken = crypto.randomUUID();
282
- const claimed = await deps.adapter.claimPendingDeliveries({
283
- limit: options.limit ?? deps.batchSize,
284
- leaseMs: deps.leaseMs,
285
- claimToken,
286
- ...options.channels ? { channels: options.channels } : {},
287
- ...options.ids ? { ids: options.ids } : {}
288
56
  });
289
- if (claimed.length === 0) return { claimed: 0, sent: 0, failed: 0, skipped: 0 };
290
- const userIds = [...new Set(claimed.map((row) => row.notification.userId))];
291
- const recipients = await deps.getRecipients(userIds);
292
- const byUserId = new Map(recipients.map((recipient) => [recipient.userId, recipient]));
293
- const releases = [];
294
- let sent = 0;
295
- let failed = 0;
296
- let skipped = 0;
297
- for (const delivery of claimed) {
298
- const outcome = await deliverOne(delivery, byUserId.get(delivery.notification.userId));
299
- if (outcome.result === "sent") sent += 1;
300
- else if (outcome.result === "skipped") skipped += 1;
301
- else failed += 1;
302
- releases.push({
303
- id: delivery.id,
304
- claimToken,
305
- outcome,
306
- ...outcome.result === "failed" && outcome.retryable ? { nextAttemptAt: nextAttemptAt(delivery.attempts + 1, deps.backoff) } : {}
307
- });
308
- await notifyAfterDeliver(delivery, outcome);
309
- }
310
- await deps.adapter.releaseDeliveries(releases);
311
- return { claimed: claimed.length, sent, failed, skipped };
312
- }
313
- async function drain(options = {}) {
314
- const maxSweeps = options.maxSweeps ?? 50;
315
- const total = { claimed: 0, sent: 0, failed: 0, skipped: 0 };
316
- for (let sweep = 0; sweep < maxSweeps; sweep += 1) {
317
- const result = await runOnce(options.ids ? { ids: options.ids } : {});
318
- total.claimed += result.claimed;
319
- total.sent += result.sent;
320
- total.failed += result.failed;
321
- total.skipped += result.skipped;
322
- if (result.claimed === 0) break;
323
- }
324
- return total;
325
- }
326
- let warnedMissingWaitUntil = false;
327
- function scheduleDeferred(waitUntil, ids) {
328
- if (waitUntil) {
329
- waitUntil(
330
- drain({ maxSweeps: 10, ...ids ? { ids } : {} }).catch(
331
- (error) => deps.logger.error("deferred sweep failed", { error })
332
- )
333
- );
334
- return;
335
- }
336
- if (!warnedMissingWaitUntil) {
337
- warnedMissingWaitUntil = true;
338
- deps.logger.warn(
339
- "delivery.mode is 'deferred' but no delivery.waitUntil was supplied \u2014 falling back to the cron sweep. Notifications are not lost, but they will not be delivered until POST /cron runs."
340
- );
341
- }
342
- }
343
- function startWorker(options = {}) {
344
- const intervalMs = options.intervalMs ?? 1e3;
345
- let stopped = false;
346
- const loop = (async () => {
347
- while (!stopped) {
348
- try {
349
- const result = await runOnce({ limit: options.batchSize ?? deps.batchSize });
350
- if (result.claimed === 0) await sleep(intervalMs);
351
- } catch (error) {
352
- deps.logger.error("worker sweep failed", { error });
353
- await sleep(intervalMs);
354
- }
355
- }
356
- })();
357
- return {
358
- stop: async () => {
359
- stopped = true;
360
- await loop;
361
- }
362
- };
363
- }
364
- return { runOnce, drain, scheduleDeferred, startWorker };
57
+ };
365
58
  }
366
59
 
367
60
  // src/core/send.ts
@@ -504,8 +197,7 @@ async function runSend(type, input, deps) {
504
197
  id: newId(),
505
198
  channel: decision.channel,
506
199
  maxAttempts: definition.maxAttempts ?? deps.maxAttempts,
507
- // `defer` needs no machinery beyond not_before — the claim query
508
- // already refuses rows whose time has not come. RFC 0004 §4.2.
200
+ // defer is just not_before; the claim query refuses rows whose time has not come. RFC 0004 §4.2.
509
201
  notBefore: decision.action === "defer" && decision.notBefore ? decision.notBefore : now
510
202
  }));
511
203
  if (deliveries.length === 0) {
@@ -555,6 +247,14 @@ async function runSend(type, input, deps) {
555
247
  }
556
248
 
557
249
  // src/core/instance.ts
250
+ var CORE_ROUTES = [
251
+ { method: "GET", path: "/", scope: { type: "user" }, owner: "core" },
252
+ { method: "GET", path: "/count", scope: { type: "user" }, owner: "core" },
253
+ { method: "POST", path: "/seen", scope: { type: "user" }, owner: "core" },
254
+ { method: "POST", path: "/read", scope: { type: "user" }, owner: "core" },
255
+ { method: "POST", path: "/read-all", scope: { type: "user" }, owner: "core" },
256
+ { method: "POST", path: "/cron", scope: { type: "machine" }, owner: "core" }
257
+ ];
558
258
  var DEFAULTS = {
559
259
  maxAttempts: 5,
560
260
  leaseMs: 6e4,
@@ -562,6 +262,24 @@ var DEFAULTS = {
562
262
  basePath: "/api/notifications",
563
263
  mode: "cron"
564
264
  };
265
+ var MIN_SECRET_LENGTH = 16;
266
+ var PLACEHOLDER_SECRETS = /* @__PURE__ */ new Set([
267
+ "secret",
268
+ "changeme",
269
+ "change-me",
270
+ "replace-me",
271
+ "replace-me-too",
272
+ "password",
273
+ "demo-signing-secret",
274
+ "demo-cron-secret"
275
+ ]);
276
+ function checkSecretStrength(name, value) {
277
+ if (value.length < MIN_SECRET_LENGTH || PLACEHOLDER_SECRETS.has(value.toLowerCase())) {
278
+ throw new ConfigError(
279
+ `\`${name}\` must be at least ${MIN_SECRET_LENGTH} characters and not a placeholder. Generate one with \`openssl rand -base64 32\`.`
280
+ );
281
+ }
282
+ }
565
283
  function validate(config, plugins) {
566
284
  if (!config.database) throw new ConfigError("`database` is required.");
567
285
  if (!config.notifications) throw new ConfigError("`notifications` is required.");
@@ -575,6 +293,12 @@ function validate(config, plugins) {
575
293
  "`secret` is required \u2014 it signs unsubscribe and other session-less links. Adding it later is a breaking change, so it is mandatory from the start."
576
294
  );
577
295
  }
296
+ checkSecretStrength("secret", config.secret);
297
+ if (config.cron?.secret) checkSecretStrength("cron.secret", config.cron.secret);
298
+ if (config.machineSecret) checkSecretStrength("machineSecret", config.machineSecret);
299
+ if (config.rateLimit && (config.rateLimit.max < 1 || config.rateLimit.windowMs < 1)) {
300
+ throw new ConfigError("`rateLimit.max` and `rateLimit.windowMs` must both be positive.");
301
+ }
578
302
  if (typeof config.getRecipients !== "function") {
579
303
  throw new ConfigError("`getRecipients` is required to resolve emails and timezones.");
580
304
  }
@@ -643,6 +367,15 @@ function easyPing(config) {
643
367
  "delivery.mode is 'deferred' but no delivery.waitUntil was supplied; delivery will wait for the cron sweep."
644
368
  );
645
369
  }
370
+ for (const plugin of plugins) {
371
+ for (const route of plugin.routes ?? []) {
372
+ if (route.scope.type === "custom") {
373
+ warnings.push(
374
+ `plugin "${plugin.id}" mounts ${route.method} ${route.path} with custom auth: ` + route.scope.justification
375
+ );
376
+ }
377
+ }
378
+ }
646
379
  for (const warning of warnings) logger.warn(warning);
647
380
  const runner = createRunner({
648
381
  adapter: config.database,
@@ -664,7 +397,13 @@ function easyPing(config) {
664
397
  secret: config.secret,
665
398
  basePath: config.basePath ?? DEFAULTS.basePath,
666
399
  pluginRoutes,
667
- ...config.cron?.secret ? { cronSecret: config.cron.secret } : {}
400
+ trustedOrigins: config.trustedOrigins,
401
+ onRequest: config.onRequest,
402
+ maxBodyBytes: config.maxBodyBytes,
403
+ rateLimit: config.rateLimit ? createRateLimiter(config.rateLimit) : void 0,
404
+ cronMaxSweeps: config.cron?.maxSweeps,
405
+ ...config.cron?.secret ? { cronSecret: config.cron.secret } : {},
406
+ ...config.machineSecret ?? config.cron?.secret ? { machineSecret: config.machineSecret ?? config.cron?.secret } : {}
668
407
  });
669
408
  async function send(type, args) {
670
409
  const result = await runSend(
@@ -696,10 +435,21 @@ function easyPing(config) {
696
435
  return result;
697
436
  }
698
437
  for (const plugin of plugins) {
438
+ const purposes = new Set(
439
+ (plugin.routes ?? []).flatMap(
440
+ (route) => route.scope.type === "signed" ? [route.scope.purpose] : []
441
+ )
442
+ );
699
443
  plugin.init?.({
700
444
  store: createPluginStore(plugin.id, plugin.schema, config.database, tablePrefix),
701
- secret: config.secret,
445
+ sign: createScopedSigner(
446
+ config.secret,
447
+ plugin.id,
448
+ purposes,
449
+ (message) => new ConfigError(message)
450
+ ),
702
451
  logger,
452
+ notificationTypes: Object.keys(config.notifications),
703
453
  getRecipients: config.getRecipients,
704
454
  send: (type, args) => send(type, args)
705
455
  });
@@ -708,6 +458,17 @@ function easyPing(config) {
708
458
  send,
709
459
  handler,
710
460
  startWorker: (options) => runner.startWorker(options ?? {}),
461
+ listRoutes: () => [
462
+ ...CORE_ROUTES.filter((route) => route.path !== "/cron" || Boolean(config.cron?.secret)),
463
+ ...plugins.flatMap(
464
+ (plugin) => (plugin.routes ?? []).map((route) => ({
465
+ method: route.method,
466
+ path: route.path,
467
+ scope: route.scope,
468
+ owner: plugin.id
469
+ }))
470
+ )
471
+ ],
711
472
  getFailedDeliveries: (options = {}) => config.database.getFailedDeliveries({
712
473
  since: options.since ?? new Date(Date.now() - 24 * 36e5),
713
474
  limit: Math.min(options.limit ?? 100, 1e3)
@@ -724,6 +485,6 @@ function easyPing(config) {
724
485
  };
725
486
  }
726
487
 
727
- export { createRunner, defineNotification, easyPing };
488
+ export { MIN_SECRET_LENGTH, createRateLimiter, defineNotification, easyPing, escapeHtml };
728
489
  //# sourceMappingURL=index.js.map
729
490
  //# sourceMappingURL=index.js.map