@siteping/server 0.1.1 → 0.2.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 CHANGED
@@ -30,9 +30,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ SITEPING_PROTOCOL_VERSION: () => SITEPING_PROTOCOL_VERSION,
34
+ WEBHOOK_EVENTS: () => WEBHOOK_EVENTS,
33
35
  createSitepingHandler: () => createSitepingHandler,
34
36
  dispatchWebhook: () => dispatchWebhook,
35
- dispatchWebhooks: () => dispatchWebhooks
37
+ dispatchWebhooks: () => dispatchWebhooks,
38
+ isSitepingConfigError: () => isSitepingConfigError
36
39
  });
37
40
  module.exports = __toCommonJS(index_exports);
38
41
 
@@ -47,8 +50,23 @@ function hasOwn(value, key) {
47
50
  return isRecord(value) && key in value;
48
51
  }
49
52
 
53
+ // ../core/src/errors.ts
54
+ var SitepingConfigError = class extends Error {
55
+ code;
56
+ constructor(code, message) {
57
+ super(message);
58
+ this.code = code;
59
+ this.name = "SitepingConfigError";
60
+ }
61
+ };
62
+ function isSitepingConfigError(error) {
63
+ return hasOwn(error, "name") && error.name === "SitepingConfigError" && hasOwn(error, "code") && typeof error.code === "string" && error.code.startsWith("CONFIG_");
64
+ }
65
+
50
66
  // ../core/src/types.ts
51
67
  var IDENTITY_FIELD_MAX_LENGTH = 200;
68
+ var PROJECT_NAME_MAX_LENGTH = 191;
69
+ var CLIENT_ID_MAX_LENGTH = 191;
52
70
  var FEEDBACK_TYPES = ["question", "change", "bug", "other"];
53
71
  var FEEDBACK_STATUSES = ["open", "in_progress", "resolved", "wont_fix"];
54
72
  var CLOSED_FEEDBACK_STATUSES = ["resolved", "wont_fix"];
@@ -134,6 +152,7 @@ function flattenAnnotation(ann) {
134
152
  var CONSOLE_DIAGNOSTIC_LEVELS = ["log", "info", "warn", "error"];
135
153
  var ANCHOR_ELEMENT_TAG_MAX = 191;
136
154
  var ANCHOR_ELEMENT_ID_MAX = 191;
155
+ var SITEPING_PROTOCOL_VERSION = 1;
137
156
 
138
157
  // src/constants.ts
139
158
  var MAX_ANNOTATIONS_PER_FEEDBACK = 50;
@@ -192,7 +211,8 @@ function createApiKeyGate({
192
211
  redactUnauthenticatedEmails = true
193
212
  }) {
194
213
  if (!apiKey && requireAuthForDestructive && isProductionEnvironment()) {
195
- throw new Error(
214
+ throw new SitepingConfigError(
215
+ "CONFIG_API_KEY_REQUIRED",
196
216
  "[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
217
  );
198
218
  }
@@ -203,15 +223,14 @@ function createApiKeyGate({
203
223
  return header !== null && safeCompare(header, `Bearer ${apiKey}`);
204
224
  };
205
225
  const authenticate = async (request, method) => {
206
- const canReadAuthorEmail = !redactUnauthenticatedEmails || isBearerAuthenticated(request);
207
226
  if (!apiKey) {
208
227
  if (requireAuthForDestructive && (method === "DELETE" || method === "PATCH")) {
209
228
  return { ok: false, status: 401, error: ERROR_MESSAGES.apiKeyRequiredForDestructive };
210
229
  }
211
- return { ok: true, principal: null, canReadAuthorEmail };
230
+ return { ok: true, principal: null };
212
231
  }
213
232
  if (publicMethods?.has(method) || isBearerAuthenticated(request)) {
214
- return { ok: true, principal: null, canReadAuthorEmail };
233
+ return { ok: true, principal: null };
215
234
  }
216
235
  return { ok: false, status: 401, error: ERROR_MESSAGES.unauthorized };
217
236
  };
@@ -228,6 +247,10 @@ function createApiKeyGate({
228
247
  async authorize() {
229
248
  return true;
230
249
  },
250
+ // One key covers every project, so the project changes nothing here.
251
+ async canReadAuthorEmail({ request }) {
252
+ return !redactUnauthenticatedEmails || isBearerAuthenticated(request);
253
+ },
231
254
  // The key proves the caller is the project side; a public POST does not.
232
255
  async canCommentAsTeam({ request }) {
233
256
  return isBearerAuthenticated(request);
@@ -244,7 +267,7 @@ function createAccessGate(access) {
244
267
  if (!principal) {
245
268
  return { ok: false, status: 401, error: ERROR_MESSAGES.unauthorized };
246
269
  }
247
- return { ok: true, principal, canReadAuthorEmail: await access.canReadAuthorEmail?.(principal) === true };
270
+ return { ok: true, principal };
248
271
  },
249
272
  // A dry run reuses the response's request, so `authenticate` is never asked
250
273
  // about another method: `authorize` decides, by action.
@@ -254,10 +277,15 @@ function createAccessGate(access) {
254
277
  async authorize(context) {
255
278
  return access.authorize ? access.authorize(context) : true;
256
279
  },
280
+ // Emails are personal data: only a `true` answer reveals them — no
281
+ // callback, or `undefined` from a principal missing the flag, hides them.
282
+ async canReadAuthorEmail({ request, principal }, projectName) {
283
+ return await access.canReadAuthorEmail?.(principal, { request, projectName }) === true;
284
+ },
257
285
  // Speaking as the team is an impersonation privilege: without the host's
258
286
  // 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;
287
+ async canCommentAsTeam({ request, principal }, projectName, canReadAuthorEmail) {
288
+ return access.canCommentAsTeam ? await access.canCommentAsTeam(principal, { request, projectName }) === true : canReadAuthorEmail;
261
289
  }
262
290
  };
263
291
  }
@@ -305,6 +333,389 @@ function preflightResponse(request, allowedOrigins) {
305
333
  return new Response(null, { status: 204, headers });
306
334
  }
307
335
 
336
+ // src/webhooks.ts
337
+ var WEBHOOK_EVENTS = [
338
+ "feedback.created",
339
+ "feedback.status_changed",
340
+ "feedback.deleted",
341
+ "comment.created"
342
+ ];
343
+ var DEFAULT_EVENTS = ["feedback.created"];
344
+ var EVENT_HEADER = "X-Siteping-Event";
345
+ var DEFAULT_TIMEOUT_MS = 5e3;
346
+ var TIMER_MAX_DELAY_MS = 2147483647;
347
+ function timeoutProblem(timeoutMs) {
348
+ if (timeoutMs === void 0 || Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= TIMER_MAX_DELAY_MS) {
349
+ return null;
350
+ }
351
+ return `timeoutMs must be a positive integer of at most ${TIMER_MAX_DELAY_MS}, got ${timeoutMs}`;
352
+ }
353
+ function eventsProblem(events) {
354
+ if (events === void 0) return null;
355
+ if (!Array.isArray(events) || events.length === 0) return "events must list at least one event";
356
+ const unknown = events.find((event) => !WEBHOOK_EVENTS.includes(event));
357
+ if (unknown !== void 0)
358
+ return `unknown event ${JSON.stringify(unknown)} in events (one of ${WEBHOOK_EVENTS.join(", ")})`;
359
+ return null;
360
+ }
361
+ function checkWebhookConfigs(configs) {
362
+ for (const config of configs) {
363
+ const problem = timeoutProblem(config.timeoutMs) ?? eventsProblem(config.events);
364
+ if (problem) {
365
+ throw new SitepingConfigError(
366
+ "CONFIG_WEBHOOK_TIMEOUT_INVALID",
367
+ `[siteping] createSitepingHandler: webhook to ${webhookOrigin(config.url)}: ${problem}.`
368
+ );
369
+ }
370
+ }
371
+ }
372
+ function subscribesTo(config, event) {
373
+ return (config.events ?? DEFAULT_EVENTS).includes(event);
374
+ }
375
+ var DISCORD_COLORS = {
376
+ bug: 15680580,
377
+ question: 3900150,
378
+ change: 16096779,
379
+ other: 7041664
380
+ };
381
+ var DEFAULT_DISCORD_COLOR = 7041664;
382
+ function deliverySubject(delivery) {
383
+ switch (delivery.event) {
384
+ case "feedback.deleted":
385
+ return delivery.target.kind === "single" ? { feedbackId: delivery.target.id, projectName: delivery.target.projectName } : { feedbackId: "", projectName: delivery.target.projectName };
386
+ case "comment.created":
387
+ return { feedbackId: delivery.comment.feedbackId, projectName: delivery.comment.projectName };
388
+ default:
389
+ return { feedbackId: delivery.feedback.id, projectName: delivery.feedback.projectName };
390
+ }
391
+ }
392
+ function truncate(text, max = 300) {
393
+ if (text.length <= max) return text;
394
+ return `${text.slice(0, max - 1)}\u2026`;
395
+ }
396
+ function fitEscaped(units, max) {
397
+ const escaped = units.join("");
398
+ if (escaped.length <= max) return escaped;
399
+ let out = "";
400
+ for (const unit of units) {
401
+ if (out.length + unit.length > max - 1) break;
402
+ out += unit;
403
+ }
404
+ return `${out}\u2026`;
405
+ }
406
+ var SLACK_HEADER_MAX = 150;
407
+ var SLACK_TEXT_MAX = 3e3;
408
+ function escapeSlackText(text) {
409
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
410
+ }
411
+ var slackField = (value, max) => fitEscaped(Array.from(value, escapeSlackText), max);
412
+ var SLACK_FROM_HALF = Math.floor((SLACK_TEXT_MAX - 11) / 2);
413
+ var slackProject = (projectName) => `*Project:* ${slackField(projectName, SLACK_TEXT_MAX - 11)}`;
414
+ var slackUrl = (url) => `*URL:* ${slackField(url, SLACK_TEXT_MAX - 7)}`;
415
+ function slackMessage(headline, preview, context) {
416
+ return {
417
+ text: `${escapeSlackText(headline)}: ${preview}`,
418
+ blocks: [
419
+ {
420
+ type: "header",
421
+ text: { type: "plain_text", text: truncate(headline, SLACK_HEADER_MAX), emoji: true }
422
+ },
423
+ {
424
+ type: "section",
425
+ text: { type: "mrkdwn", text: preview }
426
+ },
427
+ {
428
+ type: "context",
429
+ elements: context.map((text) => ({ type: "mrkdwn", text }))
430
+ }
431
+ ]
432
+ };
433
+ }
434
+ function slackFrom(authorName, authorEmail) {
435
+ const email = authorEmail ? ` (${slackField(authorEmail, SLACK_FROM_HALF)})` : "";
436
+ return `*From:* ${slackField(authorName, SLACK_FROM_HALF)}${email}`;
437
+ }
438
+ var STATUS_VERBS = {
439
+ open: "reopened",
440
+ in_progress: "marked in progress",
441
+ resolved: "resolved",
442
+ wont_fix: "marked won't fix"
443
+ };
444
+ function buildSlackPayload(feedback) {
445
+ return slackMessage(
446
+ `New ${feedback.type} feedback from ${feedback.authorName}`,
447
+ escapeSlackText(truncate(feedback.message)),
448
+ [
449
+ slackProject(feedback.projectName),
450
+ `*Type:* ${feedback.type}`,
451
+ slackUrl(feedback.url),
452
+ `*From:* ${slackField(feedback.authorName, SLACK_FROM_HALF)} (${slackField(feedback.authorEmail, SLACK_FROM_HALF)})`
453
+ ]
454
+ );
455
+ }
456
+ function buildSlackStatusPayload(feedback) {
457
+ return slackMessage(
458
+ `Feedback ${STATUS_VERBS[feedback.status]}: ${feedback.type} from ${feedback.authorName}`,
459
+ escapeSlackText(truncate(feedback.message)),
460
+ [
461
+ slackProject(feedback.projectName),
462
+ `*Status:* ${feedback.status}`,
463
+ slackUrl(feedback.url),
464
+ slackFrom(feedback.authorName, feedback.authorEmail)
465
+ ]
466
+ );
467
+ }
468
+ function buildSlackDeletionPayload(target) {
469
+ if (target.kind === "project") {
470
+ return slackMessage(
471
+ "All feedback deleted",
472
+ `Every feedback of ${slackField(target.projectName, SLACK_TEXT_MAX - 30)} was deleted.`,
473
+ [slackProject(target.projectName)]
474
+ );
475
+ }
476
+ return slackMessage("Feedback deleted", `Feedback ${slackField(target.id, SLACK_TEXT_MAX - 30)} was deleted.`, [
477
+ slackProject(target.projectName),
478
+ `*ID:* ${slackField(target.id, SLACK_TEXT_MAX - 6)}`
479
+ ]);
480
+ }
481
+ function buildSlackCommentPayload(comment) {
482
+ return slackMessage(
483
+ `New ${comment.authorRole} reply from ${comment.authorName}`,
484
+ escapeSlackText(truncate(comment.body)),
485
+ [
486
+ slackProject(comment.projectName),
487
+ `*Feedback:* ${slackField(comment.feedbackId, SLACK_TEXT_MAX - 12)}`,
488
+ slackFrom(comment.authorName, comment.authorEmail)
489
+ ]
490
+ );
491
+ }
492
+ var DISCORD_CONTENT_MAX = 2e3;
493
+ var DISCORD_TITLE_MAX = 256;
494
+ var DISCORD_FIELD_VALUE_MAX = 1024;
495
+ var DISCORD_MARKDOWN = /[\\*_~`|>#[\]()<]/g;
496
+ var DISCORD_AUTOLINK = /(<https?:\/\/[^\s<>]+>|https?:\/\/[^\s<>]+)/i;
497
+ var DISCORD_LINK_TRAILER = `.,:;"')]`;
498
+ var escapeMarkdownChar = (char) => char.replace(DISCORD_MARKDOWN, "\\$&");
499
+ var encodeLinkChar = (char) => char.replace(/[[\]()]/g, (bracket) => `%${bracket.charCodeAt(0).toString(16).toUpperCase()}`);
500
+ var count = (text, char) => text.split(char).length - 1;
501
+ function discordUrlUnits(url) {
502
+ const open = { ")": count(url, "(") - count(url, ")"), "]": count(url, "[") - count(url, "]") };
503
+ let end = url.length;
504
+ while (end > 0) {
505
+ const last = url.charAt(end - 1);
506
+ if (!DISCORD_LINK_TRAILER.includes(last)) break;
507
+ if (last === ")" || last === "]") {
508
+ if (open[last] >= 0) break;
509
+ open[last] += 1;
510
+ }
511
+ end -= 1;
512
+ }
513
+ return [...Array.from(url.slice(0, end), encodeLinkChar), ...url.slice(end)];
514
+ }
515
+ function escapeDiscordText(text, max) {
516
+ const units = text.split(DISCORD_AUTOLINK).flatMap((part, index) => index % 2 === 1 ? discordUrlUnits(part) : Array.from(part, escapeMarkdownChar));
517
+ return fitEscaped(units, max);
518
+ }
519
+ function buildDiscordPayload(feedback) {
520
+ const contentLead = `New **${feedback.type}** feedback from **`;
521
+ const titleLead = `${feedback.type} \u2014 `;
522
+ const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
523
+ return {
524
+ content: `${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
525
+ embeds: [
526
+ {
527
+ title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
528
+ description: escapeDiscordText(feedback.message, 300),
529
+ color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
530
+ fields: [
531
+ { name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false },
532
+ {
533
+ name: "Author",
534
+ value: `${escapeDiscordText(feedback.authorName, authorHalf)} (${escapeDiscordText(feedback.authorEmail, authorHalf)})`,
535
+ inline: true
536
+ },
537
+ { name: "Viewport", value: escapeDiscordText(feedback.viewport, DISCORD_FIELD_VALUE_MAX), inline: true }
538
+ ],
539
+ timestamp: new Date(feedback.createdAt).toISOString()
540
+ }
541
+ ],
542
+ allowed_mentions: { parse: [] }
543
+ };
544
+ }
545
+ var COMMENT_DISCORD_COLOR = 9133302;
546
+ function discordMessage(content, embed) {
547
+ return { content, embeds: [embed], allowed_mentions: { parse: [] } };
548
+ }
549
+ function buildDiscordStatusPayload(feedback) {
550
+ const contentLead = `Feedback **${STATUS_VERBS[feedback.status]}**: ${feedback.type} from **`;
551
+ const titleLead = `${feedback.type} \u2014 `;
552
+ return discordMessage(
553
+ `${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
554
+ {
555
+ title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
556
+ description: escapeDiscordText(feedback.message, 300),
557
+ color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
558
+ fields: [
559
+ { name: "Status", value: feedback.status, inline: true },
560
+ { name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false }
561
+ ],
562
+ timestamp: new Date(feedback.updatedAt).toISOString()
563
+ }
564
+ );
565
+ }
566
+ function buildDiscordDeletionPayload(target) {
567
+ const project = (max) => escapeDiscordText(target.projectName, max);
568
+ const contentLead = target.kind === "project" ? "All feedback deleted from **" : "Feedback deleted from **";
569
+ const fields = [{ name: "Project", value: project(DISCORD_FIELD_VALUE_MAX), inline: true }];
570
+ if (target.kind === "single") {
571
+ fields.push({ name: "ID", value: escapeDiscordText(target.id, DISCORD_FIELD_VALUE_MAX), inline: true });
572
+ }
573
+ return discordMessage(`${contentLead}${project(DISCORD_CONTENT_MAX - contentLead.length - 2)}**`, {
574
+ title: target.kind === "project" ? "All feedback deleted" : "Feedback deleted",
575
+ description: target.kind === "project" ? `Every feedback of ${project(300)} was deleted.` : `Feedback ${escapeDiscordText(target.id, 300)} was deleted.`,
576
+ color: DEFAULT_DISCORD_COLOR,
577
+ fields,
578
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
579
+ });
580
+ }
581
+ function buildDiscordCommentPayload(comment) {
582
+ const contentLead = `New **${comment.authorRole}** reply from **`;
583
+ const titleLead = "Reply \u2014 ";
584
+ const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
585
+ const author = comment.authorEmail ? `${escapeDiscordText(comment.authorName, authorHalf)} (${escapeDiscordText(comment.authorEmail, authorHalf)})` : escapeDiscordText(comment.authorName, DISCORD_FIELD_VALUE_MAX);
586
+ return discordMessage(
587
+ `${contentLead}${escapeDiscordText(comment.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
588
+ {
589
+ title: `${titleLead}${escapeDiscordText(comment.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
590
+ description: escapeDiscordText(comment.body, 300),
591
+ color: COMMENT_DISCORD_COLOR,
592
+ fields: [
593
+ { name: "Feedback", value: escapeDiscordText(comment.feedbackId, DISCORD_FIELD_VALUE_MAX), inline: true },
594
+ { name: "Author", value: author, inline: true }
595
+ ],
596
+ timestamp: new Date(comment.createdAt).toISOString()
597
+ }
598
+ );
599
+ }
600
+ function buildGenericPayload(feedback) {
601
+ const { clientId: _clientId, comments, ...payload } = feedback;
602
+ if (!comments) return payload;
603
+ return { ...payload, comments: comments.map(({ clientId: _commentClientId, ...comment }) => comment) };
604
+ }
605
+ function buildGenericCommentPayload(comment) {
606
+ const { clientId: _clientId, ...payload } = comment;
607
+ return payload;
608
+ }
609
+ function buildDeliveryPayload(type, delivery) {
610
+ switch (delivery.event) {
611
+ case "feedback.created":
612
+ if (type === "slack") return buildSlackPayload(delivery.feedback);
613
+ if (type === "discord") return buildDiscordPayload(delivery.feedback);
614
+ return buildGenericPayload(delivery.feedback);
615
+ case "feedback.status_changed":
616
+ if (type === "slack") return buildSlackStatusPayload(delivery.feedback);
617
+ if (type === "discord") return buildDiscordStatusPayload(delivery.feedback);
618
+ return buildGenericPayload(delivery.feedback);
619
+ case "feedback.deleted":
620
+ if (type === "slack") return buildSlackDeletionPayload(delivery.target);
621
+ if (type === "discord") return buildDiscordDeletionPayload(delivery.target);
622
+ return { ...delivery.target };
623
+ case "comment.created":
624
+ if (type === "slack") return buildSlackCommentPayload(delivery.comment);
625
+ if (type === "discord") return buildDiscordCommentPayload(delivery.comment);
626
+ return buildGenericCommentPayload(delivery.comment);
627
+ }
628
+ }
629
+ async function dispatchWebhook(config, feedback) {
630
+ return deliver(config, { event: "feedback.created", feedback });
631
+ }
632
+ async function deliver(config, delivery) {
633
+ const problem = timeoutProblem(config.timeoutMs);
634
+ if (problem) return reportError(config, new RangeError(problem), delivery);
635
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
636
+ const controller = new AbortController();
637
+ let timer;
638
+ try {
639
+ const body = JSON.stringify(buildDeliveryPayload(config.type ?? "generic", delivery));
640
+ const custom = config.headers ?? {};
641
+ const customNames = new Set(Object.keys(custom).map((name) => name.toLowerCase()));
642
+ const headers = {
643
+ ...customNames.has("content-type") ? {} : { "Content-Type": "application/json" },
644
+ ...customNames.has(EVENT_HEADER.toLowerCase()) ? {} : { [EVENT_HEADER]: delivery.event },
645
+ ...custom
646
+ };
647
+ timer = setTimeout(() => controller.abort(), timeoutMs);
648
+ const response = await fetch(config.url, {
649
+ method: "POST",
650
+ headers,
651
+ body,
652
+ signal: controller.signal
653
+ });
654
+ clearTimeout(timer);
655
+ if (!response.ok) {
656
+ const err = new Error(`Webhook responded with HTTP ${response.status}`);
657
+ await reportError(config, err, delivery);
658
+ }
659
+ } catch (rawError) {
660
+ clearTimeout(timer);
661
+ const err = rawError instanceof Error ? rawError : new Error(String(rawError));
662
+ await reportError(config, err, delivery);
663
+ }
664
+ }
665
+ async function reportError(config, err, delivery) {
666
+ const { feedbackId, projectName } = deliverySubject(delivery);
667
+ const subject = feedbackId ? `feedback ${feedbackId}` : `project ${projectName}`;
668
+ if (config.onError) {
669
+ try {
670
+ await config.onError(err, feedbackId, { event: delivery.event, projectName });
671
+ } catch (callbackErr) {
672
+ warnWithoutUrl(
673
+ config.url,
674
+ `webhook onError() callback threw for ${subject}: ${String(callbackErr)} (original error: ${err.message})`
675
+ );
676
+ }
677
+ return;
678
+ }
679
+ warnWithoutUrl(config.url, `webhook to ${webhookOrigin(config.url)} failed for ${subject}: ${err.message}`);
680
+ }
681
+ function webhookOrigin(url) {
682
+ try {
683
+ return new URL(url).origin;
684
+ } catch {
685
+ return "<invalid URL>";
686
+ }
687
+ }
688
+ function warnWithoutUrl(url, message) {
689
+ console.warn(`[siteping] ${url ? message.split(url).join(webhookOrigin(url)) : message}`);
690
+ }
691
+ async function dispatchWebhooks(configs, feedback) {
692
+ await dispatchDelivery(configs, { event: "feedback.created", feedback });
693
+ }
694
+ async function dispatchDelivery(configs, delivery) {
695
+ const subscribed = configs.filter((config) => subscribesTo(config, delivery.event));
696
+ if (subscribed.length === 0) return;
697
+ await Promise.all(subscribed.map((config) => deliver(config, delivery)));
698
+ }
699
+
700
+ // src/notify.ts
701
+ function createNotifier(webhooks, waitUntil, pipeline) {
702
+ return (scope, delivery) => {
703
+ if (!webhooks.some((config) => subscribesTo(config, delivery.event))) return;
704
+ const pending = dispatchDelivery(webhooks, delivery);
705
+ try {
706
+ waitUntil?.(pending);
707
+ } catch (error) {
708
+ const { feedbackId, projectName } = deliverySubject(delivery);
709
+ pipeline.logError(scope, "[siteping] waitUntil failed", {
710
+ error,
711
+ event: delivery.event,
712
+ ...feedbackId ? { feedbackId } : {},
713
+ projectName
714
+ });
715
+ }
716
+ };
717
+ }
718
+
308
719
  // src/validation.ts
309
720
  var zod = __toESM(require("zod"), 1);
310
721
  var z2 = "z" in zod ? zod.z : zod;
@@ -368,9 +779,9 @@ var screenshotRegionSchema = z2.strictObject({
368
779
  wPct: z2.number().min(0).max(1),
369
780
  hPct: z2.number().min(0).max(1)
370
781
  });
371
- var clientIdSchema = z2.string().min(1).max(200).regex(/^[a-zA-Z0-9_-]+$/, "clientId must be alphanumeric (a-z, A-Z, 0-9, _, -)");
782
+ var clientIdSchema = z2.string().min(1).max(CLIENT_ID_MAX_LENGTH).regex(/^[a-zA-Z0-9_-]+$/, "clientId must be alphanumeric (a-z, A-Z, 0-9, _, -)");
372
783
  var feedbackCreateSchema = z2.object({
373
- projectName: z2.string().min(1).max(200),
784
+ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
374
785
  type: z2.enum(FEEDBACK_TYPES),
375
786
  message: z2.string().min(1).max(5e3),
376
787
  // Page-scope identifier the widget uses to group feedbacks. Defaults to
@@ -408,7 +819,7 @@ var feedbackCreateSchema = z2.object({
408
819
  diagnostics: diagnosticsSchema.nullable().optional()
409
820
  });
410
821
  var commentCreateSchema = z2.object({
411
- projectName: z2.string().min(1).max(200),
822
+ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
412
823
  feedbackId: z2.string().min(1).max(200),
413
824
  // Trimmed, so a whitespace-only reply is a 400 rather than an empty bubble.
414
825
  body: z2.string().trim().min(1).max(COMMENT_BODY_MAX_LENGTH),
@@ -422,21 +833,21 @@ var commentCreateSchema = z2.object({
422
833
  clientId: clientIdSchema
423
834
  });
424
835
  var commentDeleteSchema = z2.object({
425
- projectName: z2.string().min(1).max(200),
836
+ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
426
837
  feedbackId: z2.string().min(1).max(200),
427
838
  commentId: z2.string().min(1).max(200)
428
839
  });
429
840
  var feedbackPatchSchema = z2.object({
430
841
  id: z2.string().min(1),
431
- projectName: z2.string().min(1).max(200),
842
+ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
432
843
  status: z2.enum(FEEDBACK_STATUSES)
433
844
  });
434
845
  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) })
846
+ z2.object({ id: z2.string().min(1), projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH) }),
847
+ z2.object({ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH), deleteAll: z2.literal(true) })
437
848
  ]);
438
849
  var getQuerySchema = z2.object({
439
- projectName: z2.string().min(1).max(200),
850
+ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
440
851
  page: z2.coerce.number().int().min(1).default(1),
441
852
  limit: z2.coerce.number().int().min(1).max(100).default(50),
442
853
  type: z2.enum(FEEDBACK_TYPES).optional(),
@@ -465,7 +876,9 @@ function formatValidationErrors(error) {
465
876
  function createCommentOperation({
466
877
  store,
467
878
  pipeline,
468
- beforeComment
879
+ notify,
880
+ beforeComment,
881
+ onCommentCreated
469
882
  }) {
470
883
  return async (scope, body) => {
471
884
  const payload = pipeline.validate(scope, commentCreateSchema, body);
@@ -478,12 +891,25 @@ function createCommentOperation({
478
891
  if (store.verifyProjectOwnership && !await store.verifyProjectOwnership(feedbackId, projectName)) {
479
892
  return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
480
893
  }
481
- const role = authorRole === "team" && await pipeline.canCommentAsTeam(scope) ? "team" : "client";
482
- const stored = await store.addComment(feedbackId, { ...comment, authorRole: role });
894
+ const role = authorRole === "team" && await pipeline.canCommentAsTeam(scope, projectName) ? "team" : "client";
895
+ const input = { ...comment, authorRole: role };
896
+ const { comment: stored, created } = store.addCommentIfAbsent ? await store.addCommentIfAbsent(feedbackId, input) : { comment: await store.addComment(feedbackId, input), created: false };
483
897
  if (stored.feedbackId !== feedbackId) {
484
898
  return pipeline.error(scope, 409, ERROR_MESSAGES.clientIdUsedByAnotherFeedback);
485
899
  }
486
- return pipeline.json(scope, pipeline.presentCreatedComment(scope, stored), { status: 201 });
900
+ if (created) {
901
+ const reply = { ...stored, projectName };
902
+ notify(scope, { event: "comment.created", comment: reply });
903
+ if (onCommentCreated) {
904
+ await pipeline.runHook(
905
+ scope,
906
+ "onCommentCreated",
907
+ { feedbackId, projectName, commentId: stored.id },
908
+ () => onCommentCreated(reply, scope.context)
909
+ );
910
+ }
911
+ }
912
+ return pipeline.json(scope, await pipeline.presentCreatedComment(scope, stored, projectName), { status: 201 });
487
913
  } catch (error) {
488
914
  if (isStoreNotFound(error)) return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
489
915
  if (isStoreLimit(error)) return pipeline.error(scope, 409, ERROR_MESSAGES.tooManyComments);
@@ -493,209 +919,6 @@ function createCommentOperation({
493
919
  };
494
920
  }
495
921
 
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
922
  // src/operations/create-feedback.ts
700
923
  var FAILED_TO_CREATE = "[siteping] Failed to create feedback";
701
924
  function toCreateInput(data) {
@@ -720,8 +943,7 @@ function toCreateInput(data) {
720
943
  function createFeedbackOperation({
721
944
  store,
722
945
  pipeline,
723
- webhooks,
724
- waitUntil,
946
+ notify,
725
947
  beforeCreate,
726
948
  onCreated
727
949
  }) {
@@ -781,14 +1003,7 @@ function createFeedbackOperation({
781
1003
  const subject = { feedbackId: feedback.id, projectName: feedback.projectName };
782
1004
  try {
783
1005
  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
- }
1006
+ notify(scope, { event: "feedback.created", feedback });
792
1007
  if (onCreated) await pipeline.runHook(scope, "onCreated", subject, () => onCreated(feedback, scope.context));
793
1008
  }
794
1009
  if (feedback.projectName !== input.projectName) {
@@ -827,6 +1042,7 @@ function deleteCommentOperation({ store, pipeline }) {
827
1042
  function deleteFeedbackOperation({
828
1043
  store,
829
1044
  pipeline,
1045
+ notify,
830
1046
  onDeleting,
831
1047
  onDeleted
832
1048
  }) {
@@ -853,6 +1069,7 @@ function deleteFeedbackOperation({
853
1069
  }
854
1070
  if (target.kind === "project") await store.deleteAllFeedbacks(target.projectName);
855
1071
  else await store.deleteFeedback(target.id);
1072
+ notify(scope, { event: "feedback.deleted", target });
856
1073
  if (onDeleted) await pipeline.runHook(scope, "onDeleted", { target }, () => onDeleted(target, scope.context));
857
1074
  return pipeline.json(scope, { deleted: true });
858
1075
  } catch (error) {
@@ -893,6 +1110,8 @@ function listFeedbacksOperation({ store, pipeline }) {
893
1110
  {
894
1111
  ...page,
895
1112
  feedbacks,
1113
+ // Lets clients rely on what a protocol version added instead of probing for fields.
1114
+ protocol: SITEPING_PROTOCOL_VERSION,
896
1115
  // Lets clients hide their comment composer, and delete buttons, up front instead of meeting a 501.
897
1116
  capabilities: {
898
1117
  comments: store.addComment !== void 0,
@@ -913,6 +1132,7 @@ function listFeedbacksOperation({ store, pipeline }) {
913
1132
  function updateFeedbackOperation({
914
1133
  store,
915
1134
  pipeline,
1135
+ notify,
916
1136
  onUpdated
917
1137
  }) {
918
1138
  return async (request) => {
@@ -929,6 +1149,7 @@ function updateFeedbackOperation({
929
1149
  return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
930
1150
  }
931
1151
  const feedback = await store.updateFeedback(id, toFeedbackUpdate(status));
1152
+ notify(scope, { event: "feedback.status_changed", feedback });
932
1153
  if (onUpdated) {
933
1154
  const subject = { feedbackId: feedback.id, projectName: feedback.projectName };
934
1155
  await pipeline.runHook(scope, "onUpdated", subject, () => onUpdated(feedback, scope.context));
@@ -1071,14 +1292,26 @@ function createPipeline({
1071
1292
  ]);
1072
1293
  return { canChangeStatus, canDelete, canComment, canDeleteComment };
1073
1294
  };
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)
1295
+ const commentPermissions = async (scope, thread, commentId, canDeleteThread) => ({
1296
+ canDelete: canDeleteThread || await may(scope, "DELETE", { action: "deleteComment", ...thread, commentId })
1081
1297
  });
1298
+ const present = async (scope, feedback, echoEmail = false) => {
1299
+ const canReadEmails = await scope.canReadAuthorEmail(feedback.projectName);
1300
+ const wire = toWireFeedback(
1301
+ presentFeedback ? presentFeedback(feedback, scope.context) : feedback,
1302
+ echoEmail || canReadEmails,
1303
+ canReadEmails
1304
+ );
1305
+ const granted = await permissions(scope, feedback);
1306
+ const thread = { projectName: feedback.projectName, feedbackId: feedback.id };
1307
+ const comments = await Promise.all(
1308
+ wire.comments.map(async (comment) => ({
1309
+ ...comment,
1310
+ permissions: await commentPermissions(scope, thread, comment.id, granted.canDeleteComment)
1311
+ }))
1312
+ );
1313
+ return { ...wire, comments, permissions: granted };
1314
+ };
1082
1315
  return {
1083
1316
  json,
1084
1317
  error,
@@ -1114,12 +1347,21 @@ function createPipeline({
1114
1347
  };
1115
1348
  }
1116
1349
  if (!outcome.ok) return { ok: false, response: error({ corsHeaders }, outcome.status, outcome.error) };
1350
+ const context = { request, principal: outcome.principal };
1351
+ const emailAccess = /* @__PURE__ */ new Map();
1117
1352
  return {
1118
1353
  ok: true,
1119
1354
  value: {
1120
- context: { request, principal: outcome.principal },
1355
+ context,
1121
1356
  corsHeaders,
1122
- canReadAuthorEmail: outcome.canReadAuthorEmail
1357
+ canReadAuthorEmail(projectName) {
1358
+ let answer = emailAccess.get(projectName);
1359
+ if (!answer) {
1360
+ answer = gate.canReadAuthorEmail(context, projectName);
1361
+ emailAccess.set(projectName, answer);
1362
+ }
1363
+ return answer;
1364
+ }
1123
1365
  }
1124
1366
  };
1125
1367
  },
@@ -1129,9 +1371,16 @@ function createPipeline({
1129
1371
  const body = await readJson(scope);
1130
1372
  return body.ok ? validate(scope, schema, body.value) : body;
1131
1373
  },
1132
- /** `null` when the policy allows the request, its 403 otherwise. */
1374
+ /**
1375
+ * `null` when the policy allows the request, its 403 otherwise. An
1376
+ * allowed request also settles its email access for the project now,
1377
+ * before anything is written: a throwing `canReadAuthorEmail` answers
1378
+ * the operation's 500 with nothing stored.
1379
+ */
1133
1380
  async authorize(scope, target) {
1134
- return await gate.authorize({ ...scope.context, ...target }) ? null : error(scope, 403, ERROR_MESSAGES.forbidden);
1381
+ if (!await gate.authorize({ ...scope.context, ...target })) return error(scope, 403, ERROR_MESSAGES.forbidden);
1382
+ await scope.canReadAuthorEmail(target.projectName);
1383
+ return null;
1135
1384
  },
1136
1385
  /**
1137
1386
  * Wire shape of a record answering a POST (fresh or replayed): the
@@ -1139,15 +1388,29 @@ function createPipeline({
1139
1388
  * its submitter — who supplied the feedback's, never its thread's.
1140
1389
  */
1141
1390
  presentCreated(scope, feedback) {
1142
- return present(scope, feedback, gate.echoesAuthorEmailOnCreate || scope.canReadAuthorEmail);
1391
+ return present(scope, feedback, gate.echoesAuthorEmailOnCreate);
1143
1392
  },
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);
1393
+ /**
1394
+ * Wire shape of a comment answering a POST, in `projectName`'s thread —
1395
+ * the same echo rule as {@link presentCreated}, with its permissions.
1396
+ */
1397
+ async presentCreatedComment(scope, comment, projectName) {
1398
+ const thread = { projectName, feedbackId: comment.feedbackId };
1399
+ const [canReadEmails, canDeleteThread] = await Promise.all([
1400
+ scope.canReadAuthorEmail(projectName),
1401
+ may(scope, "DELETE", { action: "deleteComment", ...thread })
1402
+ ]);
1403
+ return {
1404
+ ...toWireComment(comment, gate.echoesAuthorEmailOnCreate || canReadEmails),
1405
+ permissions: await commentPermissions(scope, thread, comment.id, canDeleteThread)
1406
+ };
1147
1407
  },
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);
1408
+ /**
1409
+ * Whether this caller's comment in `projectName` keeps the `team` role it
1410
+ * asks for — see `AccessGate.canCommentAsTeam`.
1411
+ */
1412
+ async canCommentAsTeam(scope, projectName) {
1413
+ return gate.canCommentAsTeam(scope.context, projectName, await scope.canReadAuthorEmail(projectName));
1151
1414
  },
1152
1415
  /**
1153
1416
  * Answer a value the store cannot hold with a 422, which a client does
@@ -1217,15 +1480,17 @@ function createSitepingHandler(options) {
1217
1480
  maxBodyBytes = DEFAULT_MAX_BODY_BYTES
1218
1481
  } = options;
1219
1482
  if (!store) {
1220
- throw new Error("[siteping] createSitepingHandler requires a `store`.");
1483
+ throw new SitepingConfigError("CONFIG_STORE_MISSING", "[siteping] createSitepingHandler requires a `store`.");
1221
1484
  }
1222
1485
  if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes <= 0) {
1223
- throw new Error(
1486
+ throw new SitepingConfigError(
1487
+ "CONFIG_MAX_BODY_BYTES_INVALID",
1224
1488
  `[siteping] createSitepingHandler: \`maxBodyBytes\` must be a positive integer, got ${maxBodyBytes}.`
1225
1489
  );
1226
1490
  }
1227
1491
  if (options.access?.authorize && !store.verifyProjectOwnership) {
1228
- throw new Error(
1492
+ throw new SitepingConfigError(
1493
+ "CONFIG_OWNERSHIP_CHECK_MISSING",
1229
1494
  "[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
1495
  );
1231
1496
  }
@@ -1233,26 +1498,37 @@ function createSitepingHandler(options) {
1233
1498
  const logger = customLogger ? safeLogger(customLogger) : consoleLogger;
1234
1499
  const pipeline = createPipeline({ gate, allowedOrigins, logger, describeError, presentFeedback, maxBodyBytes });
1235
1500
  const webhookList = webhooks ? Array.isArray(webhooks) ? webhooks : [webhooks] : [];
1236
- checkWebhookTimeouts(webhookList);
1501
+ checkWebhookConfigs(webhookList);
1502
+ if (!store.addCommentIfAbsent && (hooks.onCommentCreated || webhookList.some((config) => subscribesTo(config, "comment.created")))) {
1503
+ throw new Error(
1504
+ "[siteping] createSitepingHandler: `comment.created` webhooks and the `onCommentCreated` hook need a store implementing `addCommentIfAbsent`, which tells a new reply from a retried one."
1505
+ );
1506
+ }
1507
+ const notify = createNotifier(webhookList, waitUntil, pipeline);
1237
1508
  return {
1238
1509
  OPTIONS: (request) => preflightResponse(request, allowedOrigins),
1239
1510
  POST: routeByBody(
1240
1511
  pipeline,
1241
1512
  "POST",
1242
1513
  "feedbackId",
1243
- createCommentOperation({ store, pipeline, beforeComment }),
1514
+ createCommentOperation({
1515
+ store,
1516
+ pipeline,
1517
+ notify,
1518
+ beforeComment,
1519
+ onCommentCreated: hooks.onCommentCreated?.bind(hooks)
1520
+ }),
1244
1521
  createFeedbackOperation({
1245
1522
  store,
1246
1523
  pipeline,
1247
- webhooks: webhookList,
1248
- waitUntil,
1524
+ notify,
1249
1525
  beforeCreate,
1250
1526
  // Bound so hooks written as class methods keep their `this`.
1251
1527
  onCreated: hooks.onCreated?.bind(hooks)
1252
1528
  })
1253
1529
  ),
1254
1530
  GET: listFeedbacksOperation({ store, pipeline }),
1255
- PATCH: updateFeedbackOperation({ store, pipeline, onUpdated: hooks.onUpdated?.bind(hooks) }),
1531
+ PATCH: updateFeedbackOperation({ store, pipeline, notify, onUpdated: hooks.onUpdated?.bind(hooks) }),
1256
1532
  DELETE: routeByBody(
1257
1533
  pipeline,
1258
1534
  "DELETE",
@@ -1261,6 +1537,7 @@ function createSitepingHandler(options) {
1261
1537
  deleteFeedbackOperation({
1262
1538
  store,
1263
1539
  pipeline,
1540
+ notify,
1264
1541
  onDeleting: hooks.onDeleting?.bind(hooks),
1265
1542
  onDeleted: hooks.onDeleted?.bind(hooks)
1266
1543
  })