easy-ping 0.2.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.
Files changed (97) hide show
  1. package/README.md +15 -4
  2. package/dist/{adapter-f0OR2DhY.d.cts → adapter-BfLe2J0t.d.cts} +10 -0
  3. package/dist/{adapter-f0OR2DhY.d.ts → adapter-BfLe2J0t.d.ts} +10 -0
  4. package/dist/adapters/drizzle.cjs +9 -4
  5. package/dist/adapters/drizzle.cjs.map +1 -1
  6. package/dist/adapters/drizzle.d.cts +35 -1
  7. package/dist/adapters/drizzle.d.ts +35 -1
  8. package/dist/adapters/drizzle.js +10 -5
  9. package/dist/adapters/drizzle.js.map +1 -1
  10. package/dist/adapters/mongodb.cjs +9 -4
  11. package/dist/adapters/mongodb.cjs.map +1 -1
  12. package/dist/adapters/mongodb.d.cts +2 -2
  13. package/dist/adapters/mongodb.d.ts +2 -2
  14. package/dist/adapters/mongodb.js +8 -4
  15. package/dist/adapters/mongodb.js.map +1 -1
  16. package/dist/adapters/postgres.cjs +136 -3
  17. package/dist/adapters/postgres.cjs.map +1 -1
  18. package/dist/adapters/postgres.d.cts +45 -2
  19. package/dist/adapters/postgres.d.ts +45 -2
  20. package/dist/adapters/postgres.js +38 -5
  21. package/dist/adapters/postgres.js.map +1 -1
  22. package/dist/{chunk-OZSOIX23.js → chunk-3Y2MBUAW.js} +53 -12
  23. package/dist/chunk-3Y2MBUAW.js.map +1 -0
  24. package/dist/chunk-CYKAM7LE.js +226 -0
  25. package/dist/chunk-CYKAM7LE.js.map +1 -0
  26. package/dist/chunk-DG3ZL5NP.js +315 -0
  27. package/dist/chunk-DG3ZL5NP.js.map +1 -0
  28. package/dist/{chunk-NYDVNMKG.js → chunk-PSJHXZPR.js} +4 -3
  29. package/dist/chunk-PSJHXZPR.js.map +1 -0
  30. package/dist/{chunk-BV3U542P.js → chunk-W6I3B2CI.js} +2 -2
  31. package/dist/chunk-W6I3B2CI.js.map +1 -0
  32. package/dist/chunk-YEMRRMJQ.js +199 -0
  33. package/dist/chunk-YEMRRMJQ.js.map +1 -0
  34. package/dist/client.cjs +3 -5
  35. package/dist/client.cjs.map +1 -1
  36. package/dist/client.js +3 -5
  37. package/dist/client.js.map +1 -1
  38. package/dist/index.cjs +377 -73
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.d.cts +88 -22
  41. package/dist/index.d.ts +88 -22
  42. package/dist/index.js +119 -351
  43. package/dist/index.js.map +1 -1
  44. package/dist/node.cjs +27 -6
  45. package/dist/node.cjs.map +1 -1
  46. package/dist/node.d.cts +8 -2
  47. package/dist/node.d.ts +8 -2
  48. package/dist/node.js +26 -6
  49. package/dist/node.js.map +1 -1
  50. package/dist/{plugin-Bqj9xAOS.d.cts → plugin-C9Hoxus2.d.ts} +38 -10
  51. package/dist/{plugin-bTJKmnlq.d.ts → plugin-CiMsGlQe.d.cts} +38 -10
  52. package/dist/plugins/digests.d.cts +2 -2
  53. package/dist/plugins/digests.d.ts +2 -2
  54. package/dist/plugins/preferences-client.cjs.map +1 -1
  55. package/dist/plugins/preferences-client.d.cts +2 -2
  56. package/dist/plugins/preferences-client.d.ts +2 -2
  57. package/dist/plugins/preferences-client.js +1 -1
  58. package/dist/plugins/preferences.cjs +100 -15
  59. package/dist/plugins/preferences.cjs.map +1 -1
  60. package/dist/plugins/preferences.d.cts +4 -2
  61. package/dist/plugins/preferences.d.ts +4 -2
  62. package/dist/plugins/preferences.js +35 -7
  63. package/dist/plugins/preferences.js.map +1 -1
  64. package/dist/plugins/push.cjs +222 -61
  65. package/dist/plugins/push.cjs.map +1 -1
  66. package/dist/plugins/push.d.cts +81 -3
  67. package/dist/plugins/push.d.ts +81 -3
  68. package/dist/plugins/push.js +164 -62
  69. package/dist/plugins/push.js.map +1 -1
  70. package/dist/providers/web-push.cjs +37 -11
  71. package/dist/providers/web-push.cjs.map +1 -1
  72. package/dist/providers/web-push.d.cts +9 -3
  73. package/dist/providers/web-push.d.ts +9 -3
  74. package/dist/providers/web-push.js +37 -12
  75. package/dist/providers/web-push.js.map +1 -1
  76. package/dist/react.cjs +3 -5
  77. package/dist/react.cjs.map +1 -1
  78. package/dist/react.js +3 -5
  79. package/dist/react.js.map +1 -1
  80. package/dist/schema.cjs +2 -1
  81. package/dist/schema.cjs.map +1 -1
  82. package/dist/schema.d.cts +7 -2
  83. package/dist/schema.d.ts +7 -2
  84. package/dist/schema.js +2 -198
  85. package/dist/schema.js.map +1 -1
  86. package/dist/testing.cjs +29 -0
  87. package/dist/testing.cjs.map +1 -1
  88. package/dist/testing.d.cts +1 -1
  89. package/dist/testing.d.ts +1 -1
  90. package/dist/testing.js +29 -0
  91. package/dist/testing.js.map +1 -1
  92. package/package.json +1 -1
  93. package/dist/chunk-BV3U542P.js.map +0 -1
  94. package/dist/chunk-NYDVNMKG.js.map +0 -1
  95. package/dist/chunk-OZSOIX23.js.map +0 -1
  96. package/dist/chunk-W3BMEPPQ.js +0 -50
  97. package/dist/chunk-W3BMEPPQ.js.map +0 -1
package/dist/index.cjs CHANGED
@@ -53,18 +53,27 @@ var consoleLogger = {
53
53
 
54
54
  // src/core/tokens.ts
55
55
  var encoder = new TextEncoder();
56
- async function hmacKey(secret) {
57
- return crypto.subtle.importKey(
58
- "raw",
59
- encoder.encode(secret),
60
- { name: "HMAC", hash: "SHA-256" },
56
+ var KEY_SALT = encoder.encode("easy-ping/tokens/v1");
57
+ async function hmacKey(secret, purpose) {
58
+ const master = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, [
59
+ "deriveKey"
60
+ ]);
61
+ return crypto.subtle.deriveKey(
62
+ { name: "HKDF", hash: "SHA-256", salt: KEY_SALT, info: encoder.encode(purpose) },
63
+ master,
64
+ { name: "HMAC", hash: "SHA-256", length: 256 },
61
65
  false,
62
66
  ["sign", "verify"]
63
67
  );
64
68
  }
65
- async function signToken(secret, claims) {
66
- const body = encodeBase64Url(JSON.stringify(claims));
67
- const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder.encode(body));
69
+ async function signToken(secret, claims, now = /* @__PURE__ */ new Date()) {
70
+ const stamped = { ...claims, iat: claims.iat ?? Math.floor(now.getTime() / 1e3) };
71
+ const body = encodeBase64Url(JSON.stringify(stamped));
72
+ const signature = await crypto.subtle.sign(
73
+ "HMAC",
74
+ await hmacKey(secret, claims.purpose),
75
+ encoder.encode(body)
76
+ );
68
77
  return `${body}.${encodeBase64Url(new Uint8Array(signature))}`;
69
78
  }
70
79
  async function verifyToken(secret, token, purpose, now = /* @__PURE__ */ new Date()) {
@@ -75,7 +84,7 @@ async function verifyToken(secret, token, purpose, now = /* @__PURE__ */ new Dat
75
84
  if (!signature) return null;
76
85
  const valid = await crypto.subtle.verify(
77
86
  "HMAC",
78
- await hmacKey(secret),
87
+ await hmacKey(secret, purpose),
79
88
  signature,
80
89
  encoder.encode(body)
81
90
  );
@@ -91,43 +100,138 @@ async function verifyToken(secret, token, purpose, now = /* @__PURE__ */ new Dat
91
100
  if (typeof claims?.uid !== "string" || typeof claims.exp !== "number") return null;
92
101
  if (claims.purpose !== purpose) return null;
93
102
  if (claims.exp * 1e3 <= now.getTime()) return null;
103
+ if (claims.iat !== void 0 && typeof claims.iat !== "number") return null;
104
+ if (claims.data !== void 0) {
105
+ if (typeof claims.data !== "object" || claims.data === null || Array.isArray(claims.data)) {
106
+ return null;
107
+ }
108
+ for (const value of Object.values(claims.data)) {
109
+ if (typeof value !== "string") return null;
110
+ }
111
+ }
94
112
  return claims;
95
113
  }
96
114
  var DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
97
- var expiresIn = (seconds, now = /* @__PURE__ */ new Date()) => Math.floor(now.getTime() / 1e3) + seconds;
115
+ var MAX_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;
116
+ var expiresIn = (seconds, now = /* @__PURE__ */ new Date()) => Math.floor(now.getTime() / 1e3) + Math.min(Math.max(seconds, 0), MAX_TOKEN_TTL_SECONDS);
117
+ function createScopedSigner(secret, pluginId, purposes, onViolation) {
118
+ return (request) => {
119
+ if (!purposes.has(request.purpose)) {
120
+ throw onViolation(
121
+ `plugin "${pluginId}" tried to sign a token for purpose "${request.purpose}", which none of its signed routes declare.`
122
+ );
123
+ }
124
+ return signToken(secret, {
125
+ uid: request.uid,
126
+ purpose: request.purpose,
127
+ exp: expiresIn(request.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS),
128
+ ...request.data ? { data: request.data } : {}
129
+ });
130
+ };
131
+ }
98
132
 
99
133
  // src/core/handler.ts
134
+ var MAX_BODY_BYTES = 64 * 1024;
135
+ var MAX_READ_IDS = 200;
136
+ var PRIVATE_HEADERS = {
137
+ "cache-control": "private, no-store",
138
+ "x-content-type-options": "nosniff"
139
+ };
100
140
  var json = (body, status = 200) => new Response(JSON.stringify(body), {
101
141
  status,
102
- headers: { "content-type": "application/json" }
142
+ headers: { "content-type": "application/json", ...PRIVATE_HEADERS }
103
143
  });
104
- var empty = (status) => new Response(null, { status });
105
- var MAX_READ_IDS = 200;
106
- function timingSafeEqual(a, b) {
107
- const encoder2 = new TextEncoder();
108
- const left = encoder2.encode(a);
109
- const right = encoder2.encode(b);
110
- let diff = left.length ^ right.length;
111
- const length = Math.max(left.length, right.length);
112
- for (let i = 0; i < length; i += 1) {
113
- diff |= (left[i] ?? 0) ^ (right[i] ?? 0);
144
+ var empty = (status) => new Response(null, { status, headers: PRIVATE_HEADERS });
145
+ function harden(response) {
146
+ const headers = new Headers(response.headers);
147
+ for (const [name, value] of Object.entries(PRIVATE_HEADERS)) {
148
+ if (!headers.has(name)) headers.set(name, value);
114
149
  }
150
+ return new Response(response.body, {
151
+ status: response.status,
152
+ statusText: response.statusText,
153
+ headers
154
+ });
155
+ }
156
+ var encoder2 = new TextEncoder();
157
+ async function timingSafeEqual(a, b) {
158
+ const [left, right] = await Promise.all(
159
+ [a, b].map(
160
+ async (value) => new Uint8Array(await crypto.subtle.digest("SHA-256", encoder2.encode(value)))
161
+ )
162
+ );
163
+ let diff = 0;
164
+ for (let i = 0; i < 32; i += 1) diff |= (left?.[i] ?? 0) ^ (right?.[i] ?? 0);
115
165
  return diff === 0;
116
166
  }
117
167
  function routePath(url, basePath) {
118
- const index = url.pathname.indexOf(basePath);
119
- if (index < 0) return url.pathname;
120
- return url.pathname.slice(index + basePath.length) || "/";
168
+ const { pathname } = url;
169
+ if (pathname === basePath) return "/";
170
+ if (pathname.startsWith(`${basePath}/`)) return pathname.slice(basePath.length);
171
+ return pathname;
121
172
  }
122
- async function readJson(request) {
173
+ async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
174
+ const tooLarge = () => ({ error: json({ error: `body exceeds ${maxBytes} bytes` }, 413) });
175
+ const declared = Number(request.headers.get("content-length"));
176
+ if (Number.isFinite(declared) && declared > maxBytes) return tooLarge();
177
+ if (!request.body) return { body: {} };
178
+ const reader = request.body.getReader();
179
+ const chunks = [];
180
+ let total = 0;
181
+ for (; ; ) {
182
+ const { done, value } = await reader.read();
183
+ if (done) break;
184
+ total += value.byteLength;
185
+ if (total > maxBytes) {
186
+ await reader.cancel();
187
+ return tooLarge();
188
+ }
189
+ chunks.push(value);
190
+ }
191
+ const bytes = new Uint8Array(total);
192
+ let offset = 0;
193
+ for (const chunk of chunks) {
194
+ bytes.set(chunk, offset);
195
+ offset += chunk.byteLength;
196
+ }
197
+ const text = new TextDecoder().decode(bytes);
198
+ if (text.trim() === "") return { body: {} };
123
199
  try {
124
- const body = await request.json();
125
- return body && typeof body === "object" ? body : {};
200
+ const parsed = JSON.parse(text);
201
+ return {
202
+ body: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
203
+ };
204
+ } catch {
205
+ return { error: json({ error: "body must be a JSON object" }, 400) };
206
+ }
207
+ }
208
+ var isJsonContentType = (request) => /^application\/json\s*(;|$)/i.test((request.headers.get("content-type") ?? "").trim());
209
+ function originAllowed(origin, url, request, trusted) {
210
+ let host;
211
+ try {
212
+ host = new URL(origin).host;
126
213
  } catch {
127
- return {};
214
+ return false;
128
215
  }
216
+ if (host === url.host) return true;
217
+ const forwarded = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
218
+ if (forwarded && host === forwarded) return true;
219
+ return trusted.some(
220
+ (entry) => entry.startsWith("*.") ? host.endsWith(entry.slice(1)) : entry === origin
221
+ );
222
+ }
223
+ function csrfCheck(request, url, trusted) {
224
+ if (!isJsonContentType(request)) {
225
+ return json({ error: "content-type must be application/json" }, 415);
226
+ }
227
+ const origin = request.headers.get("origin");
228
+ if (origin === null) return null;
229
+ if (origin === "null" || !originAllowed(origin, url, request, trusted)) return empty(403);
230
+ return null;
129
231
  }
130
232
  function createHandler(deps) {
233
+ const trustedOrigins = deps.trustedOrigins ?? [];
234
+ const maxBodyBytes = deps.maxBodyBytes ?? MAX_BODY_BYTES;
131
235
  async function resolveUserId(request) {
132
236
  try {
133
237
  return await deps.session.getUserId(request);
@@ -136,47 +240,67 @@ function createHandler(deps) {
136
240
  return "error";
137
241
  }
138
242
  }
139
- async function handleCron(request) {
140
- if (!deps.cronSecret) return empty(404);
243
+ async function bearerMatches(request, secret) {
244
+ if (!secret) return false;
141
245
  const header = request.headers.get("authorization") ?? "";
142
246
  const presented = header.startsWith("Bearer ") ? header.slice(7) : header;
143
- if (!timingSafeEqual(presented, deps.cronSecret)) return empty(401);
144
- const result = await deps.runner.drain();
247
+ return timingSafeEqual(presented, secret);
248
+ }
249
+ async function handleCron(request) {
250
+ if (!deps.cronSecret) return empty(404);
251
+ if (!await bearerMatches(request, deps.cronSecret)) return empty(401);
252
+ const result = await deps.runner.drain(
253
+ deps.cronMaxSweeps === void 0 ? {} : { maxSweeps: deps.cronMaxSweeps }
254
+ );
145
255
  return json(result);
146
256
  }
147
- async function handlePluginRoute(route, request, url) {
148
- if (route.scope.type === "user") {
257
+ async function handlePluginRoute(route2, request, url) {
258
+ if (route2.scope.type === "user") {
259
+ if (request.method === "POST") {
260
+ const rejected = csrfCheck(request, url, trustedOrigins);
261
+ if (rejected) return rejected;
262
+ }
149
263
  const userId = await resolveUserId(request);
150
264
  if (userId === "error") return empty(500);
151
265
  if (userId === null) return empty(401);
152
- return route.handler({ request, userId, claims: null, params: {} });
266
+ return harden(await route2.handler({ request, userId, claims: null, params: {} }));
153
267
  }
154
- if (route.scope.type === "machine") {
155
- if (!deps.cronSecret) return empty(404);
156
- const header = request.headers.get("authorization") ?? "";
157
- const presented = header.startsWith("Bearer ") ? header.slice(7) : header;
158
- if (!timingSafeEqual(presented, deps.cronSecret)) return empty(401);
159
- return route.handler({ request, userId: null, claims: null, params: {} });
268
+ if (route2.scope.type === "machine") {
269
+ if (!deps.machineSecret) return empty(404);
270
+ if (!await bearerMatches(request, deps.machineSecret)) return empty(401);
271
+ return harden(await route2.handler({ request, userId: null, claims: null, params: {} }));
160
272
  }
161
- if (route.scope.type === "signed") {
273
+ if (route2.scope.type === "signed") {
162
274
  const token = url.searchParams.get("token") ?? (request.headers.get("authorization") ?? "").replace(/^Bearer /, "");
163
- const claims = await verifyToken(deps.secret, token, route.scope.purpose);
275
+ const claims = await verifyToken(deps.secret, token, route2.scope.purpose);
164
276
  if (!claims) return empty(400);
165
- return route.handler({ request, userId: claims.uid, claims, params: {} });
277
+ return harden(await route2.handler({ request, userId: claims.uid, claims, params: {} }));
166
278
  }
167
- return route.handler({ request, userId: null, claims: null, params: {} });
279
+ return harden(await route2.handler({ request, userId: null, claims: null, params: {} }));
168
280
  }
169
- async function handle(request) {
281
+ async function route(request) {
170
282
  const url = new URL(request.url);
171
283
  const path = routePath(url, deps.basePath);
284
+ if (deps.onRequest) {
285
+ const early = await deps.onRequest(request);
286
+ if (early instanceof Response) return early;
287
+ }
288
+ if (deps.rateLimit) {
289
+ const limited = deps.rateLimit(request);
290
+ if (limited) return limited;
291
+ }
172
292
  if (path === "/cron") {
173
293
  if (request.method !== "POST") return empty(405);
174
294
  return handleCron(request);
175
295
  }
176
296
  const pluginRoute = deps.pluginRoutes?.find(
177
- (route) => route.path === path && route.method === request.method
297
+ (candidate) => candidate.path === path && candidate.method === request.method
178
298
  );
179
299
  if (pluginRoute) return handlePluginRoute(pluginRoute, request, url);
300
+ if (request.method === "POST") {
301
+ const rejected = csrfCheck(request, url, trustedOrigins);
302
+ if (rejected) return rejected;
303
+ }
180
304
  const userId = await resolveUserId(request);
181
305
  if (userId === "error") return empty(500);
182
306
  if (userId === null) return empty(401);
@@ -184,27 +308,32 @@ function createHandler(deps) {
184
308
  const limitParam = Number(url.searchParams.get("limit") ?? 20);
185
309
  const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;
186
310
  const cursor = url.searchParams.get("cursor");
187
- const page = await deps.adapter.listNotifications({
188
- userId,
189
- limit,
190
- ...cursor ? { cursor } : {},
191
- ...url.searchParams.get("unreadOnly") === "true" ? { unreadOnly: true } : {}
192
- });
193
- return json(page);
311
+ const [page, unseenCount] = await Promise.all([
312
+ deps.adapter.listNotifications({
313
+ userId,
314
+ limit,
315
+ ...cursor ? { cursor } : {},
316
+ ...url.searchParams.get("unreadOnly") === "true" ? { unreadOnly: true } : {}
317
+ }),
318
+ cursor ? Promise.resolve(null) : deps.adapter.countUnseen(userId)
319
+ ]);
320
+ return json(unseenCount === null ? page : { ...page, unseenCount });
194
321
  }
195
322
  if (request.method === "GET" && path === "/count") {
196
323
  return json({ unseen: await deps.adapter.countUnseen(userId) });
197
324
  }
198
325
  if (request.method === "POST" && path === "/seen") {
199
- const body = await readJson(request);
200
- const before = typeof body.before === "string" ? new Date(body.before) : null;
326
+ const parsed = await readJsonBody(request, maxBodyBytes);
327
+ if ("error" in parsed) return parsed.error;
328
+ const before = typeof parsed.body.before === "string" ? new Date(parsed.body.before) : null;
201
329
  const cutoff = before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : /* @__PURE__ */ new Date();
202
330
  await deps.adapter.markSeen(userId, cutoff);
203
331
  return json({ ok: true });
204
332
  }
205
333
  if (request.method === "POST" && path === "/read") {
206
- const body = await readJson(request);
207
- const ids = Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === "string") : [];
334
+ const parsed = await readJsonBody(request, maxBodyBytes);
335
+ if ("error" in parsed) return parsed.error;
336
+ const ids = Array.isArray(parsed.body.ids) ? parsed.body.ids.filter((id) => typeof id === "string") : [];
208
337
  if (ids.length === 0) return json({ error: "ids must be a non-empty string array" }, 400);
209
338
  if (ids.length > MAX_READ_IDS) {
210
339
  return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);
@@ -217,6 +346,14 @@ function createHandler(deps) {
217
346
  }
218
347
  return empty(404);
219
348
  }
349
+ async function handle(request) {
350
+ try {
351
+ return await route(request);
352
+ } catch (error) {
353
+ deps.logger.error("request failed", { url: request.url, error });
354
+ return empty(500);
355
+ }
356
+ }
220
357
  return {
221
358
  handle,
222
359
  GET: handle,
@@ -224,6 +361,50 @@ function createHandler(deps) {
224
361
  };
225
362
  }
226
363
 
364
+ // src/core/html.ts
365
+ var ESCAPES = {
366
+ "&": "&amp;",
367
+ "<": "&lt;",
368
+ ">": "&gt;",
369
+ '"': "&quot;",
370
+ "'": "&#39;"
371
+ };
372
+ var escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
373
+
374
+ // src/core/rate-limit.ts
375
+ var clientAddress = (request) => {
376
+ const headers = request.headers;
377
+ return headers.get("cf-connecting-ip") ?? headers.get("x-real-ip") ?? headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
378
+ };
379
+ function createRateLimiter(config) {
380
+ const keyOf = config.key ?? clientAddress;
381
+ const buckets = /* @__PURE__ */ new Map();
382
+ let lastSweep = 0;
383
+ return (request) => {
384
+ const key = keyOf(request);
385
+ if (key === null) return null;
386
+ const now = Date.now();
387
+ if (now - lastSweep > config.windowMs) {
388
+ for (const [id, bucket2] of buckets) if (bucket2.resetAt <= now) buckets.delete(id);
389
+ lastSweep = now;
390
+ }
391
+ let bucket = buckets.get(key);
392
+ if (!bucket || bucket.resetAt <= now) {
393
+ bucket = { count: 0, resetAt: now + config.windowMs };
394
+ buckets.set(key, bucket);
395
+ }
396
+ bucket.count += 1;
397
+ if (bucket.count <= config.max) return null;
398
+ return new Response(null, {
399
+ status: 429,
400
+ headers: {
401
+ "retry-after": String(Math.max(1, Math.ceil((bucket.resetAt - now) / 1e3))),
402
+ "cache-control": "private, no-store"
403
+ }
404
+ });
405
+ };
406
+ }
407
+
227
408
  // src/core/backoff.ts
228
409
  var SCHEDULE_MS = [3e4, 12e4, 48e4, 192e4];
229
410
  function backoffMs(attempt, strategy = "exponential") {
@@ -251,7 +432,8 @@ async function withTimeout(run, ms, label) {
251
432
  clearTimeout(timer);
252
433
  }
253
434
  }
254
- var message = (error) => error instanceof Error ? error.message : String(error);
435
+ var redactErrorMessage = (text) => text.replace(/[^\s"'<>()[\]{},;]+@[^\s"'<>()[\]{},;]+/g, "[redacted-email]");
436
+ var message = (error) => redactErrorMessage(error instanceof Error ? error.message : String(error));
255
437
  function createRunner(deps) {
256
438
  async function deliverOne(delivery, recipient) {
257
439
  if (!recipient) {
@@ -337,7 +519,7 @@ function createRunner(deps) {
337
519
  }
338
520
  }
339
521
  async function notifyAfterDeliver(delivery, outcome) {
340
- const isTerminal = outcome.result === "sent" || !outcome.retryable || delivery.attempts + 1 >= delivery.maxAttempts;
522
+ const isTerminal = outcome.result !== "failed" || !outcome.retryable || delivery.attempts + 1 >= delivery.maxAttempts;
341
523
  for (const plugin of deps.plugins) {
342
524
  const hook = plugin.hooks?.afterDeliver;
343
525
  if (!hook) continue;
@@ -368,16 +550,18 @@ function createRunner(deps) {
368
550
  ...options.channels ? { channels: options.channels } : {},
369
551
  ...options.ids ? { ids: options.ids } : {}
370
552
  });
371
- if (claimed.length === 0) return { claimed: 0, sent: 0, failed: 0 };
553
+ if (claimed.length === 0) return { claimed: 0, sent: 0, failed: 0, skipped: 0 };
372
554
  const userIds = [...new Set(claimed.map((row) => row.notification.userId))];
373
555
  const recipients = await deps.getRecipients(userIds);
374
556
  const byUserId = new Map(recipients.map((recipient) => [recipient.userId, recipient]));
375
557
  const releases = [];
376
558
  let sent = 0;
377
559
  let failed = 0;
560
+ let skipped = 0;
378
561
  for (const delivery of claimed) {
379
562
  const outcome = await deliverOne(delivery, byUserId.get(delivery.notification.userId));
380
563
  if (outcome.result === "sent") sent += 1;
564
+ else if (outcome.result === "skipped") skipped += 1;
381
565
  else failed += 1;
382
566
  releases.push({
383
567
  id: delivery.id,
@@ -388,16 +572,17 @@ function createRunner(deps) {
388
572
  await notifyAfterDeliver(delivery, outcome);
389
573
  }
390
574
  await deps.adapter.releaseDeliveries(releases);
391
- return { claimed: claimed.length, sent, failed };
575
+ return { claimed: claimed.length, sent, failed, skipped };
392
576
  }
393
577
  async function drain(options = {}) {
394
578
  const maxSweeps = options.maxSweeps ?? 50;
395
- const total = { claimed: 0, sent: 0, failed: 0 };
579
+ const total = { claimed: 0, sent: 0, failed: 0, skipped: 0 };
396
580
  for (let sweep = 0; sweep < maxSweeps; sweep += 1) {
397
581
  const result = await runOnce(options.ids ? { ids: options.ids } : {});
398
582
  total.claimed += result.claimed;
399
583
  total.sent += result.sent;
400
584
  total.failed += result.failed;
585
+ total.skipped += result.skipped;
401
586
  if (result.claimed === 0) break;
402
587
  }
403
588
  return total;
@@ -637,6 +822,33 @@ async function runSend(type, input, deps) {
637
822
  var toSnakeCase = (value) => value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
638
823
 
639
824
  // src/core/store.ts
825
+ var OPERATORS = ["in", "lt", "lte", "gt", "gte", "not"];
826
+ var isOperator = (value) => typeof value === "object" && value !== null && !(value instanceof Date) && OPERATORS.some((op) => Object.hasOwn(value, op));
827
+ var isScalar = (value) => value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value instanceof Date;
828
+ function assertCondition(pluginId, field, condition) {
829
+ if (isScalar(condition)) return;
830
+ if (isOperator(condition)) {
831
+ const operator = condition;
832
+ const keys = Object.keys(operator);
833
+ const [op] = keys;
834
+ if (keys.length !== 1 || op === void 0) {
835
+ throw new ConfigError(`plugin "${pluginId}" gave field "${field}" more than one operator.`);
836
+ }
837
+ const payload = operator[op];
838
+ const valid = op === "in" ? Array.isArray(payload) && payload.every((item) => typeof item === "string" || typeof item === "number") : isScalar(payload);
839
+ if (valid) return;
840
+ }
841
+ throw new ConfigError(
842
+ `plugin "${pluginId}" passed a non-scalar value for field "${field}"; validate request bodies before querying with them.`
843
+ );
844
+ }
845
+ function assertValue(pluginId, field, spec, value) {
846
+ if (value === void 0 || isScalar(value)) return;
847
+ if (spec.type === "json" && typeof value === "object") return;
848
+ throw new ConfigError(
849
+ `plugin "${pluginId}" passed a non-scalar value for ${spec.type} field "${field}".`
850
+ );
851
+ }
640
852
  function createPluginStore(pluginId, schema, storage, prefix) {
641
853
  const byTableName = /* @__PURE__ */ new Map();
642
854
  for (const declaration of Object.values(schema ?? {})) {
@@ -653,7 +865,7 @@ function createPluginStore(pluginId, schema, storage, prefix) {
653
865
  }
654
866
  function checkFields(table, fields, context) {
655
867
  for (const field of fields) {
656
- const spec = table.fields[field];
868
+ const spec = Object.hasOwn(table.fields, field) ? table.fields[field] : void 0;
657
869
  if (!spec) {
658
870
  throw new ConfigError(
659
871
  `plugin "${pluginId}" used unknown field "${field}" on "${table.tableName}" (${context}).`
@@ -666,6 +878,19 @@ function createPluginStore(pluginId, schema, storage, prefix) {
666
878
  }
667
879
  }
668
880
  }
881
+ function checkWhere(table, where) {
882
+ checkFields(table, Object.keys(where), "where");
883
+ for (const [field, condition] of Object.entries(where)) {
884
+ assertCondition(pluginId, field, condition);
885
+ }
886
+ }
887
+ function checkRow(table, row, context) {
888
+ checkFields(table, Object.keys(row), context);
889
+ for (const [field, value] of Object.entries(row)) {
890
+ const spec = table.fields[field];
891
+ if (spec) assertValue(pluginId, field, spec, value);
892
+ }
893
+ }
669
894
  const qualified = (table) => prefix + table.tableName;
670
895
  const column = (field) => storage.naming === "preserve" ? field : toSnakeCase(field);
671
896
  const serialize = (table, row) => {
@@ -679,21 +904,24 @@ function createPluginStore(pluginId, schema, storage, prefix) {
679
904
  return {
680
905
  async find(table, where = {}, options = {}) {
681
906
  const declaration = resolve(table);
682
- checkFields(declaration, Object.keys(where), "where");
907
+ checkWhere(declaration, where);
683
908
  if (options.orderBy) checkFields(declaration, [options.orderBy.field], "orderBy");
684
909
  const rows = await storage.queryTable(qualified(declaration), where, options);
685
910
  return rows.map((row) => {
686
911
  const mapped = {};
687
912
  for (const field of Object.keys(declaration.fields)) {
688
913
  const value = row[column(field)];
689
- mapped[field] = declaration.fields[field]?.type === "json" && typeof value === "string" ? JSON.parse(value) : value;
914
+ const type = declaration.fields[field]?.type;
915
+ if (type === "json" && typeof value === "string") mapped[field] = JSON.parse(value);
916
+ else if (type === "date" && typeof value === "string") mapped[field] = new Date(value);
917
+ else mapped[field] = value;
690
918
  }
691
919
  return mapped;
692
920
  });
693
921
  },
694
922
  async insert(table, rows) {
695
923
  const declaration = resolve(table);
696
- for (const row of rows) checkFields(declaration, Object.keys(row), "insert");
924
+ for (const row of rows) checkRow(declaration, row, "insert");
697
925
  return storage.insertRows(
698
926
  qualified(declaration),
699
927
  rows.map((row) => serialize(declaration, row))
@@ -701,7 +929,7 @@ function createPluginStore(pluginId, schema, storage, prefix) {
701
929
  },
702
930
  async upsert(table, rows, options) {
703
931
  const declaration = resolve(table);
704
- for (const row of rows) checkFields(declaration, Object.keys(row), "upsert");
932
+ for (const row of rows) checkRow(declaration, row, "upsert");
705
933
  checkFields(declaration, options.onConflict, "onConflict");
706
934
  return storage.insertRows(
707
935
  qualified(declaration),
@@ -711,19 +939,27 @@ function createPluginStore(pluginId, schema, storage, prefix) {
711
939
  },
712
940
  async update(table, where, set) {
713
941
  const declaration = resolve(table);
714
- checkFields(declaration, Object.keys(where), "where");
715
- checkFields(declaration, Object.keys(set), "update");
942
+ checkWhere(declaration, where);
943
+ checkRow(declaration, set, "update");
716
944
  return storage.updateRows(qualified(declaration), where, serialize(declaration, set));
717
945
  },
718
946
  async remove(table, where) {
719
947
  const declaration = resolve(table);
720
- checkFields(declaration, Object.keys(where), "where");
948
+ checkWhere(declaration, where);
721
949
  return storage.deleteRows(qualified(declaration), where);
722
950
  }
723
951
  };
724
952
  }
725
953
 
726
954
  // src/core/instance.ts
955
+ var CORE_ROUTES = [
956
+ { method: "GET", path: "/", scope: { type: "user" }, owner: "core" },
957
+ { method: "GET", path: "/count", scope: { type: "user" }, owner: "core" },
958
+ { method: "POST", path: "/seen", scope: { type: "user" }, owner: "core" },
959
+ { method: "POST", path: "/read", scope: { type: "user" }, owner: "core" },
960
+ { method: "POST", path: "/read-all", scope: { type: "user" }, owner: "core" },
961
+ { method: "POST", path: "/cron", scope: { type: "machine" }, owner: "core" }
962
+ ];
727
963
  var DEFAULTS = {
728
964
  maxAttempts: 5,
729
965
  leaseMs: 6e4,
@@ -731,6 +967,24 @@ var DEFAULTS = {
731
967
  basePath: "/api/notifications",
732
968
  mode: "cron"
733
969
  };
970
+ var MIN_SECRET_LENGTH = 16;
971
+ var PLACEHOLDER_SECRETS = /* @__PURE__ */ new Set([
972
+ "secret",
973
+ "changeme",
974
+ "change-me",
975
+ "replace-me",
976
+ "replace-me-too",
977
+ "password",
978
+ "demo-signing-secret",
979
+ "demo-cron-secret"
980
+ ]);
981
+ function checkSecretStrength(name, value) {
982
+ if (value.length < MIN_SECRET_LENGTH || PLACEHOLDER_SECRETS.has(value.toLowerCase())) {
983
+ throw new ConfigError(
984
+ `\`${name}\` must be at least ${MIN_SECRET_LENGTH} characters and not a placeholder. Generate one with \`openssl rand -base64 32\`.`
985
+ );
986
+ }
987
+ }
734
988
  function validate(config, plugins) {
735
989
  if (!config.database) throw new ConfigError("`database` is required.");
736
990
  if (!config.notifications) throw new ConfigError("`notifications` is required.");
@@ -744,6 +998,12 @@ function validate(config, plugins) {
744
998
  "`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."
745
999
  );
746
1000
  }
1001
+ checkSecretStrength("secret", config.secret);
1002
+ if (config.cron?.secret) checkSecretStrength("cron.secret", config.cron.secret);
1003
+ if (config.machineSecret) checkSecretStrength("machineSecret", config.machineSecret);
1004
+ if (config.rateLimit && (config.rateLimit.max < 1 || config.rateLimit.windowMs < 1)) {
1005
+ throw new ConfigError("`rateLimit.max` and `rateLimit.windowMs` must both be positive.");
1006
+ }
747
1007
  if (typeof config.getRecipients !== "function") {
748
1008
  throw new ConfigError("`getRecipients` is required to resolve emails and timezones.");
749
1009
  }
@@ -812,6 +1072,15 @@ function easyPing(config) {
812
1072
  "delivery.mode is 'deferred' but no delivery.waitUntil was supplied; delivery will wait for the cron sweep."
813
1073
  );
814
1074
  }
1075
+ for (const plugin of plugins) {
1076
+ for (const route of plugin.routes ?? []) {
1077
+ if (route.scope.type === "custom") {
1078
+ warnings.push(
1079
+ `plugin "${plugin.id}" mounts ${route.method} ${route.path} with custom auth: ` + route.scope.justification
1080
+ );
1081
+ }
1082
+ }
1083
+ }
815
1084
  for (const warning of warnings) logger.warn(warning);
816
1085
  const runner = createRunner({
817
1086
  adapter: config.database,
@@ -833,7 +1102,13 @@ function easyPing(config) {
833
1102
  secret: config.secret,
834
1103
  basePath: config.basePath ?? DEFAULTS.basePath,
835
1104
  pluginRoutes,
836
- ...config.cron?.secret ? { cronSecret: config.cron.secret } : {}
1105
+ trustedOrigins: config.trustedOrigins,
1106
+ onRequest: config.onRequest,
1107
+ maxBodyBytes: config.maxBodyBytes,
1108
+ rateLimit: config.rateLimit ? createRateLimiter(config.rateLimit) : void 0,
1109
+ cronMaxSweeps: config.cron?.maxSweeps,
1110
+ ...config.cron?.secret ? { cronSecret: config.cron.secret } : {},
1111
+ ...config.machineSecret ?? config.cron?.secret ? { machineSecret: config.machineSecret ?? config.cron?.secret } : {}
837
1112
  });
838
1113
  async function send(type, args) {
839
1114
  const result = await runSend(
@@ -865,10 +1140,21 @@ function easyPing(config) {
865
1140
  return result;
866
1141
  }
867
1142
  for (const plugin of plugins) {
1143
+ const purposes = new Set(
1144
+ (plugin.routes ?? []).flatMap(
1145
+ (route) => route.scope.type === "signed" ? [route.scope.purpose] : []
1146
+ )
1147
+ );
868
1148
  plugin.init?.({
869
1149
  store: createPluginStore(plugin.id, plugin.schema, config.database, tablePrefix),
870
- secret: config.secret,
1150
+ sign: createScopedSigner(
1151
+ config.secret,
1152
+ plugin.id,
1153
+ purposes,
1154
+ (message2) => new ConfigError(message2)
1155
+ ),
871
1156
  logger,
1157
+ notificationTypes: Object.keys(config.notifications),
872
1158
  getRecipients: config.getRecipients,
873
1159
  send: (type, args) => send(type, args)
874
1160
  });
@@ -877,6 +1163,17 @@ function easyPing(config) {
877
1163
  send,
878
1164
  handler,
879
1165
  startWorker: (options) => runner.startWorker(options ?? {}),
1166
+ listRoutes: () => [
1167
+ ...CORE_ROUTES.filter((route) => route.path !== "/cron" || Boolean(config.cron?.secret)),
1168
+ ...plugins.flatMap(
1169
+ (plugin) => (plugin.routes ?? []).map((route) => ({
1170
+ method: route.method,
1171
+ path: route.path,
1172
+ scope: route.scope,
1173
+ owner: plugin.id
1174
+ }))
1175
+ )
1176
+ ],
880
1177
  getFailedDeliveries: (options = {}) => config.database.getFailedDeliveries({
881
1178
  since: options.since ?? new Date(Date.now() - 24 * 36e5),
882
1179
  limit: Math.min(options.limit ?? 100, 1e3)
@@ -904,8 +1201,12 @@ function defineClientPlugin(plugin) {
904
1201
  exports.ConfigError = ConfigError;
905
1202
  exports.DEFAULT_TOKEN_TTL_SECONDS = DEFAULT_TOKEN_TTL_SECONDS;
906
1203
  exports.EasyPingError = EasyPingError;
1204
+ exports.MAX_BODY_BYTES = MAX_BODY_BYTES;
1205
+ exports.MAX_TOKEN_TTL_SECONDS = MAX_TOKEN_TTL_SECONDS;
1206
+ exports.MIN_SECRET_LENGTH = MIN_SECRET_LENGTH;
907
1207
  exports.ValidationError = ValidationError;
908
1208
  exports.consoleLogger = consoleLogger;
1209
+ exports.createRateLimiter = createRateLimiter;
909
1210
  exports.createRunner = createRunner;
910
1211
  exports.decodeBase64Url = decodeBase64Url;
911
1212
  exports.defineClientPlugin = defineClientPlugin;
@@ -913,7 +1214,10 @@ exports.defineNotification = defineNotification;
913
1214
  exports.definePlugin = definePlugin;
914
1215
  exports.easyPing = easyPing;
915
1216
  exports.encodeBase64Url = encodeBase64Url;
1217
+ exports.escapeHtml = escapeHtml;
916
1218
  exports.expiresIn = expiresIn;
1219
+ exports.readJsonBody = readJsonBody;
1220
+ exports.redactErrorMessage = redactErrorMessage;
917
1221
  exports.signToken = signToken;
918
1222
  exports.verifyToken = verifyToken;
919
1223
  //# sourceMappingURL=index.cjs.map