@siteping/server 0.0.0-stage → 0.1.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/dist/index.cjs ADDED
@@ -0,0 +1,1270 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ createSitepingHandler: () => createSitepingHandler,
34
+ dispatchWebhook: () => dispatchWebhook,
35
+ dispatchWebhooks: () => dispatchWebhooks
36
+ });
37
+ module.exports = __toCommonJS(index_exports);
38
+
39
+ // ../core/src/email.ts
40
+ var EMAIL_PATTERN = /^(?!\.)(?!.*\.\.)[\p{L}\p{M}\p{N}!#$%&'*+/=?^_`{|}~.-]{0,63}[\p{L}\p{M}\p{N}!#$%&'*+/=?^_`{|}~-]@(?:[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?\.)+[\p{L}\p{N}][\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}]$/u;
41
+
42
+ // ../core/src/type-utils.ts
43
+ function isRecord(value) {
44
+ return typeof value === "object" && value !== null;
45
+ }
46
+ function hasOwn(value, key) {
47
+ return isRecord(value) && key in value;
48
+ }
49
+
50
+ // ../core/src/types.ts
51
+ var IDENTITY_FIELD_MAX_LENGTH = 200;
52
+ var FEEDBACK_TYPES = ["question", "change", "bug", "other"];
53
+ var FEEDBACK_STATUSES = ["open", "in_progress", "resolved", "wont_fix"];
54
+ var CLOSED_FEEDBACK_STATUSES = ["resolved", "wont_fix"];
55
+ function isClosedStatus(status) {
56
+ return CLOSED_FEEDBACK_STATUSES.includes(status);
57
+ }
58
+ function toFeedbackUpdate(status, closedAt = /* @__PURE__ */ new Date()) {
59
+ return isClosedStatus(status) ? { status, resolvedAt: closedAt } : { status, resolvedAt: null };
60
+ }
61
+ var COMMENT_AUTHOR_ROLES = ["client", "team"];
62
+ var COMMENT_BODY_MAX_LENGTH = 5e3;
63
+ var MAX_COMMENTS_PER_FEEDBACK = 100;
64
+ var StoreNotFoundError = class extends Error {
65
+ code = "STORE_NOT_FOUND";
66
+ constructor(message = "Record not found", options) {
67
+ super(message, options);
68
+ this.name = "StoreNotFoundError";
69
+ }
70
+ };
71
+ var StoreDuplicateError = class extends Error {
72
+ code = "STORE_DUPLICATE";
73
+ constructor(message = "Duplicate record", options) {
74
+ super(message, options);
75
+ this.name = "StoreDuplicateError";
76
+ }
77
+ };
78
+ var StoreLimitError = class extends Error {
79
+ code = "STORE_LIMIT";
80
+ constructor(message = "Store limit reached", options) {
81
+ super(message, options);
82
+ this.name = "StoreLimitError";
83
+ }
84
+ };
85
+ var StoreValueTooLongError = class extends Error {
86
+ code = "STORE_VALUE_TOO_LONG";
87
+ constructor(message = "Value too long for the store", options) {
88
+ super(message, options);
89
+ this.name = "StoreValueTooLongError";
90
+ }
91
+ };
92
+ function hasErrorCode(error, code) {
93
+ return hasOwn(error, "code") && error.code === code;
94
+ }
95
+ function isStoreNotFound(error) {
96
+ if (error instanceof StoreNotFoundError) return true;
97
+ return hasErrorCode(error, "STORE_NOT_FOUND") || hasErrorCode(error, "P2025");
98
+ }
99
+ function isStoreDuplicate(error) {
100
+ if (error instanceof StoreDuplicateError) return true;
101
+ return hasErrorCode(error, "STORE_DUPLICATE") || hasErrorCode(error, "P2002");
102
+ }
103
+ function isStoreLimit(error) {
104
+ if (error instanceof StoreLimitError) return true;
105
+ return hasErrorCode(error, "STORE_LIMIT");
106
+ }
107
+ function isStoreValueTooLong(error) {
108
+ if (error instanceof StoreValueTooLongError) return true;
109
+ return hasErrorCode(error, "STORE_VALUE_TOO_LONG");
110
+ }
111
+ function flattenAnnotation(ann) {
112
+ return {
113
+ cssSelector: ann.anchor.cssSelector,
114
+ xpath: ann.anchor.xpath,
115
+ textSnippet: ann.anchor.textSnippet,
116
+ elementTag: ann.anchor.elementTag,
117
+ elementId: ann.anchor.elementId,
118
+ textPrefix: ann.anchor.textPrefix,
119
+ textSuffix: ann.anchor.textSuffix,
120
+ fingerprint: ann.anchor.fingerprint,
121
+ neighborText: ann.anchor.neighborText,
122
+ anchorKey: ann.anchor.anchorKey ?? null,
123
+ xPct: ann.rect.xPct,
124
+ yPct: ann.rect.yPct,
125
+ wPct: ann.rect.wPct,
126
+ hPct: ann.rect.hPct,
127
+ scrollX: ann.scrollX,
128
+ scrollY: ann.scrollY,
129
+ viewportW: ann.viewportW,
130
+ viewportH: ann.viewportH,
131
+ devicePixelRatio: ann.devicePixelRatio
132
+ };
133
+ }
134
+ var CONSOLE_DIAGNOSTIC_LEVELS = ["log", "info", "warn", "error"];
135
+ var ANCHOR_ELEMENT_TAG_MAX = 191;
136
+ var ANCHOR_ELEMENT_ID_MAX = 191;
137
+
138
+ // src/constants.ts
139
+ var MAX_ANNOTATIONS_PER_FEEDBACK = 50;
140
+ var DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024;
141
+ var MAX_VALIDATION_ISSUES = 20;
142
+ var DRY_RUN_CONCURRENCY = 8;
143
+ var ERROR_MESSAGES = {
144
+ invalidJson: "Invalid JSON",
145
+ bodyTooLarge: "Request body too large",
146
+ unauthorized: "Unauthorized",
147
+ apiKeyRequiredForDestructive: "apiKey required for destructive operations",
148
+ forbidden: "Forbidden",
149
+ unsupportedMediaType: "Content-Type must be application/json",
150
+ feedbackNotFound: "Feedback not found",
151
+ commentNotFound: "Comment not found",
152
+ clientIdUsedByAnotherProject: "clientId already used by another project",
153
+ clientIdUsedByAnotherFeedback: "clientId already used on another feedback",
154
+ commentsUnsupported: "Comments are not supported by this store",
155
+ tooManyComments: `Too many client comments on this feedback (max ${MAX_COMMENTS_PER_FEEDBACK})`,
156
+ tooManyAnnotations: `Too many annotations (max ${MAX_ANNOTATIONS_PER_FEEDBACK})`,
157
+ valueTooLong: "A value is too long for this server's database",
158
+ deletionAborted: "Deletion aborted: a linked resource could not be cleaned up",
159
+ internalServerError: "Internal server error"
160
+ };
161
+ var LIST_QUERY_KEYS = [
162
+ "projectName",
163
+ "page",
164
+ "limit",
165
+ "type",
166
+ "status",
167
+ "statuses",
168
+ "search",
169
+ "url",
170
+ "urlPattern"
171
+ ];
172
+
173
+ // src/access.ts
174
+ var textEncoder = new TextEncoder();
175
+ function safeCompare(a, b) {
176
+ const bytesA = textEncoder.encode(a);
177
+ const bytesB = textEncoder.encode(b);
178
+ if (bytesA.length !== bytesB.length) return false;
179
+ let difference = 0;
180
+ for (let index = 0; index < bytesA.length; index++) {
181
+ difference |= (bytesA[index] ?? 0) ^ (bytesB[index] ?? 0);
182
+ }
183
+ return difference === 0;
184
+ }
185
+ function isProductionEnvironment() {
186
+ return typeof process !== "undefined" && process.env?.NODE_ENV === "production";
187
+ }
188
+ function createApiKeyGate({
189
+ apiKey,
190
+ publicEndpoints = apiKey ? ["POST", "OPTIONS"] : void 0,
191
+ requireAuthForDestructive = true,
192
+ redactUnauthenticatedEmails = true
193
+ }) {
194
+ if (!apiKey && requireAuthForDestructive && isProductionEnvironment()) {
195
+ throw new Error(
196
+ "[siteping] createSitepingHandler: apiKey is required in production. Set `apiKey` to enable destructive endpoints, pass `access` to plug in your own authentication, or pass `requireAuthForDestructive: false` if SitePing sits behind your own auth middleware."
197
+ );
198
+ }
199
+ const publicMethods = publicEndpoints ? new Set(publicEndpoints) : null;
200
+ const isBearerAuthenticated = (request) => {
201
+ if (!apiKey) return false;
202
+ const header = request.headers.get("Authorization");
203
+ return header !== null && safeCompare(header, `Bearer ${apiKey}`);
204
+ };
205
+ const authenticate = async (request, method) => {
206
+ const canReadAuthorEmail = !redactUnauthenticatedEmails || isBearerAuthenticated(request);
207
+ if (!apiKey) {
208
+ if (requireAuthForDestructive && (method === "DELETE" || method === "PATCH")) {
209
+ return { ok: false, status: 401, error: ERROR_MESSAGES.apiKeyRequiredForDestructive };
210
+ }
211
+ return { ok: true, principal: null, canReadAuthorEmail };
212
+ }
213
+ if (publicMethods?.has(method) || isBearerAuthenticated(request)) {
214
+ return { ok: true, principal: null, canReadAuthorEmail };
215
+ }
216
+ return { ok: false, status: 401, error: ERROR_MESSAGES.unauthorized };
217
+ };
218
+ return {
219
+ echoesAuthorEmailOnCreate: true,
220
+ // The key travels in a header a forged cross-site request cannot set.
221
+ guardsMutations: false,
222
+ listCacheControl: "private, max-age=5",
223
+ authenticate,
224
+ // This policy admits by method: a visitor reads, the key holder triages.
225
+ async admits(request, method) {
226
+ return (await authenticate(request, method)).ok;
227
+ },
228
+ async authorize() {
229
+ return true;
230
+ },
231
+ // The key proves the caller is the project side; a public POST does not.
232
+ async canCommentAsTeam({ request }) {
233
+ return isBearerAuthenticated(request);
234
+ }
235
+ };
236
+ }
237
+ function createAccessGate(access) {
238
+ return {
239
+ echoesAuthorEmailOnCreate: false,
240
+ guardsMutations: true,
241
+ listCacheControl: "no-store",
242
+ async authenticate(request) {
243
+ const principal = await access.authenticate(request);
244
+ if (!principal) {
245
+ return { ok: false, status: 401, error: ERROR_MESSAGES.unauthorized };
246
+ }
247
+ return { ok: true, principal, canReadAuthorEmail: await access.canReadAuthorEmail?.(principal) === true };
248
+ },
249
+ // A dry run reuses the response's request, so `authenticate` is never asked
250
+ // about another method: `authorize` decides, by action.
251
+ async admits() {
252
+ return true;
253
+ },
254
+ async authorize(context) {
255
+ return access.authorize ? access.authorize(context) : true;
256
+ },
257
+ // Speaking as the team is an impersonation privilege: without the host's
258
+ // word for it (its own callback, or the email access it grants), refuse.
259
+ async canCommentAsTeam({ principal }, canReadAuthorEmail) {
260
+ return access.canCommentAsTeam ? await access.canCommentAsTeam(principal) === true : canReadAuthorEmail;
261
+ }
262
+ };
263
+ }
264
+
265
+ // src/cors.ts
266
+ var DEFAULT_ALLOWED_HEADERS = ["Content-Type", "Authorization"];
267
+ var HEADER_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
268
+ function buildCorsHeaders(request, allowedOrigins) {
269
+ if (!allowedOrigins) return {};
270
+ const origin = request.headers.get("Origin");
271
+ if (!origin || !allowedOrigins.includes(origin)) return { Vary: "Origin" };
272
+ return {
273
+ "Access-Control-Allow-Origin": origin,
274
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
275
+ "Access-Control-Allow-Headers": DEFAULT_ALLOWED_HEADERS.join(", "),
276
+ "Access-Control-Allow-Credentials": "true",
277
+ "Access-Control-Max-Age": "86400",
278
+ Vary: "Origin"
279
+ };
280
+ }
281
+ function withCors(response, corsHeaders) {
282
+ for (const [key, value] of Object.entries(corsHeaders)) {
283
+ response.headers.set(key, value);
284
+ }
285
+ return response;
286
+ }
287
+ function preflightAllowedHeaders(request) {
288
+ const allowed = [...DEFAULT_ALLOWED_HEADERS];
289
+ const seen = new Set(allowed.map((name) => name.toLowerCase()));
290
+ for (const raw of (request.headers.get("Access-Control-Request-Headers") ?? "").split(",")) {
291
+ const name = raw.trim();
292
+ if (!HEADER_TOKEN.test(name) || seen.has(name.toLowerCase())) continue;
293
+ seen.add(name.toLowerCase());
294
+ allowed.push(name);
295
+ }
296
+ return allowed.join(", ");
297
+ }
298
+ function preflightResponse(request, allowedOrigins) {
299
+ const corsHeaders = buildCorsHeaders(request, allowedOrigins);
300
+ const headers = corsHeaders["Access-Control-Allow-Origin"] ? {
301
+ ...corsHeaders,
302
+ "Access-Control-Allow-Headers": preflightAllowedHeaders(request),
303
+ Vary: "Origin, Access-Control-Request-Headers"
304
+ } : corsHeaders;
305
+ return new Response(null, { status: 204, headers });
306
+ }
307
+
308
+ // src/validation.ts
309
+ var zod = __toESM(require("zod"), 1);
310
+ var z2 = "z" in zod ? zod.z : zod;
311
+ function boundedArray(element, max) {
312
+ return z2.array(z2.unknown()).max(max).pipe(z2.array(element));
313
+ }
314
+ var anchorSchema = z2.object({
315
+ cssSelector: z2.string().min(1).max(2e3),
316
+ xpath: z2.string().min(1).max(2e3),
317
+ textSnippet: z2.string().max(500),
318
+ elementTag: z2.string().min(1).max(ANCHOR_ELEMENT_TAG_MAX),
319
+ // A resolver hint: an over-long id is dropped, as the widget now does,
320
+ // rather than failing the whole payload — earlier widgets send it unbounded.
321
+ elementId: z2.string().transform((id) => id.length <= ANCHOR_ELEMENT_ID_MAX ? id : void 0).optional(),
322
+ textPrefix: z2.string().max(200),
323
+ textSuffix: z2.string().max(200),
324
+ fingerprint: z2.string().max(200),
325
+ neighborText: z2.string().max(500),
326
+ // Optional semantic anchor identifier from `data-feedback-anchor`.
327
+ // Null when no semantic ancestor exists; widget always sends this field.
328
+ anchorKey: z2.string().max(200).nullable().optional()
329
+ });
330
+ var rectSchema = z2.object({
331
+ xPct: z2.number().min(0).max(1),
332
+ yPct: z2.number().min(0).max(1),
333
+ wPct: z2.number().min(0).max(1),
334
+ hPct: z2.number().min(0).max(1)
335
+ });
336
+ var annotationSchema = z2.object({
337
+ anchor: anchorSchema,
338
+ rect: rectSchema,
339
+ // Raw `window.scrollX/Y` — no lower bound: scrollX is negative on a
340
+ // scrolled `dir="rtl"` page. `z.number()` already rejects NaN/±Infinity.
341
+ scrollX: z2.number(),
342
+ scrollY: z2.number(),
343
+ // Upper bound = the `Int` (INT4) column max, so oversized input is a 400
344
+ // here rather than a database error at insert time.
345
+ viewportW: z2.number().int().positive().max(2147483647),
346
+ viewportH: z2.number().int().positive().max(2147483647),
347
+ devicePixelRatio: z2.number().positive().default(1)
348
+ });
349
+ var consoleEntrySchema = z2.object({
350
+ level: z2.enum(CONSOLE_DIAGNOSTIC_LEVELS),
351
+ timestamp: z2.string().max(50),
352
+ message: z2.string().max(600)
353
+ });
354
+ var networkEntrySchema = z2.object({
355
+ url: z2.string().max(2e3),
356
+ method: z2.string().max(20),
357
+ status: z2.number().int().min(0).max(599),
358
+ durationMs: z2.number().min(0).max(6e5),
359
+ timestamp: z2.string().max(50)
360
+ });
361
+ var diagnosticsSchema = z2.object({
362
+ console: boundedArray(consoleEntrySchema, 50),
363
+ network: boundedArray(networkEntrySchema, 20)
364
+ });
365
+ var screenshotRegionSchema = z2.strictObject({
366
+ xPct: z2.number().min(0).max(1),
367
+ yPct: z2.number().min(0).max(1),
368
+ wPct: z2.number().min(0).max(1),
369
+ hPct: z2.number().min(0).max(1)
370
+ });
371
+ var clientIdSchema = z2.string().min(1).max(200).regex(/^[a-zA-Z0-9_-]+$/, "clientId must be alphanumeric (a-z, A-Z, 0-9, _, -)");
372
+ var feedbackCreateSchema = z2.object({
373
+ projectName: z2.string().min(1).max(200),
374
+ type: z2.enum(FEEDBACK_TYPES),
375
+ message: z2.string().min(1).max(5e3),
376
+ // Page-scope identifier the widget uses to group feedbacks. Defaults to
377
+ // `window.location.pathname` ("/orders/42"), but hosts can override
378
+ // `getPageScope()` to return a full URL, an opaque slug, anything they
379
+ // want. We trim + require non-empty so whitespace-only payloads don't
380
+ // leak into the DB; otherwise the value is opaque to the server and only
381
+ // used as a literal Prisma equality filter, so the loose shape is safe.
382
+ url: z2.string().trim().min(1).max(2e3),
383
+ // Optional parameterized URL template (e.g. "/orders/:orderId") provided
384
+ // by the host via `getPageScope()`. Null when host omits it.
385
+ urlPattern: z2.string().max(2e3).nullable().optional(),
386
+ viewport: z2.string().min(1).max(50),
387
+ userAgent: z2.string().min(1).max(500),
388
+ // The widget's identity modal validates against the same core pattern and
389
+ // length cap, so an identity the modal accepts (and persists) is never a
390
+ // 400 here.
391
+ authorName: z2.string().min(1).max(IDENTITY_FIELD_MAX_LENGTH),
392
+ authorEmail: z2.email({ pattern: EMAIL_PATTERN }).max(IDENTITY_FIELD_MAX_LENGTH),
393
+ annotations: boundedArray(annotationSchema, 50),
394
+ clientId: clientIdSchema,
395
+ // Optional base64 JPEG data URL captured by the widget when
396
+ // `enableScreenshot: true`. ~1.5 MB cap = roughly a 1.1 MB JPEG, well
397
+ // above typical sizes (the widget downscales to 1200px). Rejects abuse
398
+ // without truncating legitimate captures.
399
+ screenshotDataUrl: z2.string().max(15e5).regex(/^data:image\/(jpeg|png|webp);base64,/, "screenshotDataUrl must be a data:image/* base64 URL").nullable().optional(),
400
+ // Optional annotation-rect position within the screenshot. Sent by widgets
401
+ // that capture context around the drawn rect; null + omitted are both
402
+ // accepted so legacy clients (and captures without a screenshot) keep
403
+ // working unchanged.
404
+ screenshotRegion: screenshotRegionSchema.nullable().optional(),
405
+ // Optional console + failed-network snapshot. The widget only attaches
406
+ // this when `captureDiagnostics` is enabled; null + omitted are both
407
+ // accepted so existing clients keep working unchanged.
408
+ diagnostics: diagnosticsSchema.nullable().optional()
409
+ });
410
+ var commentCreateSchema = z2.object({
411
+ projectName: z2.string().min(1).max(200),
412
+ feedbackId: z2.string().min(1).max(200),
413
+ // Trimmed, so a whitespace-only reply is a 400 rather than an empty bubble.
414
+ body: z2.string().trim().min(1).max(COMMENT_BODY_MAX_LENGTH),
415
+ authorName: z2.string().min(1).max(IDENTITY_FIELD_MAX_LENGTH),
416
+ // The widget's reviewers always have an email; a dashboard user may have
417
+ // none on file, sent as "". Anything else must be a real address.
418
+ authorEmail: z2.union([z2.literal(""), z2.email({ pattern: EMAIL_PATTERN }).max(IDENTITY_FIELD_MAX_LENGTH)]),
419
+ // A claim, not a fact: the handler keeps "team" only when the access
420
+ // policy vouches for the caller.
421
+ authorRole: z2.enum(COMMENT_AUTHOR_ROLES).default("client"),
422
+ clientId: clientIdSchema
423
+ });
424
+ var commentDeleteSchema = z2.object({
425
+ projectName: z2.string().min(1).max(200),
426
+ feedbackId: z2.string().min(1).max(200),
427
+ commentId: z2.string().min(1).max(200)
428
+ });
429
+ var feedbackPatchSchema = z2.object({
430
+ id: z2.string().min(1),
431
+ projectName: z2.string().min(1).max(200),
432
+ status: z2.enum(FEEDBACK_STATUSES)
433
+ });
434
+ var feedbackDeleteSchema = z2.union([
435
+ z2.object({ id: z2.string().min(1), projectName: z2.string().min(1).max(200) }),
436
+ z2.object({ projectName: z2.string().min(1).max(200), deleteAll: z2.literal(true) })
437
+ ]);
438
+ var getQuerySchema = z2.object({
439
+ projectName: z2.string().min(1).max(200),
440
+ page: z2.coerce.number().int().min(1).default(1),
441
+ limit: z2.coerce.number().int().min(1).max(100).default(50),
442
+ type: z2.enum(FEEDBACK_TYPES).optional(),
443
+ status: z2.enum(FEEDBACK_STATUSES).optional(),
444
+ // Bucket status filter serialized as CSV over the wire (e.g.
445
+ // `statuses=open,in_progress`). Non-empty strings are split before each value
446
+ // is validated against the known statuses; `statuses` wins over the exact
447
+ // `status` filter downstream. Capped at 4 — the number of known statuses.
448
+ statuses: z2.preprocess(
449
+ (val) => typeof val === "string" && val.length > 0 ? val.split(",") : val,
450
+ boundedArray(z2.enum(FEEDBACK_STATUSES), 4)
451
+ ).optional(),
452
+ search: z2.string().max(200).optional(),
453
+ // Page scope filters — used by the panel's "this page / this type" controls
454
+ url: z2.string().max(2e3).optional(),
455
+ urlPattern: z2.string().max(2e3).optional()
456
+ });
457
+ function formatValidationErrors(error) {
458
+ return error.issues.slice(0, MAX_VALIDATION_ISSUES).map((issue) => ({
459
+ field: issue.path.map(String).join("."),
460
+ message: issue.message
461
+ }));
462
+ }
463
+
464
+ // src/operations/create-comment.ts
465
+ function createCommentOperation({
466
+ store,
467
+ pipeline,
468
+ beforeComment
469
+ }) {
470
+ return async (scope, body) => {
471
+ const payload = pipeline.validate(scope, commentCreateSchema, body);
472
+ if (!payload.ok) return payload.response;
473
+ try {
474
+ const { projectName, feedbackId, authorRole, ...comment } = beforeComment ? await beforeComment(payload.value, scope.context) : payload.value;
475
+ const refusal = await pipeline.authorize(scope, { action: "createComment", projectName, feedbackId });
476
+ if (refusal) return refusal;
477
+ if (!store.addComment) return pipeline.error(scope, 501, ERROR_MESSAGES.commentsUnsupported);
478
+ if (store.verifyProjectOwnership && !await store.verifyProjectOwnership(feedbackId, projectName)) {
479
+ return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
480
+ }
481
+ const role = authorRole === "team" && await pipeline.canCommentAsTeam(scope) ? "team" : "client";
482
+ const stored = await store.addComment(feedbackId, { ...comment, authorRole: role });
483
+ if (stored.feedbackId !== feedbackId) {
484
+ return pipeline.error(scope, 409, ERROR_MESSAGES.clientIdUsedByAnotherFeedback);
485
+ }
486
+ return pipeline.json(scope, pipeline.presentCreatedComment(scope, stored), { status: 201 });
487
+ } catch (error) {
488
+ if (isStoreNotFound(error)) return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
489
+ if (isStoreLimit(error)) return pipeline.error(scope, 409, ERROR_MESSAGES.tooManyComments);
490
+ if (isStoreValueTooLong(error)) return pipeline.refuseTooLong(scope, error);
491
+ return pipeline.fail(scope, "[siteping] Failed to add comment", error);
492
+ }
493
+ };
494
+ }
495
+
496
+ // src/webhooks.ts
497
+ var DEFAULT_TIMEOUT_MS = 5e3;
498
+ var TIMER_MAX_DELAY_MS = 2147483647;
499
+ function timeoutProblem(timeoutMs) {
500
+ if (timeoutMs === void 0 || Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= TIMER_MAX_DELAY_MS) {
501
+ return null;
502
+ }
503
+ return `timeoutMs must be a positive integer of at most ${TIMER_MAX_DELAY_MS}, got ${timeoutMs}`;
504
+ }
505
+ function checkWebhookTimeouts(configs) {
506
+ for (const config of configs) {
507
+ const problem = timeoutProblem(config.timeoutMs);
508
+ if (problem) {
509
+ throw new Error(`[siteping] createSitepingHandler: webhook to ${webhookOrigin(config.url)}: ${problem}.`);
510
+ }
511
+ }
512
+ }
513
+ var DISCORD_COLORS = {
514
+ bug: 15680580,
515
+ question: 3900150,
516
+ change: 16096779,
517
+ other: 7041664
518
+ };
519
+ var DEFAULT_DISCORD_COLOR = 7041664;
520
+ function truncate(text, max = 300) {
521
+ if (text.length <= max) return text;
522
+ return `${text.slice(0, max - 1)}\u2026`;
523
+ }
524
+ function fitEscaped(units, max) {
525
+ const escaped = units.join("");
526
+ if (escaped.length <= max) return escaped;
527
+ let out = "";
528
+ for (const unit of units) {
529
+ if (out.length + unit.length > max - 1) break;
530
+ out += unit;
531
+ }
532
+ return `${out}\u2026`;
533
+ }
534
+ var SLACK_HEADER_MAX = 150;
535
+ var SLACK_TEXT_MAX = 3e3;
536
+ function escapeSlackText(text) {
537
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
538
+ }
539
+ function buildSlackPayload(feedback) {
540
+ const preview = escapeSlackText(truncate(feedback.message));
541
+ const escapeField = (value, max) => fitEscaped(Array.from(value, escapeSlackText), max);
542
+ const fromHalf = Math.floor((SLACK_TEXT_MAX - 11) / 2);
543
+ const headline = `New ${feedback.type} feedback from ${feedback.authorName}`;
544
+ return {
545
+ text: `${escapeSlackText(headline)}: ${preview}`,
546
+ blocks: [
547
+ {
548
+ type: "header",
549
+ text: { type: "plain_text", text: truncate(headline, SLACK_HEADER_MAX), emoji: true }
550
+ },
551
+ {
552
+ type: "section",
553
+ text: { type: "mrkdwn", text: preview }
554
+ },
555
+ {
556
+ type: "context",
557
+ elements: [
558
+ { type: "mrkdwn", text: `*Project:* ${escapeField(feedback.projectName, SLACK_TEXT_MAX - 11)}` },
559
+ { type: "mrkdwn", text: `*Type:* ${feedback.type}` },
560
+ { type: "mrkdwn", text: `*URL:* ${escapeField(feedback.url, SLACK_TEXT_MAX - 7)}` },
561
+ {
562
+ type: "mrkdwn",
563
+ text: `*From:* ${escapeField(feedback.authorName, fromHalf)} (${escapeField(feedback.authorEmail, fromHalf)})`
564
+ }
565
+ ]
566
+ }
567
+ ]
568
+ };
569
+ }
570
+ var DISCORD_CONTENT_MAX = 2e3;
571
+ var DISCORD_TITLE_MAX = 256;
572
+ var DISCORD_FIELD_VALUE_MAX = 1024;
573
+ var DISCORD_MARKDOWN = /[\\*_~`|>#[\]()<]/g;
574
+ var DISCORD_AUTOLINK = /(<https?:\/\/[^\s<>]+>|https?:\/\/[^\s<>]+)/i;
575
+ var DISCORD_LINK_TRAILER = `.,:;"')]`;
576
+ var escapeMarkdownChar = (char) => char.replace(DISCORD_MARKDOWN, "\\$&");
577
+ var encodeLinkChar = (char) => char.replace(/[[\]()]/g, (bracket) => `%${bracket.charCodeAt(0).toString(16).toUpperCase()}`);
578
+ var count = (text, char) => text.split(char).length - 1;
579
+ function discordUrlUnits(url) {
580
+ const open = { ")": count(url, "(") - count(url, ")"), "]": count(url, "[") - count(url, "]") };
581
+ let end = url.length;
582
+ while (end > 0) {
583
+ const last = url.charAt(end - 1);
584
+ if (!DISCORD_LINK_TRAILER.includes(last)) break;
585
+ if (last === ")" || last === "]") {
586
+ if (open[last] >= 0) break;
587
+ open[last] += 1;
588
+ }
589
+ end -= 1;
590
+ }
591
+ return [...Array.from(url.slice(0, end), encodeLinkChar), ...url.slice(end)];
592
+ }
593
+ function escapeDiscordText(text, max) {
594
+ const units = text.split(DISCORD_AUTOLINK).flatMap((part, index) => index % 2 === 1 ? discordUrlUnits(part) : Array.from(part, escapeMarkdownChar));
595
+ return fitEscaped(units, max);
596
+ }
597
+ function buildDiscordPayload(feedback) {
598
+ const contentLead = `New **${feedback.type}** feedback from **`;
599
+ const titleLead = `${feedback.type} \u2014 `;
600
+ const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
601
+ return {
602
+ content: `${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
603
+ embeds: [
604
+ {
605
+ title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
606
+ description: escapeDiscordText(feedback.message, 300),
607
+ color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
608
+ fields: [
609
+ { name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false },
610
+ {
611
+ name: "Author",
612
+ value: `${escapeDiscordText(feedback.authorName, authorHalf)} (${escapeDiscordText(feedback.authorEmail, authorHalf)})`,
613
+ inline: true
614
+ },
615
+ { name: "Viewport", value: escapeDiscordText(feedback.viewport, DISCORD_FIELD_VALUE_MAX), inline: true }
616
+ ],
617
+ timestamp: new Date(feedback.createdAt).toISOString()
618
+ }
619
+ ],
620
+ allowed_mentions: { parse: [] }
621
+ };
622
+ }
623
+ function buildGenericPayload(feedback) {
624
+ const { clientId: _clientId, comments, ...payload } = feedback;
625
+ if (!comments) return payload;
626
+ return { ...payload, comments: comments.map(({ clientId: _commentClientId, ...comment }) => comment) };
627
+ }
628
+ function buildWebhookPayload(type, feedback) {
629
+ switch (type) {
630
+ case "slack":
631
+ return buildSlackPayload(feedback);
632
+ case "discord":
633
+ return buildDiscordPayload(feedback);
634
+ default:
635
+ return buildGenericPayload(feedback);
636
+ }
637
+ }
638
+ async function dispatchWebhook(config, feedback) {
639
+ const problem = timeoutProblem(config.timeoutMs);
640
+ if (problem) return reportError(config, new RangeError(problem), feedback.id);
641
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
642
+ const controller = new AbortController();
643
+ let timer;
644
+ try {
645
+ const body = JSON.stringify(buildWebhookPayload(config.type ?? "generic", feedback));
646
+ const custom = config.headers ?? {};
647
+ const setsContentType = Object.keys(custom).some((name) => name.toLowerCase() === "content-type");
648
+ const headers = setsContentType ? custom : { "Content-Type": "application/json", ...custom };
649
+ timer = setTimeout(() => controller.abort(), timeoutMs);
650
+ const response = await fetch(config.url, {
651
+ method: "POST",
652
+ headers,
653
+ body,
654
+ signal: controller.signal
655
+ });
656
+ clearTimeout(timer);
657
+ if (!response.ok) {
658
+ const err = new Error(`Webhook responded with HTTP ${response.status}`);
659
+ await reportError(config, err, feedback.id);
660
+ }
661
+ } catch (rawError) {
662
+ clearTimeout(timer);
663
+ const err = rawError instanceof Error ? rawError : new Error(String(rawError));
664
+ await reportError(config, err, feedback.id);
665
+ }
666
+ }
667
+ async function reportError(config, err, feedbackId) {
668
+ if (config.onError) {
669
+ try {
670
+ await config.onError(err, feedbackId);
671
+ } catch (callbackErr) {
672
+ warnWithoutUrl(
673
+ config.url,
674
+ `webhook onError() callback threw for feedback ${feedbackId}: ${String(callbackErr)} (original error: ${err.message})`
675
+ );
676
+ }
677
+ return;
678
+ }
679
+ warnWithoutUrl(
680
+ config.url,
681
+ `webhook to ${webhookOrigin(config.url)} failed for feedback ${feedbackId}: ${err.message}`
682
+ );
683
+ }
684
+ function webhookOrigin(url) {
685
+ try {
686
+ return new URL(url).origin;
687
+ } catch {
688
+ return "<invalid URL>";
689
+ }
690
+ }
691
+ function warnWithoutUrl(url, message) {
692
+ console.warn(`[siteping] ${url ? message.split(url).join(webhookOrigin(url)) : message}`);
693
+ }
694
+ async function dispatchWebhooks(configs, feedback) {
695
+ if (configs.length === 0) return;
696
+ await Promise.all(configs.map((c) => dispatchWebhook(c, feedback)));
697
+ }
698
+
699
+ // src/operations/create-feedback.ts
700
+ var FAILED_TO_CREATE = "[siteping] Failed to create feedback";
701
+ function toCreateInput(data) {
702
+ return {
703
+ projectName: data.projectName,
704
+ type: data.type,
705
+ message: data.message,
706
+ status: "open",
707
+ url: data.url,
708
+ urlPattern: data.urlPattern ?? null,
709
+ viewport: data.viewport,
710
+ userAgent: data.userAgent,
711
+ authorName: data.authorName,
712
+ authorEmail: data.authorEmail,
713
+ clientId: data.clientId,
714
+ annotations: data.annotations.map(flattenAnnotation),
715
+ screenshotDataUrl: data.screenshotDataUrl ?? null,
716
+ screenshotRegion: data.screenshotRegion ?? null,
717
+ diagnostics: data.diagnostics ?? null
718
+ };
719
+ }
720
+ function createFeedbackOperation({
721
+ store,
722
+ pipeline,
723
+ webhooks,
724
+ waitUntil,
725
+ beforeCreate,
726
+ onCreated
727
+ }) {
728
+ const inflightCreates = /* @__PURE__ */ new Map();
729
+ async function createOrReplay(input) {
730
+ if (store.createFeedbackIfAbsent) {
731
+ const { feedback, created } = await store.createFeedbackIfAbsent(input);
732
+ return { feedback, inserted: created };
733
+ }
734
+ const replayed = await store.findByClientId(input.clientId);
735
+ if (replayed) return { feedback: replayed, inserted: false };
736
+ return { feedback: await store.createFeedback(input), inserted: true };
737
+ }
738
+ return async (scope, body) => {
739
+ const payload = pipeline.validate(scope, feedbackCreateSchema, body);
740
+ if (!payload.ok) return payload.response;
741
+ if (payload.value.annotations.length > MAX_ANNOTATIONS_PER_FEEDBACK) {
742
+ return pipeline.error(scope, 400, ERROR_MESSAGES.tooManyAnnotations);
743
+ }
744
+ let input;
745
+ try {
746
+ const validated = toCreateInput(payload.value);
747
+ input = beforeCreate ? await beforeCreate(validated, scope.context) : validated;
748
+ const refusal = await pipeline.authorize(scope, { action: "create", projectName: input.projectName });
749
+ if (refusal) return refusal;
750
+ } catch (error) {
751
+ return pipeline.fail(scope, FAILED_TO_CREATE, error);
752
+ }
753
+ let pending = inflightCreates.get(input.clientId);
754
+ const owner = pending === void 0;
755
+ if (!pending) {
756
+ pending = createOrReplay(input);
757
+ inflightCreates.set(input.clientId, pending);
758
+ }
759
+ let outcome;
760
+ try {
761
+ outcome = await pending;
762
+ } catch (error) {
763
+ if (isStoreValueTooLong(error)) return pipeline.refuseTooLong(scope, error);
764
+ let existing = null;
765
+ if (isStoreDuplicate(error)) {
766
+ try {
767
+ existing = await store.findByClientId(input.clientId);
768
+ } catch (lookupError) {
769
+ pipeline.logError(scope, "[siteping] Failed to look up the duplicate clientId", {
770
+ error: lookupError,
771
+ projectName: input.projectName
772
+ });
773
+ }
774
+ }
775
+ if (!existing) return pipeline.fail(scope, FAILED_TO_CREATE, error);
776
+ outcome = { feedback: existing, inserted: false };
777
+ } finally {
778
+ if (owner) inflightCreates.delete(input.clientId);
779
+ }
780
+ const { feedback, inserted } = outcome;
781
+ const subject = { feedbackId: feedback.id, projectName: feedback.projectName };
782
+ try {
783
+ if (owner && inserted && feedback.projectName === input.projectName) {
784
+ if (webhooks.length > 0) {
785
+ const delivery = dispatchWebhooks(webhooks, feedback);
786
+ try {
787
+ waitUntil?.(delivery);
788
+ } catch (error) {
789
+ pipeline.logError(scope, "[siteping] waitUntil failed", { error, ...subject });
790
+ }
791
+ }
792
+ if (onCreated) await pipeline.runHook(scope, "onCreated", subject, () => onCreated(feedback, scope.context));
793
+ }
794
+ if (feedback.projectName !== input.projectName) {
795
+ return pipeline.error(scope, 409, ERROR_MESSAGES.clientIdUsedByAnotherProject);
796
+ }
797
+ return pipeline.json(scope, await pipeline.presentCreated(scope, feedback), { status: 201 });
798
+ } catch (error) {
799
+ return pipeline.fail(scope, FAILED_TO_CREATE, error);
800
+ }
801
+ };
802
+ }
803
+
804
+ // src/operations/delete-comment.ts
805
+ function deleteCommentOperation({ store, pipeline }) {
806
+ return async (scope, body) => {
807
+ const payload = pipeline.validate(scope, commentDeleteSchema, body);
808
+ if (!payload.ok) return payload.response;
809
+ const { projectName, feedbackId, commentId } = payload.value;
810
+ try {
811
+ const refusal = await pipeline.authorize(scope, { action: "deleteComment", projectName, feedbackId, commentId });
812
+ if (refusal) return refusal;
813
+ if (!store.deleteComment) return pipeline.error(scope, 501, ERROR_MESSAGES.commentsUnsupported);
814
+ if (store.verifyProjectOwnership && !await store.verifyProjectOwnership(feedbackId, projectName)) {
815
+ return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
816
+ }
817
+ await store.deleteComment(feedbackId, commentId);
818
+ return pipeline.json(scope, { deleted: true });
819
+ } catch (error) {
820
+ if (isStoreNotFound(error)) return pipeline.error(scope, 404, ERROR_MESSAGES.commentNotFound);
821
+ return pipeline.fail(scope, "[siteping] Failed to delete comment", error);
822
+ }
823
+ };
824
+ }
825
+
826
+ // src/operations/delete-feedback.ts
827
+ function deleteFeedbackOperation({
828
+ store,
829
+ pipeline,
830
+ onDeleting,
831
+ onDeleted
832
+ }) {
833
+ return async (scope, body) => {
834
+ const payload = pipeline.validate(scope, feedbackDeleteSchema, body);
835
+ if (!payload.ok) return payload.response;
836
+ const target = "deleteAll" in payload.value ? { kind: "project", projectName: payload.value.projectName } : { kind: "single", id: payload.value.id, projectName: payload.value.projectName };
837
+ try {
838
+ const refusal = await pipeline.authorize(
839
+ scope,
840
+ target.kind === "project" ? { action: "deleteAll", projectName: target.projectName } : { action: "delete", projectName: target.projectName, feedbackId: target.id }
841
+ );
842
+ if (refusal) return refusal;
843
+ if (target.kind === "single" && store.verifyProjectOwnership && !await store.verifyProjectOwnership(target.id, target.projectName)) {
844
+ return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
845
+ }
846
+ if (onDeleting) {
847
+ try {
848
+ await onDeleting(target, scope.context);
849
+ } catch (error) {
850
+ pipeline.logError(scope, "[siteping] Hook onDeleting aborted the deletion", { error, target });
851
+ return pipeline.error(scope, 502, ERROR_MESSAGES.deletionAborted);
852
+ }
853
+ }
854
+ if (target.kind === "project") await store.deleteAllFeedbacks(target.projectName);
855
+ else await store.deleteFeedback(target.id);
856
+ if (onDeleted) await pipeline.runHook(scope, "onDeleted", { target }, () => onDeleted(target, scope.context));
857
+ return pipeline.json(scope, { deleted: true });
858
+ } catch (error) {
859
+ if (isStoreNotFound(error)) return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
860
+ return pipeline.fail(scope, "[siteping] Failed to delete feedback", error);
861
+ }
862
+ };
863
+ }
864
+
865
+ // src/operations/list-feedbacks.ts
866
+ function readListQuery(request) {
867
+ const searchParams = new URL(request.url).searchParams;
868
+ const rawQuery = {};
869
+ for (const key of LIST_QUERY_KEYS) {
870
+ const value = searchParams.get(key);
871
+ if (value !== null) rawQuery[key] = value;
872
+ }
873
+ return rawQuery;
874
+ }
875
+ function listFeedbacksOperation({ store, pipeline }) {
876
+ return async (request) => {
877
+ const entry = await pipeline.enter(request, "GET");
878
+ if (!entry.ok) return entry.response;
879
+ const scope = entry.value;
880
+ const query = pipeline.validate(scope, getQuerySchema, readListQuery(request));
881
+ if (!query.ok) return query.response;
882
+ try {
883
+ const { projectName } = query.value;
884
+ const refusal = await pipeline.authorize(scope, { action: "list", projectName });
885
+ if (refusal) return refusal;
886
+ const page = await store.getFeedbacks(query.value);
887
+ const [feedbacks, canDeleteAll] = await Promise.all([
888
+ Promise.all(page.feedbacks.map((feedback) => pipeline.present(scope, feedback))),
889
+ pipeline.may(scope, "DELETE", { action: "deleteAll", projectName })
890
+ ]);
891
+ return pipeline.json(
892
+ scope,
893
+ {
894
+ ...page,
895
+ feedbacks,
896
+ // Lets clients hide their comment composer, and delete buttons, up front instead of meeting a 501.
897
+ capabilities: {
898
+ comments: store.addComment !== void 0,
899
+ deleteComments: store.deleteComment !== void 0
900
+ },
901
+ // And the actions this requester would be refused.
902
+ permissions: { canDeleteAll }
903
+ },
904
+ { headers: { "Cache-Control": pipeline.listCacheControl } }
905
+ );
906
+ } catch (error) {
907
+ return pipeline.fail(scope, "[siteping] Failed to fetch feedbacks", error);
908
+ }
909
+ };
910
+ }
911
+
912
+ // src/operations/update-feedback.ts
913
+ function updateFeedbackOperation({
914
+ store,
915
+ pipeline,
916
+ onUpdated
917
+ }) {
918
+ return async (request) => {
919
+ const entry = await pipeline.enter(request, "PATCH");
920
+ if (!entry.ok) return entry.response;
921
+ const scope = entry.value;
922
+ const payload = await pipeline.readBody(scope, feedbackPatchSchema);
923
+ if (!payload.ok) return payload.response;
924
+ const { id, projectName, status } = payload.value;
925
+ try {
926
+ const refusal = await pipeline.authorize(scope, { action: "update", projectName, feedbackId: id });
927
+ if (refusal) return refusal;
928
+ if (store.verifyProjectOwnership && !await store.verifyProjectOwnership(id, projectName)) {
929
+ return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
930
+ }
931
+ const feedback = await store.updateFeedback(id, toFeedbackUpdate(status));
932
+ if (onUpdated) {
933
+ const subject = { feedbackId: feedback.id, projectName: feedback.projectName };
934
+ await pipeline.runHook(scope, "onUpdated", subject, () => onUpdated(feedback, scope.context));
935
+ }
936
+ return pipeline.json(scope, await pipeline.present(scope, feedback));
937
+ } catch (error) {
938
+ if (isStoreNotFound(error)) return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
939
+ return pipeline.fail(scope, "[siteping] Failed to update feedback", error);
940
+ }
941
+ };
942
+ }
943
+
944
+ // src/csrf.ts
945
+ var MAX_LOGGED_ORIGIN_LENGTH = 128;
946
+ function csrfRefusal(request, method, allowedOrigins) {
947
+ if (method !== "POST" && method !== "PATCH" && method !== "DELETE") return null;
948
+ const origin = request.headers.get("Origin");
949
+ if (allowedOrigins && origin !== null && !allowedOrigins.includes(origin) && origin !== new URL(request.url).origin) {
950
+ return { status: 403, origin: origin.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, MAX_LOGGED_ORIGIN_LENGTH) };
951
+ }
952
+ const [mediaType = ""] = (request.headers.get("Content-Type") ?? "").split(";");
953
+ if (mediaType.trim().toLowerCase() !== "application/json") return { status: 415 };
954
+ return null;
955
+ }
956
+
957
+ // src/pipeline.ts
958
+ function toWireComment(comment, includeEmail) {
959
+ const { clientId: _clientId, ...wire } = comment;
960
+ return includeEmail ? wire : { ...wire, authorEmail: "" };
961
+ }
962
+ function toWireFeedback(feedback, includeEmail, includeCommentEmails) {
963
+ const { clientId: _clientId, comments, ...wire } = feedback;
964
+ return {
965
+ ...wire,
966
+ ...includeEmail ? {} : { authorEmail: "" },
967
+ comments: (comments ?? []).map((comment) => toWireComment(comment, includeCommentEmails))
968
+ };
969
+ }
970
+ function createSlots(size) {
971
+ let running = 0;
972
+ const waiting = [];
973
+ return async (task) => {
974
+ if (running < size) running += 1;
975
+ else await new Promise((resolve) => waiting.push(resolve));
976
+ try {
977
+ return await task();
978
+ } finally {
979
+ const next = waiting.shift();
980
+ if (next) next();
981
+ else running -= 1;
982
+ }
983
+ };
984
+ }
985
+ async function readText(request, maxBytes) {
986
+ if (Number(request.headers.get("Content-Length")) > maxBytes) return null;
987
+ if (!request.body) return "";
988
+ const reader = request.body.getReader();
989
+ const decoder = new TextDecoder();
990
+ let text = "";
991
+ let received = 0;
992
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
993
+ received += chunk.value.byteLength;
994
+ if (received > maxBytes) {
995
+ await reader.cancel().catch(() => {
996
+ });
997
+ return null;
998
+ }
999
+ text += decoder.decode(chunk.value, { stream: true });
1000
+ }
1001
+ return text + decoder.decode();
1002
+ }
1003
+ function parseJson(text) {
1004
+ try {
1005
+ return JSON.parse(text);
1006
+ } catch {
1007
+ return null;
1008
+ }
1009
+ }
1010
+ function requestContext(request) {
1011
+ return { method: request.method, path: new URL(request.url).pathname };
1012
+ }
1013
+ function createPipeline({
1014
+ gate,
1015
+ allowedOrigins,
1016
+ logger,
1017
+ describeError,
1018
+ presentFeedback,
1019
+ maxBodyBytes
1020
+ }) {
1021
+ const json = (scope, body, init) => withCors(Response.json(body, init), scope.corsHeaders);
1022
+ const error = (scope, status, message) => json(scope, { error: message }, { status });
1023
+ const fail = (request, scope, message, failure) => {
1024
+ logger.error(message, { error: failure, ...requestContext(request) });
1025
+ return error(scope, 500, describeError?.(failure) ?? ERROR_MESSAGES.internalServerError);
1026
+ };
1027
+ const logError = (scope, message, details) => {
1028
+ logger.error(message, { ...details, ...requestContext(scope.context.request) });
1029
+ };
1030
+ const validate = (scope, schema, input) => {
1031
+ const parsed = schema.safeParse(input);
1032
+ if (parsed.success) return { ok: true, value: parsed.data };
1033
+ return { ok: false, response: json(scope, { errors: formatValidationErrors(parsed.error) }, { status: 400 }) };
1034
+ };
1035
+ const readJson = async (scope) => {
1036
+ const text = await readText(scope.context.request, maxBodyBytes).catch(() => "");
1037
+ if (text === null) return { ok: false, response: error(scope, 413, ERROR_MESSAGES.bodyTooLarge) };
1038
+ const body = parseJson(text);
1039
+ if (!body) return { ok: false, response: error(scope, 400, ERROR_MESSAGES.invalidJson) };
1040
+ return { ok: true, value: body };
1041
+ };
1042
+ const loggedDryRunFailures = /* @__PURE__ */ new WeakSet();
1043
+ const dryRunSlots = /* @__PURE__ */ new WeakMap();
1044
+ const may = async (scope, method, target) => {
1045
+ const { request } = scope.context;
1046
+ const slots = dryRunSlots.get(request) ?? createSlots(DRY_RUN_CONCURRENCY);
1047
+ dryRunSlots.set(request, slots);
1048
+ try {
1049
+ return await slots(
1050
+ async () => await gate.admits(request, method) && await gate.authorize({ ...scope.context, ...target, dryRun: true })
1051
+ );
1052
+ } catch (failure) {
1053
+ if (!loggedDryRunFailures.has(request)) {
1054
+ loggedDryRunFailures.add(request);
1055
+ logger.error("[siteping] authorize failed on a dry run", {
1056
+ error: failure,
1057
+ action: target.action,
1058
+ ...requestContext(request)
1059
+ });
1060
+ }
1061
+ return false;
1062
+ }
1063
+ };
1064
+ const permissions = async (scope, feedback) => {
1065
+ const target = { projectName: feedback.projectName, feedbackId: feedback.id };
1066
+ const [canChangeStatus, canDelete, canComment, canDeleteComment] = await Promise.all([
1067
+ may(scope, "PATCH", { action: "update", ...target }),
1068
+ may(scope, "DELETE", { action: "delete", ...target }),
1069
+ may(scope, "POST", { action: "createComment", ...target }),
1070
+ may(scope, "DELETE", { action: "deleteComment", ...target })
1071
+ ]);
1072
+ return { canChangeStatus, canDelete, canComment, canDeleteComment };
1073
+ };
1074
+ const present = async (scope, feedback, includeEmail = scope.canReadAuthorEmail) => ({
1075
+ ...toWireFeedback(
1076
+ presentFeedback ? presentFeedback(feedback, scope.context) : feedback,
1077
+ includeEmail,
1078
+ scope.canReadAuthorEmail
1079
+ ),
1080
+ permissions: await permissions(scope, feedback)
1081
+ });
1082
+ return {
1083
+ json,
1084
+ error,
1085
+ validate,
1086
+ may,
1087
+ present,
1088
+ /**
1089
+ * Open a request: the CSRF guards when the policy needs them, then the
1090
+ * access gate. A throwing `access` callback (session store down…)
1091
+ * answers the logged 500 with CORS headers rather than rejecting, which
1092
+ * a browser would only see as an opaque network error.
1093
+ */
1094
+ async enter(request, method) {
1095
+ const corsHeaders = buildCorsHeaders(request, allowedOrigins);
1096
+ if (gate.guardsMutations) {
1097
+ const refusal = csrfRefusal(request, method, allowedOrigins);
1098
+ if (refusal?.status === 403) {
1099
+ logger.error("[siteping] Refused a mutation from an origin outside allowedOrigins", {
1100
+ origin: refusal.origin,
1101
+ ...requestContext(request)
1102
+ });
1103
+ return { ok: false, response: error({ corsHeaders }, 403, ERROR_MESSAGES.forbidden) };
1104
+ }
1105
+ if (refusal) return { ok: false, response: error({ corsHeaders }, 415, ERROR_MESSAGES.unsupportedMediaType) };
1106
+ }
1107
+ let outcome;
1108
+ try {
1109
+ outcome = await gate.authenticate(request, method);
1110
+ } catch (failure) {
1111
+ return {
1112
+ ok: false,
1113
+ response: fail(request, { corsHeaders }, "[siteping] Failed to authenticate request", failure)
1114
+ };
1115
+ }
1116
+ if (!outcome.ok) return { ok: false, response: error({ corsHeaders }, outcome.status, outcome.error) };
1117
+ return {
1118
+ ok: true,
1119
+ value: {
1120
+ context: { request, principal: outcome.principal },
1121
+ corsHeaders,
1122
+ canReadAuthorEmail: outcome.canReadAuthorEmail
1123
+ }
1124
+ };
1125
+ },
1126
+ readJson,
1127
+ /** Read and validate a JSON body. */
1128
+ async readBody(scope, schema) {
1129
+ const body = await readJson(scope);
1130
+ return body.ok ? validate(scope, schema, body.value) : body;
1131
+ },
1132
+ /** `null` when the policy allows the request, its 403 otherwise. */
1133
+ async authorize(scope, target) {
1134
+ return await gate.authorize({ ...scope.context, ...target }) ? null : error(scope, 403, ERROR_MESSAGES.forbidden);
1135
+ },
1136
+ /**
1137
+ * Wire shape of a record answering a POST (fresh or replayed): the
1138
+ * requester's email permission, unless the policy echoes the email to
1139
+ * its submitter — who supplied the feedback's, never its thread's.
1140
+ */
1141
+ presentCreated(scope, feedback) {
1142
+ return present(scope, feedback, gate.echoesAuthorEmailOnCreate || scope.canReadAuthorEmail);
1143
+ },
1144
+ /** Wire shape of a comment answering a POST — the same echo rule as {@link presentCreated}. */
1145
+ presentCreatedComment(scope, comment) {
1146
+ return toWireComment(comment, gate.echoesAuthorEmailOnCreate || scope.canReadAuthorEmail);
1147
+ },
1148
+ /** Whether this caller's comment keeps the `team` role it asks for — see `AccessGate.canCommentAsTeam`. */
1149
+ canCommentAsTeam(scope) {
1150
+ return gate.canCommentAsTeam(scope.context, scope.canReadAuthorEmail);
1151
+ },
1152
+ /**
1153
+ * Answer a value the store cannot hold with a 422, which a client does
1154
+ * not retry, and log it: the database's columns are narrower than what
1155
+ * the validation accepts, which only the operator can fix.
1156
+ */
1157
+ refuseTooLong(scope, failure) {
1158
+ logError(scope, "[siteping] A value is too long for the store", { error: failure });
1159
+ return error(scope, 422, ERROR_MESSAGES.valueTooLong);
1160
+ },
1161
+ /** Log an unexpected failure of an operation and answer its JSON 500. */
1162
+ fail(scope, message, failure) {
1163
+ return fail(scope.context.request, scope, message, failure);
1164
+ },
1165
+ logError,
1166
+ /** Run a lifecycle hook after a write; a failure is logged with `subject`, never surfaced — the write happened. */
1167
+ async runHook(scope, name, subject, invoke) {
1168
+ try {
1169
+ await invoke();
1170
+ } catch (failure) {
1171
+ logError(scope, `[siteping] Hook ${name} failed`, { error: failure, ...subject });
1172
+ }
1173
+ },
1174
+ /** The list response's `Cache-Control`. */
1175
+ listCacheControl: gate.listCacheControl
1176
+ };
1177
+ }
1178
+
1179
+ // src/handler.ts
1180
+ var consoleLogger = {
1181
+ error(message, context) {
1182
+ console.error(message, context);
1183
+ }
1184
+ };
1185
+ function safeLogger(logger) {
1186
+ return {
1187
+ error(message, context) {
1188
+ try {
1189
+ Promise.resolve(logger.error(message, context)).catch(() => consoleLogger.error(message, context));
1190
+ } catch {
1191
+ consoleLogger.error(message, context);
1192
+ }
1193
+ }
1194
+ };
1195
+ }
1196
+ function routeByBody(pipeline, method, commentKey, comment, feedback) {
1197
+ return async (request) => {
1198
+ const entry = await pipeline.enter(request, method);
1199
+ if (!entry.ok) return entry.response;
1200
+ const body = await pipeline.readJson(entry.value);
1201
+ if (!body.ok) return body.response;
1202
+ return (hasOwn(body.value, commentKey) ? comment : feedback)(entry.value, body.value);
1203
+ };
1204
+ }
1205
+ function createSitepingHandler(options) {
1206
+ const {
1207
+ store,
1208
+ allowedOrigins,
1209
+ webhooks,
1210
+ waitUntil,
1211
+ beforeCreate,
1212
+ beforeComment,
1213
+ presentFeedback,
1214
+ hooks = {},
1215
+ logger: customLogger,
1216
+ describeError,
1217
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES
1218
+ } = options;
1219
+ if (!store) {
1220
+ throw new Error("[siteping] createSitepingHandler requires a `store`.");
1221
+ }
1222
+ if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes <= 0) {
1223
+ throw new Error(
1224
+ `[siteping] createSitepingHandler: \`maxBodyBytes\` must be a positive integer, got ${maxBodyBytes}.`
1225
+ );
1226
+ }
1227
+ if (options.access?.authorize && !store.verifyProjectOwnership) {
1228
+ throw new Error(
1229
+ "[siteping] createSitepingHandler: `access.authorize` needs a store implementing `verifyProjectOwnership`. Without it, a caller authorized for one project could PATCH or DELETE another project's feedback by id."
1230
+ );
1231
+ }
1232
+ const gate = options.access ? createAccessGate(options.access) : createApiKeyGate(options);
1233
+ const logger = customLogger ? safeLogger(customLogger) : consoleLogger;
1234
+ const pipeline = createPipeline({ gate, allowedOrigins, logger, describeError, presentFeedback, maxBodyBytes });
1235
+ const webhookList = webhooks ? Array.isArray(webhooks) ? webhooks : [webhooks] : [];
1236
+ checkWebhookTimeouts(webhookList);
1237
+ return {
1238
+ OPTIONS: (request) => preflightResponse(request, allowedOrigins),
1239
+ POST: routeByBody(
1240
+ pipeline,
1241
+ "POST",
1242
+ "feedbackId",
1243
+ createCommentOperation({ store, pipeline, beforeComment }),
1244
+ createFeedbackOperation({
1245
+ store,
1246
+ pipeline,
1247
+ webhooks: webhookList,
1248
+ waitUntil,
1249
+ beforeCreate,
1250
+ // Bound so hooks written as class methods keep their `this`.
1251
+ onCreated: hooks.onCreated?.bind(hooks)
1252
+ })
1253
+ ),
1254
+ GET: listFeedbacksOperation({ store, pipeline }),
1255
+ PATCH: updateFeedbackOperation({ store, pipeline, onUpdated: hooks.onUpdated?.bind(hooks) }),
1256
+ DELETE: routeByBody(
1257
+ pipeline,
1258
+ "DELETE",
1259
+ "commentId",
1260
+ deleteCommentOperation({ store, pipeline }),
1261
+ deleteFeedbackOperation({
1262
+ store,
1263
+ pipeline,
1264
+ onDeleting: hooks.onDeleting?.bind(hooks),
1265
+ onDeleted: hooks.onDeleted?.bind(hooks)
1266
+ })
1267
+ )
1268
+ };
1269
+ }
1270
+ //# sourceMappingURL=index.cjs.map