@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.d.ts CHANGED
@@ -1,14 +1,17 @@
1
- import { Prettify, Serialized, FeedbackRecord, CommentResponse, SitepingStore, FeedbackCreateInput, CommentPayload, FeedbackStatus, FeedbackType } from './siteping-core.js';
2
- export { CommentCreateInput, CommentPayload, FeedbackCreateInput, FeedbackRecord, SitepingStore } from './siteping-core.js';
1
+ import { Prettify, Serialized, FeedbackRecord, CommentResponse, CommentRecord, SitepingStore, FeedbackCreateInput, CommentPayload, SitepingLogger, FeedbackStatus, FeedbackType } from './siteping-core.js';
2
+ export { CommentCreateInput, CommentCreateOutcome, CommentPayload, CommentRecord, CommentResponse, DiagnosticsSnapshot, FeedbackCreateInput, FeedbackListPermissions, FeedbackPermissions, FeedbackRecord, FeedbackResponse, FeedbackResponseList, FeedbackStatus, FeedbackType, SITEPING_PROTOCOL_VERSION, ScreenshotRegion, SitepingCapabilities, SitepingConfigErrorCode, SitepingLogger, SitepingStore, isSitepingConfigError } from './siteping-core.js';
3
3
 
4
4
  /**
5
- * Outgoing webhook notifications for newly-created feedbacks.
5
+ * Outgoing webhook notifications.
6
6
  *
7
7
  * Plug a Slack, Discord, or generic HTTP endpoint into `createSitepingHandler`
8
- * to receive a payload whenever a feedback is successfully persisted. Webhooks
9
- * are dispatched as fire-and-forget (`void Promise.all(...)`) so a slow or
10
- * down receiver never blocks the client response — the feedback is already in
11
- * the DB by the time we dial out.
8
+ * to receive a payload once a write succeeds: a feedback created (the
9
+ * default), its status changed, a feedback or a project's feedback deleted,
10
+ * a reply posted — each webhook picks its `events`, and every delivery names
11
+ * its event in the `X-Siteping-Event` header. Webhooks are dispatched as
12
+ * fire-and-forget (`void Promise.all(...)`) so a slow or down receiver never
13
+ * blocks the client response — the write is already in the DB by the time we
14
+ * dial out.
12
15
  *
13
16
  * - **Type-specific formatting**: Slack uses `{ text, blocks }`, Discord uses
14
17
  * `{ content, embeds }`, generic posts the record as JSON (minus `clientId`).
@@ -17,13 +20,30 @@ export { CommentCreateInput, CommentPayload, FeedbackCreateInput, FeedbackRecord
17
20
  * mention parsing disabled, so a public feedback form can never be turned
18
21
  * into a channel-wide ping or a disguised link.
19
22
  * - **Timeout**: 5s by default (overridable per webhook).
20
- * - **Error handling**: `config.onError(err, feedback.id)` is invoked when
21
- * present; otherwise we log a one-liner to `console.warn` so the issue is
22
- * surfaced without crashing the request.
23
+ * - **Error handling**: `config.onError(err, feedbackId, delivery)` is invoked
24
+ * when present; otherwise we log a one-liner to `console.warn` so the issue
25
+ * is surfaced without crashing the request.
23
26
  */
24
27
 
25
28
  /** Supported webhook integrations — drives the JSON body shape. */
26
29
  type WebhookType = "slack" | "discord" | "generic";
30
+ /**
31
+ * What a delivery reports — sent as its `X-Siteping-Event` header, and what a
32
+ * webhook's `events` subscribes to:
33
+ *
34
+ * - `feedback.created` — a feedback was stored (never for a replayed `clientId`).
35
+ * - `feedback.status_changed` — a PATCH set a feedback's status, even the one it had.
36
+ * - `feedback.deleted` — one feedback, or every feedback of a project, was deleted.
37
+ * - `comment.created` — a reply was stored (never for a replayed `clientId`).
38
+ */
39
+ type WebhookEvent = keyof GenericWebhookEventMap;
40
+ /** Every {@link WebhookEvent}, in the order above — e.g. to validate an `X-Siteping-Event` header. */
41
+ declare const WEBHOOK_EVENTS: readonly ["feedback.created", "feedback.status_changed", "feedback.deleted", "comment.created"];
42
+ /** What `onError` learns about the failed delivery, beyond the feedback id. */
43
+ interface WebhookDeliveryInfo {
44
+ event: WebhookEvent;
45
+ projectName: string;
46
+ }
27
47
  /**
28
48
  * Outgoing webhook configuration.
29
49
  *
@@ -33,18 +53,24 @@ type WebhookType = "slack" | "discord" | "generic";
33
53
  * Useful for signed-payload schemes (`X-Signature`, bearer tokens, …).
34
54
  * - `timeoutMs` — abort the fetch after this many ms: a positive integer of
35
55
  * at most 2,147,483,647, the longest delay a timer holds. Defaults to 5000.
36
- * - `onError` — invoked with the underlying error and the feedback id when
37
- * the dispatch fails (network error, non-2xx, timeout). The webhook is
38
- * fire-and-forget, so this is your only chance to observe failures. An
39
- * async one is awaited: the delivery `waitUntil` holds covers it, and its
40
- * rejection is reported like a throw, never left unhandled.
56
+ * - `events` — the {@link WebhookEvent}s this webhook receives: a non-empty
57
+ * list, defaulting to `["feedback.created"]`, so a webhook set up before
58
+ * events existed gets exactly the deliveries it always did.
59
+ * - `onError` — invoked with the underlying error, the id of the feedback the
60
+ * delivery is about (`""` for a project-wide deletion) and the delivery's
61
+ * event and project, when the dispatch fails (network error, non-2xx,
62
+ * timeout). The webhook is fire-and-forget, so this is your only chance to
63
+ * observe failures. An async one is awaited: the delivery `waitUntil` holds
64
+ * covers it, and its rejection is reported like a throw, never left
65
+ * unhandled.
41
66
  */
42
67
  interface WebhookConfig {
43
68
  url: string;
44
69
  type?: WebhookType;
45
70
  headers?: Record<string, string>;
46
71
  timeoutMs?: number;
47
- onError?: (err: Error, feedbackId: string) => void | Promise<void>;
72
+ events?: ReadonlyArray<WebhookEvent>;
73
+ onError?: (err: Error, feedbackId: string, delivery: WebhookDeliveryInfo) => void | Promise<void>;
48
74
  }
49
75
  /** Block Kit envelope used by Slack incoming webhooks. */
50
76
  interface SlackWebhookPayload {
@@ -105,6 +131,31 @@ interface DiscordWebhookPayload {
105
131
  type GenericWebhookPayload = Prettify<Serialized<Omit<FeedbackRecord, "clientId" | "comments">> & {
106
132
  comments?: CommentResponse[] | undefined;
107
133
  }>;
134
+ /**
135
+ * Generic body of `feedback.deleted` — what was deleted, as the `onDeleting`
136
+ * and `onDeleted` hooks receive it: `{ kind: "single", id, projectName }` for
137
+ * one feedback, `{ kind: "project", projectName }` for a `deleteAll`, which
138
+ * fires even when the project held nothing (stores report no count).
139
+ */
140
+ type GenericWebhookDeletionPayload = SitepingDeletionTarget;
141
+ /**
142
+ * Generic body of `comment.created` — the stored comment as the receiver
143
+ * parses it (`createdAt` an ISO string, `clientId` stripped), plus the
144
+ * project its feedback belongs to. The author's email is unredacted, like
145
+ * the feedback's in {@link GenericWebhookPayload}.
146
+ */
147
+ type GenericWebhookCommentPayload = Prettify<Serialized<Omit<SitepingCreatedComment, "clientId">>>;
148
+ /**
149
+ * The generic body of each {@link WebhookEvent} — what a `"generic"` receiver
150
+ * parses, read from its `X-Siteping-Event` header. `feedback.created` and
151
+ * `feedback.status_changed` both carry the feedback as stored after the write.
152
+ */
153
+ interface GenericWebhookEventMap {
154
+ "feedback.created": GenericWebhookPayload;
155
+ "feedback.status_changed": GenericWebhookPayload;
156
+ "feedback.deleted": GenericWebhookDeletionPayload;
157
+ "comment.created": GenericWebhookCommentPayload;
158
+ }
108
159
  /** Mapping from webhook type to its concrete body shape. */
109
160
  interface WebhookPayloadMap {
110
161
  slack: SlackWebhookPayload;
@@ -112,20 +163,24 @@ interface WebhookPayloadMap {
112
163
  generic: GenericWebhookPayload;
113
164
  }
114
165
  /**
115
- * Dispatch a single webhook. Fire-and-forget: never throws, never rejects.
166
+ * Dispatch a single webhook with `feedback`'s `feedback.created` delivery,
167
+ * whatever its `events` — the handler routes by `events` itself (see
168
+ * {@link dispatchWebhooks}). Fire-and-forget: never throws, never rejects.
116
169
  *
117
170
  * - Builds the type-specific payload.
118
- * - POSTs with an `AbortSignal` timeout.
171
+ * - POSTs with an `AbortSignal` timeout and an `X-Siteping-Event` header.
119
172
  * - On any error (network, non-2xx, timeout, exception), invokes
120
- * `config.onError(err, feedbackId)` if provided; otherwise logs a one-liner.
121
- * A `timeoutMs` no timer holds is such an error: nothing is sent.
173
+ * `config.onError(err, feedbackId, delivery)` if provided; otherwise logs a
174
+ * one-liner. A `timeoutMs` no timer holds is such an error: nothing is sent.
122
175
  */
123
176
  declare function dispatchWebhook(config: WebhookConfig, feedback: FeedbackRecord): Promise<void>;
124
177
  /**
125
- * Dispatch every configured webhook in parallel. Awaiting the returned promise
126
- * lets tests synchronize on completion, but production callers should drop the
127
- * promise on the floor (`void dispatchWebhooks(...)`) so the HTTP response
128
- * isn't held back on slow receivers.
178
+ * Dispatch `feedback`'s `feedback.created` delivery to every configured
179
+ * webhook that subscribes to it (every webhook without `events`), in
180
+ * parallel. Awaiting the returned promise lets tests synchronize on
181
+ * completion, but production callers should drop the promise on the floor
182
+ * (`void dispatchWebhooks(...)`) so the HTTP response isn't held back on slow
183
+ * receivers.
129
184
  */
130
185
  declare function dispatchWebhooks(configs: readonly WebhookConfig[], feedback: FeedbackRecord): Promise<void>;
131
186
 
@@ -139,6 +194,16 @@ interface SitepingRequestContext<Principal> {
139
194
  /** Whoever `access.authenticate` resolved — always `null` under the `apiKey` policy. */
140
195
  principal: Principal;
141
196
  }
197
+ /**
198
+ * The project a response is about, and the request it answers — what
199
+ * `canReadAuthorEmail` and `canCommentAsTeam` decide for, beside the
200
+ * principal.
201
+ */
202
+ interface SitepingProjectContext {
203
+ request: Request;
204
+ /** Project of the records the response carries, or of the thread a comment joins. */
205
+ projectName: string;
206
+ }
142
207
  /** What `SitepingAccessControl.authorize` decides about. */
143
208
  interface SitepingAuthorizationContext<Principal> extends SitepingRequestContext<Principal> {
144
209
  action: SitepingAction;
@@ -147,8 +212,9 @@ interface SitepingAuthorizationContext<Principal> extends SitepingRequestContext
147
212
  /** Target record of `update` and `delete`; the feedback whose thread `createComment` and `deleteComment` target. */
148
213
  feedbackId?: string;
149
214
  /**
150
- * Target comment of `deleteComment` — absent on a `dryRun`, whose answer
151
- * covers the feedback's whole thread.
215
+ * Target comment of `deleteComment`. A `dryRun` asks first without one,
216
+ * for the feedback's whole thread; when that answer is `false`, it asks
217
+ * again for each comment of the thread, with its id.
152
218
  */
153
219
  commentId?: string;
154
220
  /**
@@ -176,34 +242,39 @@ type SitepingPrincipal = object | string | number;
176
242
  * check is what binds the authorized `projectName` to the record. It is
177
243
  * also asked, with `dryRun: true`, to fill in the `permissions` of every
178
244
  * feedback a response carries — `update`, `delete`, `createComment` and
179
- * `deleteComment` (without a `commentId`) per feedback, plus `deleteAll`
180
- * once per list — so clients hide what it would refuse. A dry run that
181
- * throws refuses that permission (logged) rather than failing the
182
- * response. Keep it fast: a page of 50 feedbacks takes 201 dry runs, 8
183
- * at a time, sharing the one `request` — cache lookups per request. That
184
- * request is the response's whatever the action, and `authenticate` is
185
- * not asked again: a principal it admits on some methods only needs
186
- * `authorize` to refuse it the other actions.
245
+ * `deleteComment` (without a `commentId`, for the whole thread) per
246
+ * feedback, plus `deleteAll` once per list — so clients hide what it
247
+ * would refuse. When the thread-wide `deleteComment` answer is `false`,
248
+ * each comment is asked about with its `commentId`, for its own
249
+ * `permissions`. A dry run that throws refuses that permission (logged)
250
+ * rather than failing the response. Keep it fast: a page of 50 feedbacks
251
+ * takes 201 dry runs, plus one per comment of a thread it refuses
252
+ * wholesale, 8 at a time, sharing the one `request` — cache lookups per
253
+ * request. That request is the response's whatever the action, and
254
+ * `authenticate` is not asked again: a principal it admits on some
255
+ * methods only needs `authorize` to refuse it the other actions.
187
256
  * - `canReadAuthorEmail` decides whether responses include `authorEmail`
188
257
  * (reviewer PII), on feedbacks and their comments — the list, the PATCH
189
- * answer and the POST answer alike. Only `true` includes it: without the
190
- * callback, every response blanks it. A policy that serves anonymous
191
- * visitors resolves a principal for them too, so being authenticated says
192
- * nothing about who may read reviewers' emails.
258
+ * answer and the POST answer alike. It is asked once per request, for the
259
+ * project the request targets (`context.projectName`). Only `true`
260
+ * includes it: without the callback, every response blanks it. A policy
261
+ * that serves anonymous visitors resolves a principal for them too, so
262
+ * being authenticated says nothing about who may read reviewers' emails.
193
263
  * - `canCommentAsTeam` decides whether a comment that asks for the `team`
194
- * role keeps it; otherwise it is stamped `client`. Defaults to the
195
- * principal's `canReadAuthorEmail` answer (whoever may read reviewer
196
- * emails is on the project side), so a policy that sets neither never
197
- * lets a caller speak as the team. Once set, anything but `true`
198
- * (`undefined` from plain JavaScript included) refuses.
264
+ * role keeps it, in the project of its thread; otherwise it is stamped
265
+ * `client`. Defaults to the principal's `canReadAuthorEmail` answer for
266
+ * that project (whoever may read reviewer emails is on the project side),
267
+ * so a policy that sets neither never lets a caller speak as the team.
268
+ * Once set, anything but `true` (`undefined` from plain JavaScript
269
+ * included) refuses.
199
270
  *
200
- * A throw from any of them answers a logged 500.
271
+ * A throw from any of them answers a logged 500, before anything is written.
201
272
  */
202
273
  interface SitepingAccessControl<Principal extends SitepingPrincipal> {
203
274
  authenticate(request: Request): Principal | null | undefined | Promise<Principal | null | undefined>;
204
275
  authorize?(context: SitepingAuthorizationContext<Principal>): boolean | Promise<boolean>;
205
- canReadAuthorEmail?(principal: Principal): boolean | Promise<boolean>;
206
- canCommentAsTeam?(principal: Principal): boolean | Promise<boolean>;
276
+ canReadAuthorEmail?(principal: Principal, context: SitepingProjectContext): boolean | Promise<boolean>;
277
+ canCommentAsTeam?(principal: Principal, context: SitepingProjectContext): boolean | Promise<boolean>;
207
278
  }
208
279
  /**
209
280
  * What a DELETE removes: one record, or a whole project (`deleteAll`) —
@@ -217,15 +288,28 @@ type SitepingDeletionTarget = {
217
288
  kind: "project";
218
289
  projectName: string;
219
290
  };
291
+ /**
292
+ * A stored reply, as `onCommentCreated` and the `comment.created` webhook
293
+ * receive it: the comment record plus the project of its feedback — the one
294
+ * the request named, checked against the feedback when the store implements
295
+ * `verifyProjectOwnership`.
296
+ */
297
+ interface SitepingCreatedComment extends CommentRecord {
298
+ projectName: string;
299
+ }
220
300
  /**
221
301
  * Side effects around persistence — issue trackers, chat notifications,
222
302
  * search indexes, audit logs. Hooks receive the stored record, `clientId` and
223
303
  * `authorEmail` included.
224
304
  *
225
- * `onCreated`, `onUpdated` and `onDeleted` are awaited before the response
226
- * (so serverless runtimes do not freeze them mid-flight); a throw is logged
227
- * and never fails the request — the write already happened. `onCreated`
228
- * runs once per inserted feedback, never for a replayed `clientId`.
305
+ * `onCreated`, `onUpdated`, `onDeleted` and `onCommentCreated` are awaited
306
+ * before the response (so serverless runtimes do not freeze them
307
+ * mid-flight); a throw is logged and never fails the request — the write
308
+ * already happened. `onCreated` runs once per inserted feedback and
309
+ * `onCommentCreated` once per inserted reply, never for a replayed
310
+ * `clientId`; `onCommentCreated` needs a store implementing
311
+ * `addCommentIfAbsent`, which tells the two apart (the handler refuses to
312
+ * start without it).
229
313
  * `onDeleting` runs before the delete: throw to abort it (the record is kept
230
314
  * and the request answers 502), e.g. when a resource tied to the feedback
231
315
  * could not be cleaned up and the delete must be retried.
@@ -235,19 +319,7 @@ interface SitepingLifecycleHooks<Principal> {
235
319
  onUpdated?(feedback: FeedbackRecord, context: SitepingRequestContext<Principal>): void | Promise<void>;
236
320
  onDeleting?(target: SitepingDeletionTarget, context: SitepingRequestContext<Principal>): void | Promise<void>;
237
321
  onDeleted?(target: SitepingDeletionTarget, context: SitepingRequestContext<Principal>): void | Promise<void>;
238
- }
239
- /**
240
- * Where the handler reports unexpected failures. Defaults to `console.error`.
241
- * A logger that throws, or returns a promise that rejects (a log shipper
242
- * down), falls back to `console.error`: it never fails the request, nor
243
- * leaves a rejection unhandled.
244
- *
245
- * The arguments come in console's and winston's order. pino's `error` takes
246
- * the context first and type-checks here anyway, then drops the context:
247
- * adapt it — `{ error: (message, { error, ...context }) => log.error({ err: error, ...context }, message) }`.
248
- */
249
- interface SitepingLogger {
250
- error(message: string, context: Record<string, unknown>): void | Promise<void>;
322
+ onCommentCreated?(comment: SitepingCreatedComment, context: SitepingRequestContext<Principal>): void | Promise<void>;
251
323
  }
252
324
  /** Options shared by both access policies. */
253
325
  interface SitepingHandlerBaseOptions<Principal> {
@@ -268,13 +340,18 @@ interface SitepingHandlerBaseOptions<Principal> {
268
340
  */
269
341
  maxBodyBytes?: number | undefined;
270
342
  /**
271
- * Outgoing webhooks fired after a feedback is successfully persisted.
343
+ * Outgoing webhooks fired after a write succeeds — by default when a
344
+ * feedback is stored; each config's `events` opts into status changes,
345
+ * deletions and replies (see `WebhookEvent`).
272
346
  *
273
- * Pass a single config or an array — every entry receives a POST with a
274
- * type-specific payload (Slack, Discord, or generic JSON). Dispatch is
347
+ * Pass a single config or an array — every entry subscribed to an event
348
+ * receives a POST with a type-specific payload (Slack, Discord, or generic
349
+ * JSON) and an `X-Siteping-Event` header naming it. Dispatch is
275
350
  * fire-and-forget: the HTTP response is returned to the widget before
276
351
  * webhook delivery completes, so a slow receiver never blocks the client.
277
- * Provide `onError` on each config to observe failures.
352
+ * Provide `onError` on each config to observe failures. A `comment.created`
353
+ * subscription needs a store implementing `addCommentIfAbsent` (the
354
+ * handler refuses to start without it).
278
355
  */
279
356
  webhooks?: WebhookConfig | ReadonlyArray<WebhookConfig>;
280
357
  /**
@@ -282,7 +359,7 @@ interface SitepingHandlerBaseOptions<Principal> {
282
359
  * or cancel them once the response is sent (serverless functions, edge
283
360
  * workers): Next.js `after`, `waitUntil` from `@vercel/functions` or from
284
361
  * `cloudflare:workers` — a standalone function, not an unbound method.
285
- * Called once per notified feedback with a promise that never rejects; a
362
+ * Called once per notified event with a promise that never rejects; a
286
363
  * throw is logged and the delivery still runs.
287
364
  */
288
365
  waitUntil?: ((promise: Promise<unknown>) => void) | undefined;
@@ -313,8 +390,15 @@ interface SitepingHandlerBaseOptions<Principal> {
313
390
  presentFeedback?(feedback: FeedbackRecord, context: SitepingRequestContext<Principal>): FeedbackRecord;
314
391
  /** Lifecycle side effects — see `SitepingLifecycleHooks`. */
315
392
  hooks?: SitepingLifecycleHooks<Principal>;
316
- /** Where unexpected failures are reported. Defaults to `console.error`. */
317
- logger?: SitepingLogger;
393
+ /**
394
+ * Where unexpected failures are reported, through `error` — the one level
395
+ * the handler uses, so `{ error }` alone is enough. Defaults to
396
+ * `console.error`. A logger that throws, or returns a promise that rejects
397
+ * (a log shipper down), falls back to `console.error`: it never fails the
398
+ * request, nor leaves a rejection unhandled. adapter-prisma also hands it
399
+ * to the `PrismaStore` it builds when it takes warnings (`warn`).
400
+ */
401
+ logger?: SitepingLogger | undefined;
318
402
  /**
319
403
  * Map an unexpected failure to the `error` string sent to the client.
320
404
  * Return `undefined` for the default (`"Internal server error"`). Store
@@ -485,4 +569,5 @@ interface ValidationIssue {
485
569
  message: string;
486
570
  }
487
571
 
488
- export { type DiscordWebhookPayload, type FeedbackDeleteInput, type FeedbackPatchInput, type GenericWebhookPayload, type GetQueryInput, type SitepingAccessControl, type SitepingAccessHandlerOptions, type SitepingAction, type SitepingApiKeyHandlerOptions, type SitepingAuthorizationContext, type SitepingDeletionTarget, type SitepingHandler, type SitepingHandlerBaseOptions, type SitepingHandlerOptions, type SitepingHttpMethod, type SitepingLifecycleHooks, type SitepingLogger, type SitepingPrincipal, type SitepingRequestContext, type SlackWebhookPayload, type ValidationIssue, type WebhookConfig, type WebhookPayloadMap, type WebhookType, createSitepingHandler, dispatchWebhook, dispatchWebhooks };
572
+ export { WEBHOOK_EVENTS, createSitepingHandler, dispatchWebhook, dispatchWebhooks };
573
+ export type { DiscordWebhookPayload, FeedbackDeleteInput, FeedbackPatchInput, GenericWebhookCommentPayload, GenericWebhookDeletionPayload, GenericWebhookEventMap, GenericWebhookPayload, GetQueryInput, SitepingAccessControl, SitepingAccessHandlerOptions, SitepingAction, SitepingApiKeyHandlerOptions, SitepingAuthorizationContext, SitepingCreatedComment, SitepingDeletionTarget, SitepingHandler, SitepingHandlerBaseOptions, SitepingHandlerOptions, SitepingHttpMethod, SitepingLifecycleHooks, SitepingPrincipal, SitepingProjectContext, SitepingRequestContext, SlackWebhookPayload, ValidationIssue, WebhookConfig, WebhookDeliveryInfo, WebhookEvent, WebhookPayloadMap, WebhookType };