easy-ping 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/dist/adapters/drizzle.cjs +3 -2
- package/dist/adapters/drizzle.cjs.map +1 -1
- package/dist/adapters/drizzle.d.cts +34 -0
- package/dist/adapters/drizzle.d.ts +34 -0
- package/dist/adapters/drizzle.js +4 -3
- package/dist/adapters/drizzle.js.map +1 -1
- package/dist/adapters/mongodb.cjs +6 -2
- package/dist/adapters/mongodb.cjs.map +1 -1
- package/dist/adapters/mongodb.d.cts +1 -1
- package/dist/adapters/mongodb.d.ts +1 -1
- package/dist/adapters/mongodb.js +5 -2
- package/dist/adapters/mongodb.js.map +1 -1
- package/dist/adapters/postgres.cjs +3 -2
- package/dist/adapters/postgres.cjs.map +1 -1
- package/dist/adapters/postgres.d.cts +1 -1
- package/dist/adapters/postgres.d.ts +1 -1
- package/dist/adapters/postgres.js +3 -3
- package/dist/{chunk-OZSOIX23.js → chunk-3Y2MBUAW.js} +53 -12
- package/dist/chunk-3Y2MBUAW.js.map +1 -0
- package/dist/chunk-CYKAM7LE.js +226 -0
- package/dist/chunk-CYKAM7LE.js.map +1 -0
- package/dist/chunk-DG3ZL5NP.js +315 -0
- package/dist/chunk-DG3ZL5NP.js.map +1 -0
- package/dist/{chunk-NYDVNMKG.js → chunk-PSJHXZPR.js} +4 -3
- package/dist/chunk-PSJHXZPR.js.map +1 -0
- package/dist/{chunk-BV3U542P.js → chunk-W6I3B2CI.js} +2 -2
- package/dist/chunk-W6I3B2CI.js.map +1 -0
- package/dist/{chunk-RBDW4S6F.js → chunk-YEMRRMJQ.js} +3 -3
- package/dist/{chunk-RBDW4S6F.js.map → chunk-YEMRRMJQ.js.map} +1 -1
- package/dist/index.cjs +360 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +84 -20
- package/dist/index.d.ts +84 -20
- package/dist/index.js +119 -357
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +27 -6
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +8 -2
- package/dist/node.d.ts +8 -2
- package/dist/node.js +26 -6
- package/dist/node.js.map +1 -1
- package/dist/{plugin-CmPOFVmn.d.ts → plugin-C9Hoxus2.d.ts} +37 -9
- package/dist/{plugin-f5KaFWSN.d.cts → plugin-CiMsGlQe.d.cts} +37 -9
- package/dist/plugins/digests.d.cts +1 -1
- package/dist/plugins/digests.d.ts +1 -1
- package/dist/plugins/preferences-client.cjs.map +1 -1
- package/dist/plugins/preferences-client.d.cts +1 -1
- package/dist/plugins/preferences-client.d.ts +1 -1
- package/dist/plugins/preferences-client.js +1 -1
- package/dist/plugins/preferences.cjs +100 -15
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.d.cts +3 -1
- package/dist/plugins/preferences.d.ts +3 -1
- package/dist/plugins/preferences.js +35 -7
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.cjs +197 -38
- package/dist/plugins/push.cjs.map +1 -1
- package/dist/plugins/push.d.cts +32 -10
- package/dist/plugins/push.d.ts +32 -10
- package/dist/plugins/push.js +140 -39
- package/dist/plugins/push.js.map +1 -1
- package/dist/providers/web-push.cjs +37 -11
- package/dist/providers/web-push.cjs.map +1 -1
- package/dist/providers/web-push.d.cts +8 -2
- package/dist/providers/web-push.d.ts +8 -2
- package/dist/providers/web-push.js +37 -12
- package/dist/providers/web-push.js.map +1 -1
- package/dist/schema.cjs +2 -1
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +6 -1
- package/dist/schema.d.ts +6 -1
- package/dist/schema.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-BV3U542P.js.map +0 -1
- package/dist/chunk-NYDVNMKG.js.map +0 -1
- package/dist/chunk-OZSOIX23.js.map +0 -1
- package/dist/chunk-W3BMEPPQ.js +0 -50
- package/dist/chunk-W3BMEPPQ.js.map +0 -1
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
67
|
-
const
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
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
|
|
119
|
-
if (
|
|
120
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
140
|
-
if (!
|
|
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
|
-
|
|
144
|
-
|
|
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(
|
|
148
|
-
if (
|
|
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
|
|
266
|
+
return harden(await route2.handler({ request, userId, claims: null, params: {} }));
|
|
153
267
|
}
|
|
154
|
-
if (
|
|
155
|
-
if (!deps.
|
|
156
|
-
|
|
157
|
-
|
|
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 (
|
|
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,
|
|
275
|
+
const claims = await verifyToken(deps.secret, token, route2.scope.purpose);
|
|
164
276
|
if (!claims) return empty(400);
|
|
165
|
-
return
|
|
277
|
+
return harden(await route2.handler({ request, userId: claims.uid, claims, params: {} }));
|
|
166
278
|
}
|
|
167
|
-
return
|
|
279
|
+
return harden(await route2.handler({ request, userId: null, claims: null, params: {} }));
|
|
168
280
|
}
|
|
169
|
-
async function
|
|
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
|
-
(
|
|
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
|
|
203
|
-
|
|
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
|
|
210
|
-
|
|
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
|
+
"&": "&",
|
|
367
|
+
"<": "<",
|
|
368
|
+
">": ">",
|
|
369
|
+
'"': """,
|
|
370
|
+
"'": "'"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|