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.
Files changed (79) hide show
  1. package/README.md +15 -4
  2. package/dist/adapters/drizzle.cjs +3 -2
  3. package/dist/adapters/drizzle.cjs.map +1 -1
  4. package/dist/adapters/drizzle.d.cts +34 -0
  5. package/dist/adapters/drizzle.d.ts +34 -0
  6. package/dist/adapters/drizzle.js +4 -3
  7. package/dist/adapters/drizzle.js.map +1 -1
  8. package/dist/adapters/mongodb.cjs +6 -2
  9. package/dist/adapters/mongodb.cjs.map +1 -1
  10. package/dist/adapters/mongodb.d.cts +1 -1
  11. package/dist/adapters/mongodb.d.ts +1 -1
  12. package/dist/adapters/mongodb.js +5 -2
  13. package/dist/adapters/mongodb.js.map +1 -1
  14. package/dist/adapters/postgres.cjs +3 -2
  15. package/dist/adapters/postgres.cjs.map +1 -1
  16. package/dist/adapters/postgres.d.cts +1 -1
  17. package/dist/adapters/postgres.d.ts +1 -1
  18. package/dist/adapters/postgres.js +3 -3
  19. package/dist/{chunk-OZSOIX23.js → chunk-3Y2MBUAW.js} +53 -12
  20. package/dist/chunk-3Y2MBUAW.js.map +1 -0
  21. package/dist/chunk-CYKAM7LE.js +226 -0
  22. package/dist/chunk-CYKAM7LE.js.map +1 -0
  23. package/dist/chunk-DG3ZL5NP.js +315 -0
  24. package/dist/chunk-DG3ZL5NP.js.map +1 -0
  25. package/dist/{chunk-NYDVNMKG.js → chunk-PSJHXZPR.js} +4 -3
  26. package/dist/chunk-PSJHXZPR.js.map +1 -0
  27. package/dist/{chunk-BV3U542P.js → chunk-W6I3B2CI.js} +2 -2
  28. package/dist/chunk-W6I3B2CI.js.map +1 -0
  29. package/dist/{chunk-RBDW4S6F.js → chunk-YEMRRMJQ.js} +3 -3
  30. package/dist/{chunk-RBDW4S6F.js.map → chunk-YEMRRMJQ.js.map} +1 -1
  31. package/dist/index.cjs +360 -62
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +84 -20
  34. package/dist/index.d.ts +84 -20
  35. package/dist/index.js +119 -357
  36. package/dist/index.js.map +1 -1
  37. package/dist/node.cjs +27 -6
  38. package/dist/node.cjs.map +1 -1
  39. package/dist/node.d.cts +8 -2
  40. package/dist/node.d.ts +8 -2
  41. package/dist/node.js +26 -6
  42. package/dist/node.js.map +1 -1
  43. package/dist/{plugin-CmPOFVmn.d.ts → plugin-C9Hoxus2.d.ts} +37 -9
  44. package/dist/{plugin-f5KaFWSN.d.cts → plugin-CiMsGlQe.d.cts} +37 -9
  45. package/dist/plugins/digests.d.cts +1 -1
  46. package/dist/plugins/digests.d.ts +1 -1
  47. package/dist/plugins/preferences-client.cjs.map +1 -1
  48. package/dist/plugins/preferences-client.d.cts +1 -1
  49. package/dist/plugins/preferences-client.d.ts +1 -1
  50. package/dist/plugins/preferences-client.js +1 -1
  51. package/dist/plugins/preferences.cjs +100 -15
  52. package/dist/plugins/preferences.cjs.map +1 -1
  53. package/dist/plugins/preferences.d.cts +3 -1
  54. package/dist/plugins/preferences.d.ts +3 -1
  55. package/dist/plugins/preferences.js +35 -7
  56. package/dist/plugins/preferences.js.map +1 -1
  57. package/dist/plugins/push.cjs +197 -38
  58. package/dist/plugins/push.cjs.map +1 -1
  59. package/dist/plugins/push.d.cts +32 -10
  60. package/dist/plugins/push.d.ts +32 -10
  61. package/dist/plugins/push.js +140 -39
  62. package/dist/plugins/push.js.map +1 -1
  63. package/dist/providers/web-push.cjs +37 -11
  64. package/dist/providers/web-push.cjs.map +1 -1
  65. package/dist/providers/web-push.d.cts +8 -2
  66. package/dist/providers/web-push.d.ts +8 -2
  67. package/dist/providers/web-push.js +37 -12
  68. package/dist/providers/web-push.js.map +1 -1
  69. package/dist/schema.cjs +2 -1
  70. package/dist/schema.cjs.map +1 -1
  71. package/dist/schema.d.cts +6 -1
  72. package/dist/schema.d.ts +6 -1
  73. package/dist/schema.js +2 -2
  74. package/package.json +1 -1
  75. package/dist/chunk-BV3U542P.js.map +0 -1
  76. package/dist/chunk-NYDVNMKG.js.map +0 -1
  77. package/dist/chunk-OZSOIX23.js.map +0 -1
  78. package/dist/chunk-W3BMEPPQ.js +0 -50
  79. 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;
172
+ }
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: {} };
199
+ try {
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
+ }
121
207
  }
122
- async function readJson(request) {
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;
123
211
  try {
124
- const body = await request.json();
125
- return body && typeof body === "object" ? body : {};
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);
@@ -199,15 +323,17 @@ function createHandler(deps) {
199
323
  return json({ unseen: await deps.adapter.countUnseen(userId) });
200
324
  }
201
325
  if (request.method === "POST" && path === "/seen") {
202
- const body = await readJson(request);
203
- 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;
204
329
  const cutoff = before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : /* @__PURE__ */ new Date();
205
330
  await deps.adapter.markSeen(userId, cutoff);
206
331
  return json({ ok: true });
207
332
  }
208
333
  if (request.method === "POST" && path === "/read") {
209
- const body = await readJson(request);
210
- 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") : [];
211
337
  if (ids.length === 0) return json({ error: "ids must be a non-empty string array" }, 400);
212
338
  if (ids.length > MAX_READ_IDS) {
213
339
  return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);
@@ -220,6 +346,14 @@ function createHandler(deps) {
220
346
  }
221
347
  return empty(404);
222
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
+ }
223
357
  return {
224
358
  handle,
225
359
  GET: handle,
@@ -227,6 +361,50 @@ function createHandler(deps) {
227
361
  };
228
362
  }
229
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
+
230
408
  // src/core/backoff.ts
231
409
  var SCHEDULE_MS = [3e4, 12e4, 48e4, 192e4];
232
410
  function backoffMs(attempt, strategy = "exponential") {
@@ -254,7 +432,8 @@ async function withTimeout(run, ms, label) {
254
432
  clearTimeout(timer);
255
433
  }
256
434
  }
257
- 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));
258
437
  function createRunner(deps) {
259
438
  async function deliverOne(delivery, recipient) {
260
439
  if (!recipient) {
@@ -643,6 +822,33 @@ async function runSend(type, input, deps) {
643
822
  var toSnakeCase = (value) => value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
644
823
 
645
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
+ }
646
852
  function createPluginStore(pluginId, schema, storage, prefix) {
647
853
  const byTableName = /* @__PURE__ */ new Map();
648
854
  for (const declaration of Object.values(schema ?? {})) {
@@ -659,7 +865,7 @@ function createPluginStore(pluginId, schema, storage, prefix) {
659
865
  }
660
866
  function checkFields(table, fields, context) {
661
867
  for (const field of fields) {
662
- const spec = table.fields[field];
868
+ const spec = Object.hasOwn(table.fields, field) ? table.fields[field] : void 0;
663
869
  if (!spec) {
664
870
  throw new ConfigError(
665
871
  `plugin "${pluginId}" used unknown field "${field}" on "${table.tableName}" (${context}).`
@@ -672,6 +878,19 @@ function createPluginStore(pluginId, schema, storage, prefix) {
672
878
  }
673
879
  }
674
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
+ }
675
894
  const qualified = (table) => prefix + table.tableName;
676
895
  const column = (field) => storage.naming === "preserve" ? field : toSnakeCase(field);
677
896
  const serialize = (table, row) => {
@@ -685,21 +904,24 @@ function createPluginStore(pluginId, schema, storage, prefix) {
685
904
  return {
686
905
  async find(table, where = {}, options = {}) {
687
906
  const declaration = resolve(table);
688
- checkFields(declaration, Object.keys(where), "where");
907
+ checkWhere(declaration, where);
689
908
  if (options.orderBy) checkFields(declaration, [options.orderBy.field], "orderBy");
690
909
  const rows = await storage.queryTable(qualified(declaration), where, options);
691
910
  return rows.map((row) => {
692
911
  const mapped = {};
693
912
  for (const field of Object.keys(declaration.fields)) {
694
913
  const value = row[column(field)];
695
- 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;
696
918
  }
697
919
  return mapped;
698
920
  });
699
921
  },
700
922
  async insert(table, rows) {
701
923
  const declaration = resolve(table);
702
- for (const row of rows) checkFields(declaration, Object.keys(row), "insert");
924
+ for (const row of rows) checkRow(declaration, row, "insert");
703
925
  return storage.insertRows(
704
926
  qualified(declaration),
705
927
  rows.map((row) => serialize(declaration, row))
@@ -707,7 +929,7 @@ function createPluginStore(pluginId, schema, storage, prefix) {
707
929
  },
708
930
  async upsert(table, rows, options) {
709
931
  const declaration = resolve(table);
710
- for (const row of rows) checkFields(declaration, Object.keys(row), "upsert");
932
+ for (const row of rows) checkRow(declaration, row, "upsert");
711
933
  checkFields(declaration, options.onConflict, "onConflict");
712
934
  return storage.insertRows(
713
935
  qualified(declaration),
@@ -717,19 +939,27 @@ function createPluginStore(pluginId, schema, storage, prefix) {
717
939
  },
718
940
  async update(table, where, set) {
719
941
  const declaration = resolve(table);
720
- checkFields(declaration, Object.keys(where), "where");
721
- checkFields(declaration, Object.keys(set), "update");
942
+ checkWhere(declaration, where);
943
+ checkRow(declaration, set, "update");
722
944
  return storage.updateRows(qualified(declaration), where, serialize(declaration, set));
723
945
  },
724
946
  async remove(table, where) {
725
947
  const declaration = resolve(table);
726
- checkFields(declaration, Object.keys(where), "where");
948
+ checkWhere(declaration, where);
727
949
  return storage.deleteRows(qualified(declaration), where);
728
950
  }
729
951
  };
730
952
  }
731
953
 
732
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
+ ];
733
963
  var DEFAULTS = {
734
964
  maxAttempts: 5,
735
965
  leaseMs: 6e4,
@@ -737,6 +967,24 @@ var DEFAULTS = {
737
967
  basePath: "/api/notifications",
738
968
  mode: "cron"
739
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
+ }
740
988
  function validate(config, plugins) {
741
989
  if (!config.database) throw new ConfigError("`database` is required.");
742
990
  if (!config.notifications) throw new ConfigError("`notifications` is required.");
@@ -750,6 +998,12 @@ function validate(config, plugins) {
750
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."
751
999
  );
752
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
+ }
753
1007
  if (typeof config.getRecipients !== "function") {
754
1008
  throw new ConfigError("`getRecipients` is required to resolve emails and timezones.");
755
1009
  }
@@ -818,6 +1072,15 @@ function easyPing(config) {
818
1072
  "delivery.mode is 'deferred' but no delivery.waitUntil was supplied; delivery will wait for the cron sweep."
819
1073
  );
820
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
+ }
821
1084
  for (const warning of warnings) logger.warn(warning);
822
1085
  const runner = createRunner({
823
1086
  adapter: config.database,
@@ -839,7 +1102,13 @@ function easyPing(config) {
839
1102
  secret: config.secret,
840
1103
  basePath: config.basePath ?? DEFAULTS.basePath,
841
1104
  pluginRoutes,
842
- ...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 } : {}
843
1112
  });
844
1113
  async function send(type, args) {
845
1114
  const result = await runSend(
@@ -871,10 +1140,21 @@ function easyPing(config) {
871
1140
  return result;
872
1141
  }
873
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
+ );
874
1148
  plugin.init?.({
875
1149
  store: createPluginStore(plugin.id, plugin.schema, config.database, tablePrefix),
876
- secret: config.secret,
1150
+ sign: createScopedSigner(
1151
+ config.secret,
1152
+ plugin.id,
1153
+ purposes,
1154
+ (message2) => new ConfigError(message2)
1155
+ ),
877
1156
  logger,
1157
+ notificationTypes: Object.keys(config.notifications),
878
1158
  getRecipients: config.getRecipients,
879
1159
  send: (type, args) => send(type, args)
880
1160
  });
@@ -883,6 +1163,17 @@ function easyPing(config) {
883
1163
  send,
884
1164
  handler,
885
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
+ ],
886
1177
  getFailedDeliveries: (options = {}) => config.database.getFailedDeliveries({
887
1178
  since: options.since ?? new Date(Date.now() - 24 * 36e5),
888
1179
  limit: Math.min(options.limit ?? 100, 1e3)
@@ -910,8 +1201,12 @@ function defineClientPlugin(plugin) {
910
1201
  exports.ConfigError = ConfigError;
911
1202
  exports.DEFAULT_TOKEN_TTL_SECONDS = DEFAULT_TOKEN_TTL_SECONDS;
912
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;
913
1207
  exports.ValidationError = ValidationError;
914
1208
  exports.consoleLogger = consoleLogger;
1209
+ exports.createRateLimiter = createRateLimiter;
915
1210
  exports.createRunner = createRunner;
916
1211
  exports.decodeBase64Url = decodeBase64Url;
917
1212
  exports.defineClientPlugin = defineClientPlugin;
@@ -919,7 +1214,10 @@ exports.defineNotification = defineNotification;
919
1214
  exports.definePlugin = definePlugin;
920
1215
  exports.easyPing = easyPing;
921
1216
  exports.encodeBase64Url = encodeBase64Url;
1217
+ exports.escapeHtml = escapeHtml;
922
1218
  exports.expiresIn = expiresIn;
1219
+ exports.readJsonBody = readJsonBody;
1220
+ exports.redactErrorMessage = redactErrorMessage;
923
1221
  exports.signToken = signToken;
924
1222
  exports.verifyToken = verifyToken;
925
1223
  //# sourceMappingURL=index.cjs.map