@siteping/server 0.1.1 → 0.2.1

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.js CHANGED
@@ -9,8 +9,23 @@ function hasOwn(value, key) {
9
9
  return isRecord(value) && key in value;
10
10
  }
11
11
 
12
+ // ../core/src/errors.ts
13
+ var SitepingConfigError = class extends Error {
14
+ code;
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.code = code;
18
+ this.name = "SitepingConfigError";
19
+ }
20
+ };
21
+ function isSitepingConfigError(error) {
22
+ return hasOwn(error, "name") && error.name === "SitepingConfigError" && hasOwn(error, "code") && typeof error.code === "string" && error.code.startsWith("CONFIG_");
23
+ }
24
+
12
25
  // ../core/src/types.ts
13
26
  var IDENTITY_FIELD_MAX_LENGTH = 200;
27
+ var PROJECT_NAME_MAX_LENGTH = 191;
28
+ var CLIENT_ID_MAX_LENGTH = 191;
14
29
  var FEEDBACK_TYPES = ["question", "change", "bug", "other"];
15
30
  var FEEDBACK_STATUSES = ["open", "in_progress", "resolved", "wont_fix"];
16
31
  var CLOSED_FEEDBACK_STATUSES = ["resolved", "wont_fix"];
@@ -96,6 +111,7 @@ function flattenAnnotation(ann) {
96
111
  var CONSOLE_DIAGNOSTIC_LEVELS = ["log", "info", "warn", "error"];
97
112
  var ANCHOR_ELEMENT_TAG_MAX = 191;
98
113
  var ANCHOR_ELEMENT_ID_MAX = 191;
114
+ var SITEPING_PROTOCOL_VERSION = 1;
99
115
 
100
116
  // src/constants.ts
101
117
  var MAX_ANNOTATIONS_PER_FEEDBACK = 50;
@@ -154,7 +170,8 @@ function createApiKeyGate({
154
170
  redactUnauthenticatedEmails = true
155
171
  }) {
156
172
  if (!apiKey && requireAuthForDestructive && isProductionEnvironment()) {
157
- throw new Error(
173
+ throw new SitepingConfigError(
174
+ "CONFIG_API_KEY_REQUIRED",
158
175
  "[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."
159
176
  );
160
177
  }
@@ -165,15 +182,14 @@ function createApiKeyGate({
165
182
  return header !== null && safeCompare(header, `Bearer ${apiKey}`);
166
183
  };
167
184
  const authenticate = async (request, method) => {
168
- const canReadAuthorEmail = !redactUnauthenticatedEmails || isBearerAuthenticated(request);
169
185
  if (!apiKey) {
170
186
  if (requireAuthForDestructive && (method === "DELETE" || method === "PATCH")) {
171
187
  return { ok: false, status: 401, error: ERROR_MESSAGES.apiKeyRequiredForDestructive };
172
188
  }
173
- return { ok: true, principal: null, canReadAuthorEmail };
189
+ return { ok: true, principal: null };
174
190
  }
175
191
  if (publicMethods?.has(method) || isBearerAuthenticated(request)) {
176
- return { ok: true, principal: null, canReadAuthorEmail };
192
+ return { ok: true, principal: null };
177
193
  }
178
194
  return { ok: false, status: 401, error: ERROR_MESSAGES.unauthorized };
179
195
  };
@@ -190,6 +206,10 @@ function createApiKeyGate({
190
206
  async authorize() {
191
207
  return true;
192
208
  },
209
+ // One key covers every project, so the project changes nothing here.
210
+ async canReadAuthorEmail({ request }) {
211
+ return !redactUnauthenticatedEmails || isBearerAuthenticated(request);
212
+ },
193
213
  // The key proves the caller is the project side; a public POST does not.
194
214
  async canCommentAsTeam({ request }) {
195
215
  return isBearerAuthenticated(request);
@@ -206,7 +226,7 @@ function createAccessGate(access) {
206
226
  if (!principal) {
207
227
  return { ok: false, status: 401, error: ERROR_MESSAGES.unauthorized };
208
228
  }
209
- return { ok: true, principal, canReadAuthorEmail: await access.canReadAuthorEmail?.(principal) === true };
229
+ return { ok: true, principal };
210
230
  },
211
231
  // A dry run reuses the response's request, so `authenticate` is never asked
212
232
  // about another method: `authorize` decides, by action.
@@ -216,10 +236,15 @@ function createAccessGate(access) {
216
236
  async authorize(context) {
217
237
  return access.authorize ? access.authorize(context) : true;
218
238
  },
239
+ // Emails are personal data: only a `true` answer reveals them — no
240
+ // callback, or `undefined` from a principal missing the flag, hides them.
241
+ async canReadAuthorEmail({ request, principal }, projectName) {
242
+ return await access.canReadAuthorEmail?.(principal, { request, projectName }) === true;
243
+ },
219
244
  // Speaking as the team is an impersonation privilege: without the host's
220
245
  // word for it (its own callback, or the email access it grants), refuse.
221
- async canCommentAsTeam({ principal }, canReadAuthorEmail) {
222
- return access.canCommentAsTeam ? await access.canCommentAsTeam(principal) === true : canReadAuthorEmail;
246
+ async canCommentAsTeam({ request, principal }, projectName, canReadAuthorEmail) {
247
+ return access.canCommentAsTeam ? await access.canCommentAsTeam(principal, { request, projectName }) === true : canReadAuthorEmail;
223
248
  }
224
249
  };
225
250
  }
@@ -267,6 +292,389 @@ function preflightResponse(request, allowedOrigins) {
267
292
  return new Response(null, { status: 204, headers });
268
293
  }
269
294
 
295
+ // src/webhooks.ts
296
+ var WEBHOOK_EVENTS = [
297
+ "feedback.created",
298
+ "feedback.status_changed",
299
+ "feedback.deleted",
300
+ "comment.created"
301
+ ];
302
+ var DEFAULT_EVENTS = ["feedback.created"];
303
+ var EVENT_HEADER = "X-Siteping-Event";
304
+ var DEFAULT_TIMEOUT_MS = 5e3;
305
+ var TIMER_MAX_DELAY_MS = 2147483647;
306
+ function timeoutProblem(timeoutMs) {
307
+ if (timeoutMs === void 0 || Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= TIMER_MAX_DELAY_MS) {
308
+ return null;
309
+ }
310
+ return `timeoutMs must be a positive integer of at most ${TIMER_MAX_DELAY_MS}, got ${timeoutMs}`;
311
+ }
312
+ function eventsProblem(events) {
313
+ if (events === void 0) return null;
314
+ if (!Array.isArray(events) || events.length === 0) return "events must list at least one event";
315
+ const unknown = events.find((event) => !WEBHOOK_EVENTS.includes(event));
316
+ if (unknown !== void 0)
317
+ return `unknown event ${JSON.stringify(unknown)} in events (one of ${WEBHOOK_EVENTS.join(", ")})`;
318
+ return null;
319
+ }
320
+ function checkWebhookConfigs(configs) {
321
+ for (const config of configs) {
322
+ const problem = timeoutProblem(config.timeoutMs) ?? eventsProblem(config.events);
323
+ if (problem) {
324
+ throw new SitepingConfigError(
325
+ "CONFIG_WEBHOOK_TIMEOUT_INVALID",
326
+ `[siteping] createSitepingHandler: webhook to ${webhookOrigin(config.url)}: ${problem}.`
327
+ );
328
+ }
329
+ }
330
+ }
331
+ function subscribesTo(config, event) {
332
+ return (config.events ?? DEFAULT_EVENTS).includes(event);
333
+ }
334
+ var DISCORD_COLORS = {
335
+ bug: 15680580,
336
+ question: 3900150,
337
+ change: 16096779,
338
+ other: 7041664
339
+ };
340
+ var DEFAULT_DISCORD_COLOR = 7041664;
341
+ function deliverySubject(delivery) {
342
+ switch (delivery.event) {
343
+ case "feedback.deleted":
344
+ return delivery.target.kind === "single" ? { feedbackId: delivery.target.id, projectName: delivery.target.projectName } : { feedbackId: "", projectName: delivery.target.projectName };
345
+ case "comment.created":
346
+ return { feedbackId: delivery.comment.feedbackId, projectName: delivery.comment.projectName };
347
+ default:
348
+ return { feedbackId: delivery.feedback.id, projectName: delivery.feedback.projectName };
349
+ }
350
+ }
351
+ function truncate(text, max = 300) {
352
+ if (text.length <= max) return text;
353
+ return `${text.slice(0, max - 1)}\u2026`;
354
+ }
355
+ function fitEscaped(units, max) {
356
+ const escaped = units.join("");
357
+ if (escaped.length <= max) return escaped;
358
+ let out = "";
359
+ for (const unit of units) {
360
+ if (out.length + unit.length > max - 1) break;
361
+ out += unit;
362
+ }
363
+ return `${out}\u2026`;
364
+ }
365
+ var SLACK_HEADER_MAX = 150;
366
+ var SLACK_TEXT_MAX = 3e3;
367
+ function escapeSlackText(text) {
368
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
369
+ }
370
+ var slackField = (value, max) => fitEscaped(Array.from(value, escapeSlackText), max);
371
+ var SLACK_FROM_HALF = Math.floor((SLACK_TEXT_MAX - 11) / 2);
372
+ var slackProject = (projectName) => `*Project:* ${slackField(projectName, SLACK_TEXT_MAX - 11)}`;
373
+ var slackUrl = (url) => `*URL:* ${slackField(url, SLACK_TEXT_MAX - 7)}`;
374
+ function slackMessage(headline, preview, context) {
375
+ return {
376
+ text: `${escapeSlackText(headline)}: ${preview}`,
377
+ blocks: [
378
+ {
379
+ type: "header",
380
+ text: { type: "plain_text", text: truncate(headline, SLACK_HEADER_MAX), emoji: true }
381
+ },
382
+ {
383
+ type: "section",
384
+ text: { type: "mrkdwn", text: preview }
385
+ },
386
+ {
387
+ type: "context",
388
+ elements: context.map((text) => ({ type: "mrkdwn", text }))
389
+ }
390
+ ]
391
+ };
392
+ }
393
+ function slackFrom(authorName, authorEmail) {
394
+ const email = authorEmail ? ` (${slackField(authorEmail, SLACK_FROM_HALF)})` : "";
395
+ return `*From:* ${slackField(authorName, SLACK_FROM_HALF)}${email}`;
396
+ }
397
+ var STATUS_VERBS = {
398
+ open: "reopened",
399
+ in_progress: "marked in progress",
400
+ resolved: "resolved",
401
+ wont_fix: "marked won't fix"
402
+ };
403
+ function buildSlackPayload(feedback) {
404
+ return slackMessage(
405
+ `New ${feedback.type} feedback from ${feedback.authorName}`,
406
+ escapeSlackText(truncate(feedback.message)),
407
+ [
408
+ slackProject(feedback.projectName),
409
+ `*Type:* ${feedback.type}`,
410
+ slackUrl(feedback.url),
411
+ `*From:* ${slackField(feedback.authorName, SLACK_FROM_HALF)} (${slackField(feedback.authorEmail, SLACK_FROM_HALF)})`
412
+ ]
413
+ );
414
+ }
415
+ function buildSlackStatusPayload(feedback) {
416
+ return slackMessage(
417
+ `Feedback ${STATUS_VERBS[feedback.status]}: ${feedback.type} from ${feedback.authorName}`,
418
+ escapeSlackText(truncate(feedback.message)),
419
+ [
420
+ slackProject(feedback.projectName),
421
+ `*Status:* ${feedback.status}`,
422
+ slackUrl(feedback.url),
423
+ slackFrom(feedback.authorName, feedback.authorEmail)
424
+ ]
425
+ );
426
+ }
427
+ function buildSlackDeletionPayload(target) {
428
+ if (target.kind === "project") {
429
+ return slackMessage(
430
+ "All feedback deleted",
431
+ `Every feedback of ${slackField(target.projectName, SLACK_TEXT_MAX - 30)} was deleted.`,
432
+ [slackProject(target.projectName)]
433
+ );
434
+ }
435
+ return slackMessage("Feedback deleted", `Feedback ${slackField(target.id, SLACK_TEXT_MAX - 30)} was deleted.`, [
436
+ slackProject(target.projectName),
437
+ `*ID:* ${slackField(target.id, SLACK_TEXT_MAX - 6)}`
438
+ ]);
439
+ }
440
+ function buildSlackCommentPayload(comment) {
441
+ return slackMessage(
442
+ `New ${comment.authorRole} reply from ${comment.authorName}`,
443
+ escapeSlackText(truncate(comment.body)),
444
+ [
445
+ slackProject(comment.projectName),
446
+ `*Feedback:* ${slackField(comment.feedbackId, SLACK_TEXT_MAX - 12)}`,
447
+ slackFrom(comment.authorName, comment.authorEmail)
448
+ ]
449
+ );
450
+ }
451
+ var DISCORD_CONTENT_MAX = 2e3;
452
+ var DISCORD_TITLE_MAX = 256;
453
+ var DISCORD_FIELD_VALUE_MAX = 1024;
454
+ var DISCORD_MARKDOWN = /[\\*_~`|>#[\]()<]/g;
455
+ var DISCORD_AUTOLINK = /(<https?:\/\/[^\s<>]+>|https?:\/\/[^\s<>]+)/i;
456
+ var DISCORD_LINK_TRAILER = `.,:;"')]`;
457
+ var escapeMarkdownChar = (char) => char.replace(DISCORD_MARKDOWN, "\\$&");
458
+ var encodeLinkChar = (char) => char.replace(/[[\]()]/g, (bracket) => `%${bracket.charCodeAt(0).toString(16).toUpperCase()}`);
459
+ var count = (text, char) => text.split(char).length - 1;
460
+ function discordUrlUnits(url) {
461
+ const open = { ")": count(url, "(") - count(url, ")"), "]": count(url, "[") - count(url, "]") };
462
+ let end = url.length;
463
+ while (end > 0) {
464
+ const last = url.charAt(end - 1);
465
+ if (!DISCORD_LINK_TRAILER.includes(last)) break;
466
+ if (last === ")" || last === "]") {
467
+ if (open[last] >= 0) break;
468
+ open[last] += 1;
469
+ }
470
+ end -= 1;
471
+ }
472
+ return [...Array.from(url.slice(0, end), encodeLinkChar), ...url.slice(end)];
473
+ }
474
+ function escapeDiscordText(text, max) {
475
+ const units = text.split(DISCORD_AUTOLINK).flatMap((part, index) => index % 2 === 1 ? discordUrlUnits(part) : Array.from(part, escapeMarkdownChar));
476
+ return fitEscaped(units, max);
477
+ }
478
+ function buildDiscordPayload(feedback) {
479
+ const contentLead = `New **${feedback.type}** feedback from **`;
480
+ const titleLead = `${feedback.type} \u2014 `;
481
+ const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
482
+ return {
483
+ content: `${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
484
+ embeds: [
485
+ {
486
+ title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
487
+ description: escapeDiscordText(feedback.message, 300),
488
+ color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
489
+ fields: [
490
+ { name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false },
491
+ {
492
+ name: "Author",
493
+ value: `${escapeDiscordText(feedback.authorName, authorHalf)} (${escapeDiscordText(feedback.authorEmail, authorHalf)})`,
494
+ inline: true
495
+ },
496
+ { name: "Viewport", value: escapeDiscordText(feedback.viewport, DISCORD_FIELD_VALUE_MAX), inline: true }
497
+ ],
498
+ timestamp: new Date(feedback.createdAt).toISOString()
499
+ }
500
+ ],
501
+ allowed_mentions: { parse: [] }
502
+ };
503
+ }
504
+ var COMMENT_DISCORD_COLOR = 9133302;
505
+ function discordMessage(content, embed) {
506
+ return { content, embeds: [embed], allowed_mentions: { parse: [] } };
507
+ }
508
+ function buildDiscordStatusPayload(feedback) {
509
+ const contentLead = `Feedback **${STATUS_VERBS[feedback.status]}**: ${feedback.type} from **`;
510
+ const titleLead = `${feedback.type} \u2014 `;
511
+ return discordMessage(
512
+ `${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
513
+ {
514
+ title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
515
+ description: escapeDiscordText(feedback.message, 300),
516
+ color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
517
+ fields: [
518
+ { name: "Status", value: feedback.status, inline: true },
519
+ { name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false }
520
+ ],
521
+ timestamp: new Date(feedback.updatedAt).toISOString()
522
+ }
523
+ );
524
+ }
525
+ function buildDiscordDeletionPayload(target) {
526
+ const project = (max) => escapeDiscordText(target.projectName, max);
527
+ const contentLead = target.kind === "project" ? "All feedback deleted from **" : "Feedback deleted from **";
528
+ const fields = [{ name: "Project", value: project(DISCORD_FIELD_VALUE_MAX), inline: true }];
529
+ if (target.kind === "single") {
530
+ fields.push({ name: "ID", value: escapeDiscordText(target.id, DISCORD_FIELD_VALUE_MAX), inline: true });
531
+ }
532
+ return discordMessage(`${contentLead}${project(DISCORD_CONTENT_MAX - contentLead.length - 2)}**`, {
533
+ title: target.kind === "project" ? "All feedback deleted" : "Feedback deleted",
534
+ description: target.kind === "project" ? `Every feedback of ${project(300)} was deleted.` : `Feedback ${escapeDiscordText(target.id, 300)} was deleted.`,
535
+ color: DEFAULT_DISCORD_COLOR,
536
+ fields,
537
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
538
+ });
539
+ }
540
+ function buildDiscordCommentPayload(comment) {
541
+ const contentLead = `New **${comment.authorRole}** reply from **`;
542
+ const titleLead = "Reply \u2014 ";
543
+ const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
544
+ const author = comment.authorEmail ? `${escapeDiscordText(comment.authorName, authorHalf)} (${escapeDiscordText(comment.authorEmail, authorHalf)})` : escapeDiscordText(comment.authorName, DISCORD_FIELD_VALUE_MAX);
545
+ return discordMessage(
546
+ `${contentLead}${escapeDiscordText(comment.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
547
+ {
548
+ title: `${titleLead}${escapeDiscordText(comment.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
549
+ description: escapeDiscordText(comment.body, 300),
550
+ color: COMMENT_DISCORD_COLOR,
551
+ fields: [
552
+ { name: "Feedback", value: escapeDiscordText(comment.feedbackId, DISCORD_FIELD_VALUE_MAX), inline: true },
553
+ { name: "Author", value: author, inline: true }
554
+ ],
555
+ timestamp: new Date(comment.createdAt).toISOString()
556
+ }
557
+ );
558
+ }
559
+ function buildGenericPayload(feedback) {
560
+ const { clientId: _clientId, comments, ...payload } = feedback;
561
+ if (!comments) return payload;
562
+ return { ...payload, comments: comments.map(({ clientId: _commentClientId, ...comment }) => comment) };
563
+ }
564
+ function buildGenericCommentPayload(comment) {
565
+ const { clientId: _clientId, ...payload } = comment;
566
+ return payload;
567
+ }
568
+ function buildDeliveryPayload(type, delivery) {
569
+ switch (delivery.event) {
570
+ case "feedback.created":
571
+ if (type === "slack") return buildSlackPayload(delivery.feedback);
572
+ if (type === "discord") return buildDiscordPayload(delivery.feedback);
573
+ return buildGenericPayload(delivery.feedback);
574
+ case "feedback.status_changed":
575
+ if (type === "slack") return buildSlackStatusPayload(delivery.feedback);
576
+ if (type === "discord") return buildDiscordStatusPayload(delivery.feedback);
577
+ return buildGenericPayload(delivery.feedback);
578
+ case "feedback.deleted":
579
+ if (type === "slack") return buildSlackDeletionPayload(delivery.target);
580
+ if (type === "discord") return buildDiscordDeletionPayload(delivery.target);
581
+ return { ...delivery.target };
582
+ case "comment.created":
583
+ if (type === "slack") return buildSlackCommentPayload(delivery.comment);
584
+ if (type === "discord") return buildDiscordCommentPayload(delivery.comment);
585
+ return buildGenericCommentPayload(delivery.comment);
586
+ }
587
+ }
588
+ async function dispatchWebhook(config, feedback) {
589
+ return deliver(config, { event: "feedback.created", feedback });
590
+ }
591
+ async function deliver(config, delivery) {
592
+ const problem = timeoutProblem(config.timeoutMs);
593
+ if (problem) return reportError(config, new RangeError(problem), delivery);
594
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
595
+ const controller = new AbortController();
596
+ let timer;
597
+ try {
598
+ const body = JSON.stringify(buildDeliveryPayload(config.type ?? "generic", delivery));
599
+ const custom = config.headers ?? {};
600
+ const customNames = new Set(Object.keys(custom).map((name) => name.toLowerCase()));
601
+ const headers = {
602
+ ...customNames.has("content-type") ? {} : { "Content-Type": "application/json" },
603
+ ...customNames.has(EVENT_HEADER.toLowerCase()) ? {} : { [EVENT_HEADER]: delivery.event },
604
+ ...custom
605
+ };
606
+ timer = setTimeout(() => controller.abort(), timeoutMs);
607
+ const response = await fetch(config.url, {
608
+ method: "POST",
609
+ headers,
610
+ body,
611
+ signal: controller.signal
612
+ });
613
+ clearTimeout(timer);
614
+ if (!response.ok) {
615
+ const err = new Error(`Webhook responded with HTTP ${response.status}`);
616
+ await reportError(config, err, delivery);
617
+ }
618
+ } catch (rawError) {
619
+ clearTimeout(timer);
620
+ const err = rawError instanceof Error ? rawError : new Error(String(rawError));
621
+ await reportError(config, err, delivery);
622
+ }
623
+ }
624
+ async function reportError(config, err, delivery) {
625
+ const { feedbackId, projectName } = deliverySubject(delivery);
626
+ const subject = feedbackId ? `feedback ${feedbackId}` : `project ${projectName}`;
627
+ if (config.onError) {
628
+ try {
629
+ await config.onError(err, feedbackId, { event: delivery.event, projectName });
630
+ } catch (callbackErr) {
631
+ warnWithoutUrl(
632
+ config.url,
633
+ `webhook onError() callback threw for ${subject}: ${String(callbackErr)} (original error: ${err.message})`
634
+ );
635
+ }
636
+ return;
637
+ }
638
+ warnWithoutUrl(config.url, `webhook to ${webhookOrigin(config.url)} failed for ${subject}: ${err.message}`);
639
+ }
640
+ function webhookOrigin(url) {
641
+ try {
642
+ return new URL(url).origin;
643
+ } catch {
644
+ return "<invalid URL>";
645
+ }
646
+ }
647
+ function warnWithoutUrl(url, message) {
648
+ console.warn(`[siteping] ${url ? message.split(url).join(webhookOrigin(url)) : message}`);
649
+ }
650
+ async function dispatchWebhooks(configs, feedback) {
651
+ await dispatchDelivery(configs, { event: "feedback.created", feedback });
652
+ }
653
+ async function dispatchDelivery(configs, delivery) {
654
+ const subscribed = configs.filter((config) => subscribesTo(config, delivery.event));
655
+ if (subscribed.length === 0) return;
656
+ await Promise.all(subscribed.map((config) => deliver(config, delivery)));
657
+ }
658
+
659
+ // src/notify.ts
660
+ function createNotifier(webhooks, waitUntil, pipeline) {
661
+ return (scope, delivery) => {
662
+ if (!webhooks.some((config) => subscribesTo(config, delivery.event))) return;
663
+ const pending = dispatchDelivery(webhooks, delivery);
664
+ try {
665
+ waitUntil?.(pending);
666
+ } catch (error) {
667
+ const { feedbackId, projectName } = deliverySubject(delivery);
668
+ pipeline.logError(scope, "[siteping] waitUntil failed", {
669
+ error,
670
+ event: delivery.event,
671
+ ...feedbackId ? { feedbackId } : {},
672
+ projectName
673
+ });
674
+ }
675
+ };
676
+ }
677
+
270
678
  // src/validation.ts
271
679
  import * as zod from "zod";
272
680
  var z2 = "z" in zod ? zod.z : zod;
@@ -330,9 +738,9 @@ var screenshotRegionSchema = z2.strictObject({
330
738
  wPct: z2.number().min(0).max(1),
331
739
  hPct: z2.number().min(0).max(1)
332
740
  });
333
- var clientIdSchema = z2.string().min(1).max(200).regex(/^[a-zA-Z0-9_-]+$/, "clientId must be alphanumeric (a-z, A-Z, 0-9, _, -)");
741
+ 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, _, -)");
334
742
  var feedbackCreateSchema = z2.object({
335
- projectName: z2.string().min(1).max(200),
743
+ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
336
744
  type: z2.enum(FEEDBACK_TYPES),
337
745
  message: z2.string().min(1).max(5e3),
338
746
  // Page-scope identifier the widget uses to group feedbacks. Defaults to
@@ -370,7 +778,7 @@ var feedbackCreateSchema = z2.object({
370
778
  diagnostics: diagnosticsSchema.nullable().optional()
371
779
  });
372
780
  var commentCreateSchema = z2.object({
373
- projectName: z2.string().min(1).max(200),
781
+ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
374
782
  feedbackId: z2.string().min(1).max(200),
375
783
  // Trimmed, so a whitespace-only reply is a 400 rather than an empty bubble.
376
784
  body: z2.string().trim().min(1).max(COMMENT_BODY_MAX_LENGTH),
@@ -384,21 +792,21 @@ var commentCreateSchema = z2.object({
384
792
  clientId: clientIdSchema
385
793
  });
386
794
  var commentDeleteSchema = z2.object({
387
- projectName: z2.string().min(1).max(200),
795
+ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
388
796
  feedbackId: z2.string().min(1).max(200),
389
797
  commentId: z2.string().min(1).max(200)
390
798
  });
391
799
  var feedbackPatchSchema = z2.object({
392
800
  id: z2.string().min(1),
393
- projectName: z2.string().min(1).max(200),
801
+ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
394
802
  status: z2.enum(FEEDBACK_STATUSES)
395
803
  });
396
804
  var feedbackDeleteSchema = z2.union([
397
- z2.object({ id: z2.string().min(1), projectName: z2.string().min(1).max(200) }),
398
- z2.object({ projectName: z2.string().min(1).max(200), deleteAll: z2.literal(true) })
805
+ z2.object({ id: z2.string().min(1), projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH) }),
806
+ z2.object({ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH), deleteAll: z2.literal(true) })
399
807
  ]);
400
808
  var getQuerySchema = z2.object({
401
- projectName: z2.string().min(1).max(200),
809
+ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
402
810
  page: z2.coerce.number().int().min(1).default(1),
403
811
  limit: z2.coerce.number().int().min(1).max(100).default(50),
404
812
  type: z2.enum(FEEDBACK_TYPES).optional(),
@@ -427,7 +835,9 @@ function formatValidationErrors(error) {
427
835
  function createCommentOperation({
428
836
  store,
429
837
  pipeline,
430
- beforeComment
838
+ notify,
839
+ beforeComment,
840
+ onCommentCreated
431
841
  }) {
432
842
  return async (scope, body) => {
433
843
  const payload = pipeline.validate(scope, commentCreateSchema, body);
@@ -440,12 +850,25 @@ function createCommentOperation({
440
850
  if (store.verifyProjectOwnership && !await store.verifyProjectOwnership(feedbackId, projectName)) {
441
851
  return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
442
852
  }
443
- const role = authorRole === "team" && await pipeline.canCommentAsTeam(scope) ? "team" : "client";
444
- const stored = await store.addComment(feedbackId, { ...comment, authorRole: role });
853
+ const role = authorRole === "team" && await pipeline.canCommentAsTeam(scope, projectName) ? "team" : "client";
854
+ const input = { ...comment, authorRole: role };
855
+ const { comment: stored, created } = store.addCommentIfAbsent ? await store.addCommentIfAbsent(feedbackId, input) : { comment: await store.addComment(feedbackId, input), created: false };
445
856
  if (stored.feedbackId !== feedbackId) {
446
857
  return pipeline.error(scope, 409, ERROR_MESSAGES.clientIdUsedByAnotherFeedback);
447
858
  }
448
- return pipeline.json(scope, pipeline.presentCreatedComment(scope, stored), { status: 201 });
859
+ if (created) {
860
+ const reply = { ...stored, projectName };
861
+ notify(scope, { event: "comment.created", comment: reply });
862
+ if (onCommentCreated) {
863
+ await pipeline.runHook(
864
+ scope,
865
+ "onCommentCreated",
866
+ { feedbackId, projectName, commentId: stored.id },
867
+ () => onCommentCreated(reply, scope.context)
868
+ );
869
+ }
870
+ }
871
+ return pipeline.json(scope, await pipeline.presentCreatedComment(scope, stored, projectName), { status: 201 });
449
872
  } catch (error) {
450
873
  if (isStoreNotFound(error)) return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
451
874
  if (isStoreLimit(error)) return pipeline.error(scope, 409, ERROR_MESSAGES.tooManyComments);
@@ -455,209 +878,6 @@ function createCommentOperation({
455
878
  };
456
879
  }
457
880
 
458
- // src/webhooks.ts
459
- var DEFAULT_TIMEOUT_MS = 5e3;
460
- var TIMER_MAX_DELAY_MS = 2147483647;
461
- function timeoutProblem(timeoutMs) {
462
- if (timeoutMs === void 0 || Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= TIMER_MAX_DELAY_MS) {
463
- return null;
464
- }
465
- return `timeoutMs must be a positive integer of at most ${TIMER_MAX_DELAY_MS}, got ${timeoutMs}`;
466
- }
467
- function checkWebhookTimeouts(configs) {
468
- for (const config of configs) {
469
- const problem = timeoutProblem(config.timeoutMs);
470
- if (problem) {
471
- throw new Error(`[siteping] createSitepingHandler: webhook to ${webhookOrigin(config.url)}: ${problem}.`);
472
- }
473
- }
474
- }
475
- var DISCORD_COLORS = {
476
- bug: 15680580,
477
- question: 3900150,
478
- change: 16096779,
479
- other: 7041664
480
- };
481
- var DEFAULT_DISCORD_COLOR = 7041664;
482
- function truncate(text, max = 300) {
483
- if (text.length <= max) return text;
484
- return `${text.slice(0, max - 1)}\u2026`;
485
- }
486
- function fitEscaped(units, max) {
487
- const escaped = units.join("");
488
- if (escaped.length <= max) return escaped;
489
- let out = "";
490
- for (const unit of units) {
491
- if (out.length + unit.length > max - 1) break;
492
- out += unit;
493
- }
494
- return `${out}\u2026`;
495
- }
496
- var SLACK_HEADER_MAX = 150;
497
- var SLACK_TEXT_MAX = 3e3;
498
- function escapeSlackText(text) {
499
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
500
- }
501
- function buildSlackPayload(feedback) {
502
- const preview = escapeSlackText(truncate(feedback.message));
503
- const escapeField = (value, max) => fitEscaped(Array.from(value, escapeSlackText), max);
504
- const fromHalf = Math.floor((SLACK_TEXT_MAX - 11) / 2);
505
- const headline = `New ${feedback.type} feedback from ${feedback.authorName}`;
506
- return {
507
- text: `${escapeSlackText(headline)}: ${preview}`,
508
- blocks: [
509
- {
510
- type: "header",
511
- text: { type: "plain_text", text: truncate(headline, SLACK_HEADER_MAX), emoji: true }
512
- },
513
- {
514
- type: "section",
515
- text: { type: "mrkdwn", text: preview }
516
- },
517
- {
518
- type: "context",
519
- elements: [
520
- { type: "mrkdwn", text: `*Project:* ${escapeField(feedback.projectName, SLACK_TEXT_MAX - 11)}` },
521
- { type: "mrkdwn", text: `*Type:* ${feedback.type}` },
522
- { type: "mrkdwn", text: `*URL:* ${escapeField(feedback.url, SLACK_TEXT_MAX - 7)}` },
523
- {
524
- type: "mrkdwn",
525
- text: `*From:* ${escapeField(feedback.authorName, fromHalf)} (${escapeField(feedback.authorEmail, fromHalf)})`
526
- }
527
- ]
528
- }
529
- ]
530
- };
531
- }
532
- var DISCORD_CONTENT_MAX = 2e3;
533
- var DISCORD_TITLE_MAX = 256;
534
- var DISCORD_FIELD_VALUE_MAX = 1024;
535
- var DISCORD_MARKDOWN = /[\\*_~`|>#[\]()<]/g;
536
- var DISCORD_AUTOLINK = /(<https?:\/\/[^\s<>]+>|https?:\/\/[^\s<>]+)/i;
537
- var DISCORD_LINK_TRAILER = `.,:;"')]`;
538
- var escapeMarkdownChar = (char) => char.replace(DISCORD_MARKDOWN, "\\$&");
539
- var encodeLinkChar = (char) => char.replace(/[[\]()]/g, (bracket) => `%${bracket.charCodeAt(0).toString(16).toUpperCase()}`);
540
- var count = (text, char) => text.split(char).length - 1;
541
- function discordUrlUnits(url) {
542
- const open = { ")": count(url, "(") - count(url, ")"), "]": count(url, "[") - count(url, "]") };
543
- let end = url.length;
544
- while (end > 0) {
545
- const last = url.charAt(end - 1);
546
- if (!DISCORD_LINK_TRAILER.includes(last)) break;
547
- if (last === ")" || last === "]") {
548
- if (open[last] >= 0) break;
549
- open[last] += 1;
550
- }
551
- end -= 1;
552
- }
553
- return [...Array.from(url.slice(0, end), encodeLinkChar), ...url.slice(end)];
554
- }
555
- function escapeDiscordText(text, max) {
556
- const units = text.split(DISCORD_AUTOLINK).flatMap((part, index) => index % 2 === 1 ? discordUrlUnits(part) : Array.from(part, escapeMarkdownChar));
557
- return fitEscaped(units, max);
558
- }
559
- function buildDiscordPayload(feedback) {
560
- const contentLead = `New **${feedback.type}** feedback from **`;
561
- const titleLead = `${feedback.type} \u2014 `;
562
- const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
563
- return {
564
- content: `${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
565
- embeds: [
566
- {
567
- title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
568
- description: escapeDiscordText(feedback.message, 300),
569
- color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
570
- fields: [
571
- { name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false },
572
- {
573
- name: "Author",
574
- value: `${escapeDiscordText(feedback.authorName, authorHalf)} (${escapeDiscordText(feedback.authorEmail, authorHalf)})`,
575
- inline: true
576
- },
577
- { name: "Viewport", value: escapeDiscordText(feedback.viewport, DISCORD_FIELD_VALUE_MAX), inline: true }
578
- ],
579
- timestamp: new Date(feedback.createdAt).toISOString()
580
- }
581
- ],
582
- allowed_mentions: { parse: [] }
583
- };
584
- }
585
- function buildGenericPayload(feedback) {
586
- const { clientId: _clientId, comments, ...payload } = feedback;
587
- if (!comments) return payload;
588
- return { ...payload, comments: comments.map(({ clientId: _commentClientId, ...comment }) => comment) };
589
- }
590
- function buildWebhookPayload(type, feedback) {
591
- switch (type) {
592
- case "slack":
593
- return buildSlackPayload(feedback);
594
- case "discord":
595
- return buildDiscordPayload(feedback);
596
- default:
597
- return buildGenericPayload(feedback);
598
- }
599
- }
600
- async function dispatchWebhook(config, feedback) {
601
- const problem = timeoutProblem(config.timeoutMs);
602
- if (problem) return reportError(config, new RangeError(problem), feedback.id);
603
- const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
604
- const controller = new AbortController();
605
- let timer;
606
- try {
607
- const body = JSON.stringify(buildWebhookPayload(config.type ?? "generic", feedback));
608
- const custom = config.headers ?? {};
609
- const setsContentType = Object.keys(custom).some((name) => name.toLowerCase() === "content-type");
610
- const headers = setsContentType ? custom : { "Content-Type": "application/json", ...custom };
611
- timer = setTimeout(() => controller.abort(), timeoutMs);
612
- const response = await fetch(config.url, {
613
- method: "POST",
614
- headers,
615
- body,
616
- signal: controller.signal
617
- });
618
- clearTimeout(timer);
619
- if (!response.ok) {
620
- const err = new Error(`Webhook responded with HTTP ${response.status}`);
621
- await reportError(config, err, feedback.id);
622
- }
623
- } catch (rawError) {
624
- clearTimeout(timer);
625
- const err = rawError instanceof Error ? rawError : new Error(String(rawError));
626
- await reportError(config, err, feedback.id);
627
- }
628
- }
629
- async function reportError(config, err, feedbackId) {
630
- if (config.onError) {
631
- try {
632
- await config.onError(err, feedbackId);
633
- } catch (callbackErr) {
634
- warnWithoutUrl(
635
- config.url,
636
- `webhook onError() callback threw for feedback ${feedbackId}: ${String(callbackErr)} (original error: ${err.message})`
637
- );
638
- }
639
- return;
640
- }
641
- warnWithoutUrl(
642
- config.url,
643
- `webhook to ${webhookOrigin(config.url)} failed for feedback ${feedbackId}: ${err.message}`
644
- );
645
- }
646
- function webhookOrigin(url) {
647
- try {
648
- return new URL(url).origin;
649
- } catch {
650
- return "<invalid URL>";
651
- }
652
- }
653
- function warnWithoutUrl(url, message) {
654
- console.warn(`[siteping] ${url ? message.split(url).join(webhookOrigin(url)) : message}`);
655
- }
656
- async function dispatchWebhooks(configs, feedback) {
657
- if (configs.length === 0) return;
658
- await Promise.all(configs.map((c) => dispatchWebhook(c, feedback)));
659
- }
660
-
661
881
  // src/operations/create-feedback.ts
662
882
  var FAILED_TO_CREATE = "[siteping] Failed to create feedback";
663
883
  function toCreateInput(data) {
@@ -682,8 +902,7 @@ function toCreateInput(data) {
682
902
  function createFeedbackOperation({
683
903
  store,
684
904
  pipeline,
685
- webhooks,
686
- waitUntil,
905
+ notify,
687
906
  beforeCreate,
688
907
  onCreated
689
908
  }) {
@@ -743,14 +962,7 @@ function createFeedbackOperation({
743
962
  const subject = { feedbackId: feedback.id, projectName: feedback.projectName };
744
963
  try {
745
964
  if (owner && inserted && feedback.projectName === input.projectName) {
746
- if (webhooks.length > 0) {
747
- const delivery = dispatchWebhooks(webhooks, feedback);
748
- try {
749
- waitUntil?.(delivery);
750
- } catch (error) {
751
- pipeline.logError(scope, "[siteping] waitUntil failed", { error, ...subject });
752
- }
753
- }
965
+ notify(scope, { event: "feedback.created", feedback });
754
966
  if (onCreated) await pipeline.runHook(scope, "onCreated", subject, () => onCreated(feedback, scope.context));
755
967
  }
756
968
  if (feedback.projectName !== input.projectName) {
@@ -789,6 +1001,7 @@ function deleteCommentOperation({ store, pipeline }) {
789
1001
  function deleteFeedbackOperation({
790
1002
  store,
791
1003
  pipeline,
1004
+ notify,
792
1005
  onDeleting,
793
1006
  onDeleted
794
1007
  }) {
@@ -815,6 +1028,7 @@ function deleteFeedbackOperation({
815
1028
  }
816
1029
  if (target.kind === "project") await store.deleteAllFeedbacks(target.projectName);
817
1030
  else await store.deleteFeedback(target.id);
1031
+ notify(scope, { event: "feedback.deleted", target });
818
1032
  if (onDeleted) await pipeline.runHook(scope, "onDeleted", { target }, () => onDeleted(target, scope.context));
819
1033
  return pipeline.json(scope, { deleted: true });
820
1034
  } catch (error) {
@@ -855,6 +1069,8 @@ function listFeedbacksOperation({ store, pipeline }) {
855
1069
  {
856
1070
  ...page,
857
1071
  feedbacks,
1072
+ // Lets clients rely on what a protocol version added instead of probing for fields.
1073
+ protocol: SITEPING_PROTOCOL_VERSION,
858
1074
  // Lets clients hide their comment composer, and delete buttons, up front instead of meeting a 501.
859
1075
  capabilities: {
860
1076
  comments: store.addComment !== void 0,
@@ -875,6 +1091,7 @@ function listFeedbacksOperation({ store, pipeline }) {
875
1091
  function updateFeedbackOperation({
876
1092
  store,
877
1093
  pipeline,
1094
+ notify,
878
1095
  onUpdated
879
1096
  }) {
880
1097
  return async (request) => {
@@ -891,6 +1108,7 @@ function updateFeedbackOperation({
891
1108
  return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
892
1109
  }
893
1110
  const feedback = await store.updateFeedback(id, toFeedbackUpdate(status));
1111
+ notify(scope, { event: "feedback.status_changed", feedback });
894
1112
  if (onUpdated) {
895
1113
  const subject = { feedbackId: feedback.id, projectName: feedback.projectName };
896
1114
  await pipeline.runHook(scope, "onUpdated", subject, () => onUpdated(feedback, scope.context));
@@ -1033,14 +1251,26 @@ function createPipeline({
1033
1251
  ]);
1034
1252
  return { canChangeStatus, canDelete, canComment, canDeleteComment };
1035
1253
  };
1036
- const present = async (scope, feedback, includeEmail = scope.canReadAuthorEmail) => ({
1037
- ...toWireFeedback(
1038
- presentFeedback ? presentFeedback(feedback, scope.context) : feedback,
1039
- includeEmail,
1040
- scope.canReadAuthorEmail
1041
- ),
1042
- permissions: await permissions(scope, feedback)
1254
+ const commentPermissions = async (scope, thread, commentId, canDeleteThread) => ({
1255
+ canDelete: canDeleteThread || await may(scope, "DELETE", { action: "deleteComment", ...thread, commentId })
1043
1256
  });
1257
+ const present = async (scope, feedback, echoEmail = false) => {
1258
+ const canReadEmails = await scope.canReadAuthorEmail(feedback.projectName);
1259
+ const wire = toWireFeedback(
1260
+ presentFeedback ? presentFeedback(feedback, scope.context) : feedback,
1261
+ echoEmail || canReadEmails,
1262
+ canReadEmails
1263
+ );
1264
+ const granted = await permissions(scope, feedback);
1265
+ const thread = { projectName: feedback.projectName, feedbackId: feedback.id };
1266
+ const comments = await Promise.all(
1267
+ wire.comments.map(async (comment) => ({
1268
+ ...comment,
1269
+ permissions: await commentPermissions(scope, thread, comment.id, granted.canDeleteComment)
1270
+ }))
1271
+ );
1272
+ return { ...wire, comments, permissions: granted };
1273
+ };
1044
1274
  return {
1045
1275
  json,
1046
1276
  error,
@@ -1076,12 +1306,21 @@ function createPipeline({
1076
1306
  };
1077
1307
  }
1078
1308
  if (!outcome.ok) return { ok: false, response: error({ corsHeaders }, outcome.status, outcome.error) };
1309
+ const context = { request, principal: outcome.principal };
1310
+ const emailAccess = /* @__PURE__ */ new Map();
1079
1311
  return {
1080
1312
  ok: true,
1081
1313
  value: {
1082
- context: { request, principal: outcome.principal },
1314
+ context,
1083
1315
  corsHeaders,
1084
- canReadAuthorEmail: outcome.canReadAuthorEmail
1316
+ canReadAuthorEmail(projectName) {
1317
+ let answer = emailAccess.get(projectName);
1318
+ if (!answer) {
1319
+ answer = gate.canReadAuthorEmail(context, projectName);
1320
+ emailAccess.set(projectName, answer);
1321
+ }
1322
+ return answer;
1323
+ }
1085
1324
  }
1086
1325
  };
1087
1326
  },
@@ -1091,9 +1330,16 @@ function createPipeline({
1091
1330
  const body = await readJson(scope);
1092
1331
  return body.ok ? validate(scope, schema, body.value) : body;
1093
1332
  },
1094
- /** `null` when the policy allows the request, its 403 otherwise. */
1333
+ /**
1334
+ * `null` when the policy allows the request, its 403 otherwise. An
1335
+ * allowed request also settles its email access for the project now,
1336
+ * before anything is written: a throwing `canReadAuthorEmail` answers
1337
+ * the operation's 500 with nothing stored.
1338
+ */
1095
1339
  async authorize(scope, target) {
1096
- return await gate.authorize({ ...scope.context, ...target }) ? null : error(scope, 403, ERROR_MESSAGES.forbidden);
1340
+ if (!await gate.authorize({ ...scope.context, ...target })) return error(scope, 403, ERROR_MESSAGES.forbidden);
1341
+ await scope.canReadAuthorEmail(target.projectName);
1342
+ return null;
1097
1343
  },
1098
1344
  /**
1099
1345
  * Wire shape of a record answering a POST (fresh or replayed): the
@@ -1101,15 +1347,29 @@ function createPipeline({
1101
1347
  * its submitter — who supplied the feedback's, never its thread's.
1102
1348
  */
1103
1349
  presentCreated(scope, feedback) {
1104
- return present(scope, feedback, gate.echoesAuthorEmailOnCreate || scope.canReadAuthorEmail);
1350
+ return present(scope, feedback, gate.echoesAuthorEmailOnCreate);
1105
1351
  },
1106
- /** Wire shape of a comment answering a POST — the same echo rule as {@link presentCreated}. */
1107
- presentCreatedComment(scope, comment) {
1108
- return toWireComment(comment, gate.echoesAuthorEmailOnCreate || scope.canReadAuthorEmail);
1352
+ /**
1353
+ * Wire shape of a comment answering a POST, in `projectName`'s thread —
1354
+ * the same echo rule as {@link presentCreated}, with its permissions.
1355
+ */
1356
+ async presentCreatedComment(scope, comment, projectName) {
1357
+ const thread = { projectName, feedbackId: comment.feedbackId };
1358
+ const [canReadEmails, canDeleteThread] = await Promise.all([
1359
+ scope.canReadAuthorEmail(projectName),
1360
+ may(scope, "DELETE", { action: "deleteComment", ...thread })
1361
+ ]);
1362
+ return {
1363
+ ...toWireComment(comment, gate.echoesAuthorEmailOnCreate || canReadEmails),
1364
+ permissions: await commentPermissions(scope, thread, comment.id, canDeleteThread)
1365
+ };
1109
1366
  },
1110
- /** Whether this caller's comment keeps the `team` role it asks for — see `AccessGate.canCommentAsTeam`. */
1111
- canCommentAsTeam(scope) {
1112
- return gate.canCommentAsTeam(scope.context, scope.canReadAuthorEmail);
1367
+ /**
1368
+ * Whether this caller's comment in `projectName` keeps the `team` role it
1369
+ * asks for — see `AccessGate.canCommentAsTeam`.
1370
+ */
1371
+ async canCommentAsTeam(scope, projectName) {
1372
+ return gate.canCommentAsTeam(scope.context, projectName, await scope.canReadAuthorEmail(projectName));
1113
1373
  },
1114
1374
  /**
1115
1375
  * Answer a value the store cannot hold with a 422, which a client does
@@ -1179,15 +1439,17 @@ function createSitepingHandler(options) {
1179
1439
  maxBodyBytes = DEFAULT_MAX_BODY_BYTES
1180
1440
  } = options;
1181
1441
  if (!store) {
1182
- throw new Error("[siteping] createSitepingHandler requires a `store`.");
1442
+ throw new SitepingConfigError("CONFIG_STORE_MISSING", "[siteping] createSitepingHandler requires a `store`.");
1183
1443
  }
1184
1444
  if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes <= 0) {
1185
- throw new Error(
1445
+ throw new SitepingConfigError(
1446
+ "CONFIG_MAX_BODY_BYTES_INVALID",
1186
1447
  `[siteping] createSitepingHandler: \`maxBodyBytes\` must be a positive integer, got ${maxBodyBytes}.`
1187
1448
  );
1188
1449
  }
1189
1450
  if (options.access?.authorize && !store.verifyProjectOwnership) {
1190
- throw new Error(
1451
+ throw new SitepingConfigError(
1452
+ "CONFIG_OWNERSHIP_CHECK_MISSING",
1191
1453
  "[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."
1192
1454
  );
1193
1455
  }
@@ -1195,26 +1457,37 @@ function createSitepingHandler(options) {
1195
1457
  const logger = customLogger ? safeLogger(customLogger) : consoleLogger;
1196
1458
  const pipeline = createPipeline({ gate, allowedOrigins, logger, describeError, presentFeedback, maxBodyBytes });
1197
1459
  const webhookList = webhooks ? Array.isArray(webhooks) ? webhooks : [webhooks] : [];
1198
- checkWebhookTimeouts(webhookList);
1460
+ checkWebhookConfigs(webhookList);
1461
+ if (!store.addCommentIfAbsent && (hooks.onCommentCreated || webhookList.some((config) => subscribesTo(config, "comment.created")))) {
1462
+ throw new Error(
1463
+ "[siteping] createSitepingHandler: `comment.created` webhooks and the `onCommentCreated` hook need a store implementing `addCommentIfAbsent`, which tells a new reply from a retried one."
1464
+ );
1465
+ }
1466
+ const notify = createNotifier(webhookList, waitUntil, pipeline);
1199
1467
  return {
1200
1468
  OPTIONS: (request) => preflightResponse(request, allowedOrigins),
1201
1469
  POST: routeByBody(
1202
1470
  pipeline,
1203
1471
  "POST",
1204
1472
  "feedbackId",
1205
- createCommentOperation({ store, pipeline, beforeComment }),
1473
+ createCommentOperation({
1474
+ store,
1475
+ pipeline,
1476
+ notify,
1477
+ beforeComment,
1478
+ onCommentCreated: hooks.onCommentCreated?.bind(hooks)
1479
+ }),
1206
1480
  createFeedbackOperation({
1207
1481
  store,
1208
1482
  pipeline,
1209
- webhooks: webhookList,
1210
- waitUntil,
1483
+ notify,
1211
1484
  beforeCreate,
1212
1485
  // Bound so hooks written as class methods keep their `this`.
1213
1486
  onCreated: hooks.onCreated?.bind(hooks)
1214
1487
  })
1215
1488
  ),
1216
1489
  GET: listFeedbacksOperation({ store, pipeline }),
1217
- PATCH: updateFeedbackOperation({ store, pipeline, onUpdated: hooks.onUpdated?.bind(hooks) }),
1490
+ PATCH: updateFeedbackOperation({ store, pipeline, notify, onUpdated: hooks.onUpdated?.bind(hooks) }),
1218
1491
  DELETE: routeByBody(
1219
1492
  pipeline,
1220
1493
  "DELETE",
@@ -1223,6 +1496,7 @@ function createSitepingHandler(options) {
1223
1496
  deleteFeedbackOperation({
1224
1497
  store,
1225
1498
  pipeline,
1499
+ notify,
1226
1500
  onDeleting: hooks.onDeleting?.bind(hooks),
1227
1501
  onDeleted: hooks.onDeleted?.bind(hooks)
1228
1502
  })
@@ -1230,8 +1504,11 @@ function createSitepingHandler(options) {
1230
1504
  };
1231
1505
  }
1232
1506
  export {
1507
+ SITEPING_PROTOCOL_VERSION,
1508
+ WEBHOOK_EVENTS,
1233
1509
  createSitepingHandler,
1234
1510
  dispatchWebhook,
1235
- dispatchWebhooks
1511
+ dispatchWebhooks,
1512
+ isSitepingConfigError
1236
1513
  };
1237
1514
  //# sourceMappingURL=index.js.map