@beezping/adapter-prisma 0.7.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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../../core/src/filters.ts","../../core/src/type-utils.ts","../../core/src/types.ts","../../server/src/constants/limits.ts","../../server/src/constants/error-messages.ts","../../server/src/access.ts","../../server/src/api-key-access.ts","../../server/src/constants/http.ts","../../server/src/cors.ts","../../server/src/internal-error.ts","../../core/src/email.ts","../../core/src/type-utils.ts","../../core/src/types.ts","../../server/src/validation.ts","../../server/src/webhooks.ts","../../server/src/operations/create-feedback.ts","../../server/src/operations/delete-feedback.ts","../../server/src/operations/list-feedbacks.ts","../../server/src/operations/update-feedback.ts","../../server/src/request-guards.ts","../../server/src/request-pipeline.ts","../../server/src/handler.ts","../../server/src/identity.ts"],"sourcesContent":["import {\n clampPagination,\n type FeedbackCreateInput,\n type FeedbackPage,\n type FeedbackPayload,\n type FeedbackQuery,\n type FeedbackRecord,\n type FeedbackStatus,\n type FeedbackType,\n type FeedbackUpdateInput,\n hasOwn,\n isStoreDuplicate,\n isStoreNotFound,\n isUnreachableOffset,\n type ScreenshotStorage,\n type SitepingStore,\n StoreDuplicateError,\n StoreNotFoundError,\n} from \"@beezping/core\";\nimport {\n type ApiKeyAccessOptions,\n createSitepingHandler as createServerHandler,\n type SitepingHandler,\n type WebhookConfig,\n} from \"@beezping/server\";\n\nexport type { ScreenshotStorage, SitepingStore } from \"@beezping/core\";\nexport {\n flattenAnnotation,\n isStorePersistence,\n StoreDuplicateError,\n StoreNotFoundError,\n StorePersistenceError,\n} from \"@beezping/core\";\nexport type {\n FeedbackDeleteInput,\n FeedbackPatchInput,\n GetQueryInput,\n SitepingHandler,\n SitepingHttpMethod,\n} from \"@beezping/server\";\n\n/**\n * @deprecated The create wire shape is core's `FeedbackPayload` — import\n * that instead. This alias is kept for one release cycle.\n */\nexport type FeedbackCreateSchemaInput = FeedbackPayload;\nexport type {\n DiscordWebhookPayload,\n GenericWebhookPayload,\n SlackWebhookPayload,\n WebhookConfig,\n WebhookPayloadMap,\n WebhookType,\n} from \"@beezping/server\";\nexport { dispatchWebhook, dispatchWebhooks } from \"@beezping/server\";\n\n// ---------------------------------------------------------------------------\n// Minimal PrismaClient shape expected by this adapter\n// ---------------------------------------------------------------------------\n\n/**\n * Structural type for a Prisma model delegate (`prisma.sitepingFeedback`).\n *\n * Arguments are kept `unknown` so any Prisma version's generated client\n * satisfies the constraint; the adapter assembles type-safe payloads\n * internally before forwarding them.\n *\n * Members use **method syntax** (`create(args)`) rather than function-property\n * syntax (`create: (args) => ...`) on purpose: under `strictFunctionTypes`,\n * function-property parameters are checked *contravariantly*, so a real\n * generated delegate — whose `create(args: SpecificArgs)` takes a type narrower\n * than `unknown` — would fail to assign to `PrismaModelDelegate`. Method\n * signatures are checked *bivariantly* on parameters, which is exactly what we\n * want for structurally matching a third-party generated client (#99).\n */\nexport interface PrismaModelDelegate {\n create(args: unknown): Promise<unknown>;\n findMany(args: unknown): Promise<unknown[]>;\n findUnique(args: unknown): Promise<unknown>;\n update(args: unknown): Promise<unknown>;\n delete(args: unknown): Promise<unknown>;\n deleteMany(args: unknown): Promise<unknown>;\n count(args: unknown): Promise<number>;\n}\n\n/**\n * Compile-time regression guard for #99 — intentionally in `src/` because the\n * package's `check` script (`tsc --noEmit`) only type-checks `src/`, and\n * vitest transpiles tests without type-checking.\n *\n * `GeneratedDelegateProbe` mirrors a real generated client: every method\n * declares args NARROWER than `unknown`. With method syntax the conditional\n * below resolves to `true`; if `PrismaModelDelegate` ever regresses to\n * function-property syntax (contravariant under `strictFunctionTypes`), it\n * resolves to `false` and the `AssertTrue` constraint fails the build.\n */\ntype AssertTrue<T extends true> = T;\ninterface GeneratedDelegateProbe {\n create(args: { data: unknown; include?: unknown }): Promise<{ id: string }>;\n findMany(args: { where?: unknown; include?: unknown }): Promise<{ id: string }[]>;\n findUnique(args: { where: unknown }): Promise<{ id: string } | null>;\n update(args: { where: unknown; data: unknown }): Promise<{ id: string }>;\n delete(args: { where: unknown }): Promise<{ id: string }>;\n deleteMany(args: { where?: unknown }): Promise<{ count: number }>;\n count(args: { where?: unknown }): Promise<number>;\n}\ntype _AssertDelegateBivariance = AssertTrue<GeneratedDelegateProbe extends PrismaModelDelegate ? true : false>;\n\n/**\n * Minimal Prisma client shape expected by this adapter.\n * Consumers pass their own `PrismaClient` instance at runtime — this interface\n * defines the subset of methods the adapter actually uses, so it can be\n * referenced in handler option types without importing `@prisma/client`.\n */\nexport interface SitepingPrismaClient {\n sitepingFeedback: PrismaModelDelegate;\n}\n\n// ---------------------------------------------------------------------------\n// PrismaStore — SitepingStore implementation backed by Prisma\n// ---------------------------------------------------------------------------\n\nconst INCLUDE_ANNOTATIONS = { annotations: true } as const;\n\n/**\n * Prisma datasource providers whose generated client exposes `mode?: QueryMode`\n * on string filters. Verified against Prisma 6.x by inspecting the generated\n * `StringFilter` type per provider:\n * - postgresql, mongodb, cockroachdb → emit `mode?: QueryMode`\n * - mysql, sqlite, sqlserver → no `mode` field; passing it raises\n * `PrismaClientValidationError: Unknown argument 'mode'` at runtime.\n * `postgres` is kept as a defensive alias in case `_activeProvider` ever\n * surfaces the legacy spelling.\n */\nconst PROVIDERS_SUPPORTING_INSENSITIVE_MODE: ReadonlySet<string> = new Set([\n \"postgresql\",\n \"postgres\",\n \"mongodb\",\n \"cockroachdb\",\n]);\n\n/** Internal shape used to probe `PrismaClient` for the active provider. */\ninterface PrismaClientProbe {\n _activeProvider?: unknown;\n _engineConfig?: { activeProvider?: unknown };\n _engine?: { config?: { activeProvider?: unknown } };\n}\n\n/**\n * Best-effort detection of the active Prisma provider for a runtime client.\n *\n * The provider is not part of any public API on `PrismaClient`. We probe a\n * few known internal locations across Prisma 5.x and 6.x and fall back to\n * `null` (treated as \"unknown — assume default Postgres-style behaviour\")\n * when none match.\n */\nfunction detectActiveProvider(prisma: unknown): string | null {\n try {\n const candidate = prisma as PrismaClientProbe | null | undefined;\n const fromActive = candidate?._activeProvider;\n if (typeof fromActive === \"string\") return fromActive;\n const fromEngineConfig = candidate?._engineConfig?.activeProvider;\n if (typeof fromEngineConfig === \"string\") return fromEngineConfig;\n const fromEngine = candidate?._engine?.config?.activeProvider;\n if (typeof fromEngine === \"string\") return fromEngine;\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Options accepted by `PrismaStore`.\n */\nexport interface PrismaStoreOptions {\n /**\n * When `true`, the `?search=` filter is built with `mode: \"insensitive\"`\n * (case-insensitive across all letters, including non-ASCII).\n *\n * When `false`, the filter is built without `mode` — uses each database's\n * default `LIKE` semantics (case-insensitive ASCII on SQLite by default;\n * case-sensitive on PostgreSQL with the standard `LIKE` operator;\n * collation-driven on MySQL and SQL Server).\n *\n * When omitted, the value is auto-detected from the Prisma client's active\n * provider: providers whose generated client exposes `mode?: QueryMode`\n * (`postgresql`, `mongodb`, `cockroachdb`) get `true`; others (`mysql`,\n * `sqlite`, `sqlserver`) get `false`. Unknown / undetectable providers\n * default to `false` — `contains` without `mode` works on every provider;\n * `mode: \"insensitive\"` throws on MySQL/SQLite/SQL Server, so the safer\n * default is to omit it.\n */\n caseInsensitiveSearch?: boolean;\n /**\n * Optional storage backend for screenshots. Without it, the data URL is\n * persisted inline on `Feedback.screenshotUrl` with a one-time warn.\n */\n screenshotStorage?: ScreenshotStorage | undefined;\n}\n\n/** `where` filter shape passed to `findMany` / `count`. Each field maps to a typed Prisma filter. */\ninterface FeedbackWhereInput {\n projectName: string;\n type?: FeedbackType;\n // Exact match (`status`) or bucket match (`{ in: [...] }` from `statuses`).\n status?: FeedbackStatus | { in: FeedbackStatus[] };\n url?: string;\n urlPattern?: string;\n message?: { contains: string; mode?: \"insensitive\" };\n}\n\n/**\n * Translate Prisma's coded errors into the store contract's classes (the\n * handler layer and the dashboard are ORM-agnostic and only know these).\n * Anything else — connection failures, validation errors — passes through.\n */\nfunction toStoreError(error: unknown): unknown {\n if (error instanceof StoreNotFoundError || error instanceof StoreDuplicateError) return error;\n if (isStoreNotFound(error)) return new StoreNotFoundError(undefined, { cause: error });\n if (isStoreDuplicate(error)) return new StoreDuplicateError(undefined, { cause: error });\n return error;\n}\n\n/**\n * Whether a persisted `screenshotUrl` points at an object a `ScreenshotStorage`\n * owns — inline `data:` URLs were never uploaded, so there is nothing to delete.\n */\nfunction isStoredScreenshotUrl(url: unknown): url is string {\n return typeof url === \"string\" && url.length > 0 && !url.startsWith(\"data:\");\n}\n\n/**\n * Prisma-backed implementation of `SitepingStore`.\n *\n * Wraps a PrismaClient to satisfy the abstract store interface.\n *\n * Pass `screenshotStorage` to externalise screenshots (S3, R2, B2, …) — the\n * widget's data URL is uploaded and only the returned URL is persisted, so\n * the database stays small. Without `screenshotStorage`, the data URL is\n * persisted inline (logged once on first use as a heads-up).\n */\nexport class PrismaStore implements SitepingStore {\n /** @internal */\n private prisma: SitepingPrismaClient;\n private readonly screenshotStorage: ScreenshotStorage | undefined;\n /** Module-level flag would leak across PrismaStore instances in tests; use per-instance. */\n private inlineFallbackWarned = false;\n /** @internal */\n private caseInsensitiveSearch: boolean;\n\n constructor(prisma: SitepingPrismaClient, options: PrismaStoreOptions = {}) {\n this.prisma = prisma;\n this.screenshotStorage = options.screenshotStorage;\n if (typeof options.caseInsensitiveSearch === \"boolean\") {\n this.caseInsensitiveSearch = options.caseInsensitiveSearch;\n } else {\n const provider = detectActiveProvider(prisma);\n // When the provider can't be detected, default to `false`: `contains`\n // without `mode` works on every Prisma provider; `mode: \"insensitive\"`\n // throws on MySQL/SQLite/SQL Server. Trades non-ASCII case-insensitivity\n // on undetectable Postgres clients (rare — _activeProvider is set on\n // every real Prisma 5/6 client) for not crashing on the others.\n this.caseInsensitiveSearch = provider !== null && PROVIDERS_SUPPORTING_INSENSITIVE_MODE.has(provider);\n }\n }\n\n async createFeedback(data: FeedbackCreateInput): Promise<FeedbackRecord> {\n const screenshotUrl = await this.persistScreenshot(data.screenshotDataUrl, data.clientId);\n\n try {\n return await this.insertFeedback(data, screenshotUrl);\n } catch (error) {\n // A replay of an already-stored clientId (the widget's retry queue):\n // the existing row keeps its own screenshot, so the one just uploaded\n // for this attempt is an orphan — drop it before reporting the dup.\n if (isStoreDuplicate(error)) await this.discardScreenshots([screenshotUrl]);\n throw toStoreError(error);\n }\n }\n\n private async insertFeedback(data: FeedbackCreateInput, screenshotUrl: string | null): Promise<FeedbackRecord> {\n return (await this.prisma.sitepingFeedback.create({\n data: {\n projectName: data.projectName,\n type: data.type,\n message: data.message,\n status: data.status,\n url: data.url,\n urlPattern: data.urlPattern ?? null,\n screenshotUrl,\n // Persisted as JSON when the model has a `screenshotRegion Json?`\n // column — same omit-when-null contract as `diagnostics` below, so\n // hosts that haven't run `npx siteping sync` keep working.\n ...(data.screenshotRegion ? { screenshotRegion: data.screenshotRegion } : {}),\n // Persisted as JSON when the model has a `diagnostics Json?` column.\n // Hosts that haven't run `npx siteping sync` keep their schema as-is\n // and Prisma will throw if we pass an unknown column, so omit the\n // key entirely when diagnostics is null.\n ...(data.diagnostics ? { diagnostics: data.diagnostics } : {}),\n viewport: data.viewport,\n userAgent: data.userAgent,\n authorName: data.authorName,\n authorEmail: data.authorEmail,\n clientId: data.clientId,\n annotations: {\n create: data.annotations.map((ann) => ({\n cssSelector: ann.cssSelector,\n xpath: ann.xpath,\n textSnippet: ann.textSnippet,\n elementTag: ann.elementTag,\n elementId: ann.elementId,\n textPrefix: ann.textPrefix,\n textSuffix: ann.textSuffix,\n fingerprint: ann.fingerprint,\n neighborText: ann.neighborText,\n anchorKey: ann.anchorKey ?? null,\n xPct: ann.xPct,\n yPct: ann.yPct,\n wPct: ann.wPct,\n hPct: ann.hPct,\n scrollX: ann.scrollX,\n scrollY: ann.scrollY,\n viewportW: ann.viewportW,\n viewportH: ann.viewportH,\n devicePixelRatio: ann.devicePixelRatio,\n })),\n },\n },\n include: INCLUDE_ANNOTATIONS,\n })) as FeedbackRecord;\n }\n\n /**\n * Resolve the value to persist on `Feedback.screenshotUrl`.\n *\n * - No data URL → null\n * - Storage configured → upload, return remote URL. Upload failures\n * persist `null` (drop the screenshot) rather than silently inlining\n * the data URL — an inline fallback would bloat Postgres unnoticed\n * during a multi-minute storage outage. The feedback message itself is\n * preserved; only the screenshot is missing, and the warn surfaces it.\n * - No storage → inline base64, with a one-time warn so prod operators\n * notice the footgun.\n *\n * Operators who prefer the legacy inline-on-failure behaviour can wrap\n * their `ScreenshotStorage.upload` with their own catch + return the\n * data URL — the adapter treats whatever the storage returns as final.\n */\n private async persistScreenshot(dataUrl: string | null | undefined, clientId: string): Promise<string | null> {\n if (!dataUrl) return null;\n\n if (this.screenshotStorage) {\n try {\n // Use clientId as the upload-time identifier — the feedback row's\n // own id isn't created yet and clientId is unique + stable.\n // NOTE: clientId is client-supplied; storage implementations that\n // map it to a filesystem path MUST sanitize against path traversal.\n const { url } = await this.screenshotStorage.upload(dataUrl, {\n feedbackId: clientId,\n mimeType: \"image/jpeg\",\n });\n return url;\n } catch (err) {\n console.warn(\n \"[siteping] screenshotStorage.upload failed — feedback will be saved without a screenshot. Wrap your storage's upload to handle this differently:\",\n err,\n );\n return null;\n }\n }\n\n if (!this.inlineFallbackWarned) {\n this.inlineFallbackWarned = true;\n console.warn(\n \"[siteping] enableScreenshot is on but no `screenshotStorage` is configured — base64 data URLs will be persisted inline on Feedback.screenshotUrl. Configure a ScreenshotStorage (S3/R2/…) for production.\",\n );\n }\n return dataUrl;\n }\n\n /**\n * Best-effort cleanup of stored screenshots through `ScreenshotStorage.delete`\n * — the hook the interface documents for feedback deletion. Failures are\n * logged and swallowed: an orphaned object is preferable to a delete that\n * reports failure after the row is already gone. Inline `data:` URLs and\n * stores without a `delete` hook are skipped.\n */\n private async discardScreenshots(urls: ReadonlyArray<unknown>): Promise<void> {\n const remove = this.screenshotStorage?.delete?.bind(this.screenshotStorage);\n if (!remove) return;\n const stored = urls.filter(isStoredScreenshotUrl);\n if (stored.length === 0) return;\n\n const results = await Promise.allSettled(stored.map((url) => remove(url)));\n results.forEach((result, index) => {\n if (result.status === \"rejected\") {\n console.warn(\n `[siteping] screenshotStorage.delete failed for ${stored[index]} — object left in place:`,\n result.reason,\n );\n }\n });\n }\n\n /** URLs of the stored screenshots in `projectName` — only fetched when a `delete` hook can use them. */\n private async storedScreenshotUrls(projectName: string): Promise<string[]> {\n if (!this.screenshotStorage?.delete) return [];\n const rows = (await this.prisma.sitepingFeedback.findMany({\n where: { projectName, screenshotUrl: { not: null } },\n select: { screenshotUrl: true },\n })) as ReadonlyArray<{ screenshotUrl: string | null }>;\n return rows.map((row) => row.screenshotUrl).filter(isStoredScreenshotUrl);\n }\n\n async findByClientId(clientId: string): Promise<FeedbackRecord | null> {\n return (await this.prisma.sitepingFeedback.findUnique({\n where: { clientId },\n include: INCLUDE_ANNOTATIONS,\n })) as FeedbackRecord | null;\n }\n\n async getFeedbacks(query: FeedbackQuery): Promise<FeedbackPage> {\n const { projectName, type, status, statuses, search, url, urlPattern } = query;\n // Same clamp as the in-memory pipeline: the HTTP schema already bounds\n // page/limit, but direct callers reach the store without it.\n const { limit, skip } = clampPagination(query);\n\n const where: FeedbackWhereInput = { projectName };\n if (type) where.type = type;\n // Bucket filter (`statuses`) wins over the exact `status` filter; an empty\n // array is treated as absent so no status constraint is applied.\n if (statuses && statuses.length > 0) {\n where.status = { in: [...statuses] };\n } else if (status) {\n where.status = status;\n }\n if (url) where.url = url;\n if (urlPattern) where.urlPattern = urlPattern;\n if (search) {\n where.message = this.caseInsensitiveSearch ? { contains: search, mode: \"insensitive\" } : { contains: search };\n }\n\n // A huge `page` from a direct caller yields a `skip` Prisma rejects\n // (non-integer or past 64 bits): answer the empty page the in-memory\n // stores return, with the real total, without issuing `findMany`.\n if (isUnreachableOffset(skip)) {\n return { feedbacks: [], total: await this.prisma.sitepingFeedback.count({ where }) };\n }\n\n const [feedbacks, total] = await Promise.all([\n this.prisma.sitepingFeedback.findMany({\n where,\n include: INCLUDE_ANNOTATIONS,\n orderBy: { createdAt: \"desc\" },\n skip,\n take: limit,\n }),\n this.prisma.sitepingFeedback.count({ where }),\n ]);\n\n return { feedbacks: feedbacks as FeedbackRecord[], total };\n }\n\n async updateFeedback(id: string, data: FeedbackUpdateInput): Promise<FeedbackRecord> {\n try {\n return (await this.prisma.sitepingFeedback.update({\n where: { id },\n data: {\n status: data.status,\n resolvedAt: data.resolvedAt,\n },\n include: INCLUDE_ANNOTATIONS,\n })) as FeedbackRecord;\n } catch (error) {\n throw toStoreError(error);\n }\n }\n\n async deleteFeedback(id: string): Promise<void> {\n let deleted: { screenshotUrl?: string | null } | null;\n try {\n // Prisma returns the deleted row — the only chance to learn which\n // screenshot object the feedback owned.\n deleted = (await this.prisma.sitepingFeedback.delete({ where: { id } })) as {\n screenshotUrl?: string | null;\n } | null;\n } catch (error) {\n throw toStoreError(error);\n }\n await this.discardScreenshots([deleted?.screenshotUrl]);\n }\n\n async deleteAllFeedbacks(projectName: string): Promise<void> {\n // Rows first, storage second: a failed storage cleanup leaves orphaned\n // objects (acceptable), the reverse would leave rows pointing at deleted\n // screenshots.\n const screenshotUrls = await this.storedScreenshotUrls(projectName);\n await this.prisma.sitepingFeedback.deleteMany({ where: { projectName } });\n await this.discardScreenshots(screenshotUrls);\n }\n\n /**\n * Verify that a feedback record with `id` belongs to `projectName`.\n * Returns `true` when the record exists and matches, `false` otherwise.\n */\n async verifyProjectOwnership(id: string, projectName: string): Promise<boolean> {\n const record = (await this.prisma.sitepingFeedback.findUnique({\n where: { id },\n // Only need projectName for the check — skip annotations\n })) as { projectName: string } | null;\n return record !== null && record.projectName === projectName;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Handler — thin Prisma wrapper over the store-agnostic @beezping/server\n// ---------------------------------------------------------------------------\n\nexport interface HandlerOptions extends ApiKeyAccessOptions {\n /** Prisma client — used when `store` is not provided. Wrapped in a `PrismaStore` internally. */\n prisma?: SitepingPrismaClient;\n /** Abstract store — when provided, takes precedence over `prisma`. */\n store?: SitepingStore;\n /**\n * Optional storage backend for screenshots. Used only with `prisma`\n * (ignored when a custom `store` is passed — that store is responsible\n * for its own screenshot strategy). Without a storage, the data URL is\n * persisted inline on `Feedback.screenshotUrl` with a one-time warn.\n */\n screenshotStorage?: ScreenshotStorage;\n /** Allowed CORS origins — when set, validates the Origin header */\n allowedOrigins?: ReadonlyArray<string> | undefined;\n /**\n * Override case-insensitive search behaviour for the built-in `PrismaStore`.\n *\n * Only applied when `prisma` is provided (not when a custom `store` is\n * passed). See `PrismaStoreOptions.caseInsensitiveSearch` for details on\n * auto-detection and per-provider semantics.\n */\n caseInsensitiveSearch?: boolean;\n /**\n * Outgoing webhooks fired after a feedback is successfully persisted.\n * Fire-and-forget; provide `onError` on each config to observe failures.\n */\n webhooks?: WebhookConfig | ReadonlyArray<WebhookConfig>;\n}\n\n/**\n * Create request handlers for the Siteping API endpoint.\n *\n * Accepts either a `store` (abstract) or a `prisma` client (backwards compatible).\n * When `prisma` is provided without `store`, it is wrapped in a `PrismaStore`.\n * For custom auth (sessions, roles), lifecycle hooks or input transforms use\n * `createSitepingHandler` from `@beezping/server` with a `PrismaStore`.\n *\n * **Rate limiting** is not handled by this library. Apply rate limiting at the\n * framework or reverse-proxy level (e.g. Next.js middleware, Nginx, Cloudflare).\n *\n * @example Next.js App Router — `app/api/siteping/route.ts`\n * ```ts\n * import { createSitepingHandler } from '@beezping/adapter-prisma'\n * import { prisma } from '@/lib/prisma'\n *\n * export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({ prisma })\n * ```\n */\nexport function createSitepingHandler({\n prisma,\n store: providedStore,\n screenshotStorage,\n caseInsensitiveSearch,\n ...handlerOptions\n}: HandlerOptions): SitepingHandler {\n if (!providedStore && !prisma) {\n throw new Error(\"[siteping] createSitepingHandler requires either `store` or `prisma`.\");\n }\n const store: SitepingStore =\n providedStore ??\n new PrismaStore(prisma as NonNullable<typeof prisma>, {\n screenshotStorage,\n ...(typeof caseInsensitiveSearch === \"boolean\" ? { caseInsensitiveSearch } : {}),\n });\n return createServerHandler({ ...handlerOptions, store, describeError: actionableErrorMessage });\n}\n\nfunction isTableNotFoundError(error: unknown): error is { code: \"P2021\" } {\n return hasOwn(error, \"code\") && (error as { code: unknown }).code === \"P2021\";\n}\n\n/** Actionable message for known Prisma setup errors; `undefined` falls back to the generic one. */\nfunction actionableErrorMessage(error: unknown): string | undefined {\n if (isTableNotFoundError(error)) {\n return \"Table 'SitepingFeedback' not found. Run 'npx prisma db push' to create it.\";\n }\n return undefined;\n}\n","/**\n * Shared feedback-record filtering and pagination — extracted from\n * `adapter-memory` and `adapter-localstorage` which previously kept two\n * near-identical copies of the same logic. Any adapter that holds an\n * in-memory snapshot of feedbacks can use it.\n *\n * Filtering order matches the historical adapter behaviour:\n * 1. projectName (always required)\n * 2. type\n * 3. status / statuses (`statuses` bucket wins when both are set)\n * 4. url\n * 5. urlPattern\n * 6. search (lowercase substring match on `message`)\n *\n * Pagination goes through `clampPagination`: `limit` capped at 100, `page`\n * 1-based, both clamped up to 1 rather than indexing backwards from the end\n * of the match set. Query adapters reuse the same helper so every store\n * paginates identically.\n */\n\nimport type { FeedbackQuery, FeedbackRecord } from \"./types.js\";\n\n/** Default page size when the caller omits `query.limit`. */\nexport const DEFAULT_PAGE_LIMIT = 50;\n/** Maximum allowed page size — defends against memory blow-ups on hostile callers. */\nexport const MAX_PAGE_LIMIT = 100;\n\nexport interface FilterResult {\n feedbacks: FeedbackRecord[];\n total: number;\n}\n\n/** Normalised pagination window — see {@link clampPagination}. */\nexport interface Pagination {\n /** 1-based page number, at least 1. */\n page: number;\n /** Page size in `[1, MAX_PAGE_LIMIT]`. */\n limit: number;\n /** Offset of the first row: `(page - 1) * limit`. */\n skip: number;\n}\n\nfunction toPositiveInteger(value: number | undefined, fallback: number): number {\n return value !== undefined && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback;\n}\n\n/**\n * Normalise `page` / `limit` to the store contract: `page` is 1-based and\n * clamped up to 1, `limit` defaults to 50 and is clamped into `[1, 100]`,\n * non-finite values fall back to the defaults. `skip` is the derived row\n * offset for query backends (`OFFSET`, Prisma `skip`).\n *\n * Shared by the in-memory pipeline and query adapters (`PrismaStore`) so\n * every store paginates identically — the HTTP schema clamps the same way,\n * but direct callers (dashboard store mode, server actions) reach the store\n * without a schema in front of them.\n */\nexport function clampPagination(query: Pick<FeedbackQuery, \"page\" | \"limit\">): Pagination {\n const page = toPositiveInteger(query.page, 1);\n const limit = Math.min(toPositiveInteger(query.limit, DEFAULT_PAGE_LIMIT), MAX_PAGE_LIMIT);\n return { page, limit, skip: (page - 1) * limit };\n}\n\n/**\n * Whether a {@link clampPagination} offset lies beyond any row a store can\n * hold. `clampPagination` bounds `page` from below only, so a direct caller's\n * huge `page` yields an offset past `Number.MAX_SAFE_INTEGER` — or `Infinity`\n * — that SQL backends reject (`OFFSET` is a 64-bit integer in PostgreSQL and\n * SQLite; Prisma's `skip` rejects non-integers and 64-bit overflow).\n *\n * Every safe integer fits a signed 64-bit offset and no table holds more rows\n * than that, so query adapters answer such a page as empty — with the real\n * `total` — instead of issuing the query: the same result the in-memory\n * pipeline returns.\n *\n * @param skip - The `skip` returned by {@link clampPagination}.\n * @returns `true` when no row can sit at that offset.\n */\nexport function isUnreachableOffset(skip: number): boolean {\n return !Number.isSafeInteger(skip);\n}\n\n/**\n * Apply the standard feedback filter + pagination pipeline against an\n * in-memory snapshot. Used by `MemoryStore.getFeedbacks` and\n * `LocalStorageStore.getFeedbacks` so the two never drift.\n *\n * @param items All known feedback records (already include `annotations`).\n * @param query Filter and pagination options. `projectName` is required.\n */\nexport function applyFeedbackFilters(items: readonly FeedbackRecord[], query: FeedbackQuery): FilterResult {\n let results: FeedbackRecord[] = items.filter((f) => f.projectName === query.projectName);\n\n if (query.type) results = results.filter((f) => f.type === query.type);\n // `statuses` (bucket / any-of) wins over the exact `status` filter when both\n // are present; an empty array is treated as absent.\n if (query.statuses && query.statuses.length > 0) {\n const allowed = query.statuses;\n results = results.filter((f) => allowed.includes(f.status));\n } else if (query.status) {\n results = results.filter((f) => f.status === query.status);\n }\n if (query.url) results = results.filter((f) => f.url === query.url);\n if (query.urlPattern) results = results.filter((f) => f.urlPattern === query.urlPattern);\n if (query.search) {\n const s = query.search.toLowerCase();\n results = results.filter((f) => f.message.toLowerCase().includes(s));\n }\n\n // Newest first is part of the store contract (PrismaStore orders by\n // createdAt desc) — sort explicitly instead of relying on insertion order.\n // Array.prototype.sort is stable, so same-millisecond records keep their\n // insertion order (newest inserted first).\n results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());\n\n const total = results.length;\n // Both bounds are clamped (see `clampPagination`): `(page - 1) * limit`\n // goes negative for a non-positive page or limit, and `slice` reads\n // negative indices from the END — so `page: -1` used to return a window\n // whose position depended on how many records happened to match.\n const { limit, skip } = clampPagination(query);\n if (isUnreachableOffset(skip)) return { feedbacks: [], total };\n\n return { feedbacks: results.slice(skip, skip + limit), total };\n}\n","/**\n * General-purpose TypeScript utility types used across `@beezping/*`.\n *\n * These are kept dependency-free and re-exported from the package entry\n * so adapters and integrators can rely on the same primitives the core\n * uses internally.\n */\n\n/**\n * Force TypeScript to expand a computed type into a flat object literal in\n * tooltips and error messages. Purely cosmetic — same structural type, just\n * easier to read.\n *\n * @example\n * type Raw = Omit<FeedbackRecord, \"annotations\"> & { annotations: number };\n * type Pretty = Prettify<Raw>; // displayed as a flat object\n */\nexport type Prettify<T> = { [K in keyof T]: T[K] } & {};\n\n/**\n * Returns `Y` when `A` is exactly assignable to `B` and vice-versa,\n * otherwise `N`. Powers compile-time equality assertions.\n */\nexport type IfEquals<A, B, Y = true, N = false> =\n (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? Y : N;\n\n/**\n * Compile-time exact-type guard — resolves to `true` when `Actual` and\n * `Expected` are identical, `never` otherwise. Assign the result to a\n * `const _lock: AssertEqual<A, B> = true;` so any drift becomes a compile\n * error at the declaration site.\n */\nexport type AssertEqual<Actual, Expected> = IfEquals<Actual, Expected, true, never>;\n\n/**\n * JSON-serialized shape of `T` — the wire form produced by `Response.json()`\n * / `JSON.stringify`: `Date` becomes ISO `string` (nullability preserved),\n * arrays are serialized element-wise, everything else is untouched.\n *\n * Used to derive the `*Response` API types from the `*Record` store types so\n * the two can never drift: add a field to `FeedbackRecord` and\n * `FeedbackResponse` follows automatically.\n */\nexport type Serialized<T> = {\n [K in keyof T]: T[K] extends Date\n ? string\n : T[K] extends Date | null\n ? string | null\n : T[K] extends (infer U)[]\n ? Serialized<U>[]\n : T[K];\n};\n\n/**\n * Type guard that narrows `value` to a non-null `Record<PropertyKey, unknown>`.\n * Useful when validating arbitrary inputs before reading fields.\n */\nexport function isRecord(value: unknown): value is Record<PropertyKey, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n\n/**\n * Returns true when `value` is an object that exposes the requested key.\n * Type-narrows `value` so the property can be accessed without further\n * casting — a strictly typed replacement for `\"k\" in obj`.\n *\n * Named after the standardised `Object.hasOwn` helper rather than the\n * legacy `Object.prototype.hasOwnProperty`, which the linter forbids\n * shadowing.\n */\nexport function hasOwn<K extends PropertyKey>(value: unknown, key: K): value is Record<K, unknown> {\n return isRecord(value) && key in value;\n}\n","import { type AssertEqual, hasOwn, type Prettify, type Serialized } from \"./type-utils.js\";\n\n// ---------------------------------------------------------------------------\n// Config\n// ---------------------------------------------------------------------------\n\n/** FAB anchor — bottom-corner placement supported by the widget. */\nexport type SitepingPosition = \"bottom-right\" | \"bottom-left\";\n\n/** Visual theme — `auto` resolves to `light` or `dark` via system preference. */\nexport type SitepingTheme = \"light\" | \"dark\" | \"auto\";\n\n/** Built-in UI locales shipped with the widget. */\nexport const BUILTIN_LOCALES = [\"en\", \"fr\", \"de\", \"es\", \"it\", \"pt\", \"ru\"] as const;\nexport type BuiltinLocale = (typeof BUILTIN_LOCALES)[number];\n\n/**\n * Locale identifier accepted by the widget. Built-in locales are kept as\n * literal strings so editors auto-complete them, but arbitrary BCP-47 tags\n * are also accepted (custom dictionaries registered via `registerLocale`).\n */\nexport type SitepingLocale = BuiltinLocale | (string & {});\n\n/**\n * Reasons reported through `SitepingConfig.onSkip` — production environment,\n * mobile viewport, or server-side rendering (no `window`/`document`).\n */\nexport type SitepingSkipReason = \"production\" | \"mobile\" | \"ssr\";\n\n/** Per-channel + per-buffer-size diagnostics configuration. */\nexport interface DiagnosticsCaptureOptions {\n console?: boolean | undefined;\n network?: boolean | undefined;\n maxConsoleEntries?: number | undefined;\n maxNetworkEntries?: number | undefined;\n}\n\n/** Identity payload supplied by the host application — bypasses the modal. */\nexport interface SitepingIdentity {\n name: string;\n email: string;\n}\n\n/** Deep-link configuration — controls how a feedback id is read from the URL. */\nexport interface SitepingDeepLinkOptions {\n /** Query parameter name carrying the feedback id. Defaults to `\"siteping\"`. */\n param?: string | undefined;\n}\n\n/**\n * Extra request headers for HTTP mode — a static map, or a factory (sync or\n * async) invoked once per request to produce fresh values (e.g. a short-lived\n * session token).\n */\nexport type SitepingHeadersOption =\n | Record<string, string>\n | (() => Record<string, string> | Promise<Record<string, string>>);\n\n/**\n * Cookie policy for HTTP-mode requests — forwarded verbatim as the\n * `credentials` option of every `fetch` the widget (or the dashboard's\n * endpoint source) makes. Mirrors the DOM `RequestCredentials` union,\n * declared here so core stays free of DOM lib types.\n *\n * - `\"same-origin\"` (default): cookies only when the endpoint shares the\n * page's origin — the browser's own default.\n * - `\"include\"`: also send cookies to a cross-origin endpoint. Required when a\n * server on another origin authenticates with a session cookie; the server\n * must answer with credentialed CORS (the page's exact origin plus\n * `Access-Control-Allow-Credentials: true`, e.g. `@beezping/server`'s\n * `allowedOrigins`).\n * - `\"omit\"`: never send cookies, even same-origin.\n */\nexport type SitepingRequestCredentials = \"omit\" | \"same-origin\" | \"include\";\n\n/** Every accepted {@link SitepingRequestCredentials} value — runtime guard source for untyped (script-tag) consumers. */\nexport const REQUEST_CREDENTIALS_MODES = [\n \"omit\",\n \"same-origin\",\n \"include\",\n] as const satisfies readonly SitepingRequestCredentials[];\n\n/**\n * Credentials mode used when none is configured — the browser's own `fetch`\n * default, so leaving the option unset never changes cookie behavior (and\n * never opts a cross-origin endpoint into cookie-carrying, CSRF-prone requests).\n */\nexport const DEFAULT_REQUEST_CREDENTIALS = \"same-origin\" satisfies SitepingRequestCredentials;\n\n/**\n * Narrow an untyped config value to a {@link SitepingRequestCredentials} mode.\n *\n * @param value - Raw `credentials` option (may come from an untyped script-tag config).\n * @returns `true` when `value` is one of {@link REQUEST_CREDENTIALS_MODES}.\n */\nexport function isRequestCredentials(value: unknown): value is SitepingRequestCredentials {\n return typeof value === \"string\" && (REQUEST_CREDENTIALS_MODES as readonly string[]).includes(value);\n}\n\n/**\n * Actionable description of a rejected `credentials` option — shared by the\n * widget init guard and the dashboard's endpoint source so both report the\n * same wording.\n *\n * @param value - The rejected raw value (a config value, never user content).\n * @returns e.g. `invalid \\`credentials\\` \"all\". Expected one of \"omit\", \"same-origin\", \"include\".`\n */\nexport function describeInvalidRequestCredentials(value: unknown): string {\n const accepted = REQUEST_CREDENTIALS_MODES.map((mode) => `\"${mode}\"`).join(\", \");\n return `invalid \\`credentials\\` ${String(JSON.stringify(value))}. Expected one of ${accepted}.`;\n}\n\n/**\n * Options shared by both widget modes (HTTP and direct store).\n *\n * Do not use this type directly — use {@link SitepingConfig}, the\n * discriminated union that adds the mode-specific fields.\n */\nexport interface SitepingBaseConfig {\n /** Required — project identifier used to scope feedbacks */\n projectName: string;\n /** FAB position — defaults to 'bottom-right' */\n position?: SitepingPosition | undefined;\n /**\n * Show the \"toggle markers visibility\" item in the FAB radial menu.\n * Defaults to `true` (current behavior). Set to `false` to hide that\n * item entirely — useful for hosts that always want markers visible\n * (e.g. dedicated review tools) or that find the eye icon redundant\n * when no marker is on screen.\n *\n * Hiding the item also removes its keyboard navigation slot — the\n * remaining two items still respond to ArrowUp/ArrowDown/Home/End.\n * The marker-visibility state itself is unaffected; markers stay\n * visible (the previous default state) and `annotations:toggle` is\n * simply never emitted from the FAB.\n */\n showAnnotationsToggle?: boolean | undefined;\n /** Accent color for the widget UI — defaults to '#0066ff' */\n accentColor?: string | undefined;\n /**\n * Render the widget even when it would normally be skipped — this bypasses\n * BOTH the production-environment guard AND the mobile-viewport guard.\n * It does NOT bypass the SSR guard: without `window`/`document` the widget\n * never renders and `onSkip(\"ssr\")` fires instead.\n * Defaults to false. Use it for dedicated review tools, staging environments,\n * or responsive testing where you always want the widget present.\n */\n forceShow?: boolean | undefined;\n /**\n * Minimum viewport width (px) at or above which the widget renders. Below it,\n * the widget is skipped and `onSkip(\"mobile\")` fires. Defaults to `768`.\n *\n * Set lower (e.g. `0`) to allow narrow/mobile viewports, or use `forceShow`\n * to bypass the viewport check entirely.\n */\n minViewportWidth?: number | undefined;\n /** Enable debug logging of lifecycle events — defaults to false */\n debug?: boolean | undefined;\n /** Color theme — defaults to 'light' */\n theme?: SitepingTheme | undefined;\n /** UI locale — defaults to 'en'. Built-in: en, fr, de, es, it, pt (Brazilian), ru. Any other string falls back to English. */\n locale?: SitepingLocale | undefined;\n /**\n * Returns the current page scope for annotations and panel filtering.\n * Called on initial markers load and on `instance.refresh()`.\n *\n * Default: `{ url: window.location.pathname, urlPattern: null }` — annotations\n * are scoped strictly to the current pathname.\n *\n * Apps with parameterized routes (e.g. React Router) should return both the\n * concrete URL and the route template (e.g. `/orders/:orderId`) so the panel\n * can offer a \"this type of page\" filter that groups feedbacks by template.\n */\n getPageScope?: (() => PageScope) | undefined;\n /**\n * When true (default), the widget filters initial markers and panel results\n * by `feedback.url === scope.url`, so annotations created on one page never\n * leak to other pages — even if their CSS selector accidentally matches.\n * Set to `false` to revert to the legacy project-wide behavior.\n */\n scopeAnnotationsByUrl?: boolean | undefined;\n /**\n * Capture a JPEG screenshot of the annotated area on submit. Defaults to\n * `false` — opt-in because:\n *\n * - it adds runtime weight (~60 KB gzip dynamic chunk for html2canvas-pro,\n * loaded only on first capture),\n * - it embeds page content in the feedback (privacy/GDPR consideration —\n * inform end users in your widget host UI when enabling).\n *\n * `html2canvas-pro` ships as a regular dependency of `@beezping/widget` so the\n * dynamic import always resolves; you don't need to install anything extra.\n *\n * **Masking sensitive elements:** add `data-siteping-ignore=\"true\"` to any\n * element you do NOT want captured (password fields, credit-card forms,\n * API tokens shown in the UI, etc.). The capture predicate skips matching\n * elements *and their descendants*. Do this BEFORE turning on screenshots\n * in production — once a feedback is saved, the screenshot is in your DB\n * (or object storage) regardless of what was on the page.\n */\n enableScreenshot?: boolean | undefined;\n /**\n * Enable right-click (`contextmenu`) to instantly open the comment composer\n * at the cursor location. When enabled, a document-level listener intercepts\n * right-clicks, prevents the browser's native context menu, and enters the\n * annotation flow anchored to the element under the cursor. Defaults to\n * `false` — the browser's native context menu is never hijacked unless the\n * host explicitly opts in.\n *\n * Keyboard-triggered context menus (≣ Menu key, Shift+F10) always get the\n * native menu; only mouse right-click and touch/pen long-press open the\n * composer.\n *\n * **Modifier-key escape hatch:** holding Shift, Ctrl, Alt, or Meta while\n * right-clicking always falls through to the native context menu, giving\n * users (and devtools) an escape hatch regardless of this setting.\n *\n * Right-clicks on SitePing's own UI (FAB, panel, markers, popup) are\n * ignored — the native menu is shown as expected.\n *\n * Note: on Android, `contextmenu` fires on long-press. The widget already\n * hides below `minViewportWidth` (default 768 px), but tablets above that\n * threshold will trigger this flow on long-press.\n */\n enableRightClickComment?: boolean | undefined;\n /**\n * Capture the last few `console.*` calls and failed network requests\n * (HTTP >= 400 or network error) at the moment a feedback is submitted.\n *\n * Lets reviewers replay the technical context that led to the report —\n * stack traces, 500 responses, dead third-party scripts. Great for the\n * \"the page just doesn't work\" feedback that contains zero detail.\n *\n * - `true` — capture with defaults (50 console / 20 network entries).\n * - `false` (default) — no capture, no monkey-patching.\n * - object — per-channel toggles + custom buffer sizes.\n *\n * **Privacy considerations:** console messages may contain anything the\n * host page logs, including user data. Failed network requests record the\n * URL (with query string) but never the response body. Inform end users\n * before enabling in environments where they might log sensitive values.\n */\n captureDiagnostics?: boolean | DiagnosticsCaptureOptions | undefined;\n /** Called when the widget is skipped (production mode, mobile viewport, SSR — no DOM) */\n onSkip?: (reason: SitepingSkipReason) => void;\n /**\n * Auto-focus a specific annotation when its ID appears in the URL query\n * string. Lets hosts deeplink directly into a feedback from external\n * systems (Zammad tickets, Slack notifications, dashboard rows).\n *\n * When enabled, the widget reads the configured query parameter from\n * `window.location.search` right after the initial markers load. If the\n * value matches a visible feedback ID, the widget scrolls the annotation\n * into view, pins its highlight, and pulses the marker — the same visual\n * affordance a marker click produces.\n *\n * - `false` / `undefined` (default): no URL parsing. Existing behavior\n * unchanged, no host URL inspection.\n * - `true`: enabled with default query parameter name `siteping`.\n * - object: enabled with a custom parameter name. Use this to avoid\n * clashes with host-app query keys.\n *\n * Only the initial load triggers focus. Subsequent URL changes (SPA\n * navigation, `history.pushState`, hash updates) are ignored —\n * deliberate, to avoid surprising re-scrolls during normal browsing.\n * Hosts that need re-focus on route change can call\n * `instance.focusFeedback(id)` explicitly.\n */\n deepLink?: boolean | SitepingDeepLinkOptions | undefined;\n /**\n * Automatically re-fetch feedbacks when the page changes during client-side\n * (SPA) navigation. Enabled by default.\n *\n * The widget is normally mounted once (singleton) inside a persistent layout\n * — e.g. a Next.js App Router `layout.tsx`, which does NOT remount on\n * client-side navigation. Without this, init runs a single time and both the\n * panel list and the page markers stay frozen on the page where the widget\n * first mounted. With it on, the widget patches the History API\n * (`pushState`/`replaceState`, which SPA routers call instead of triggering\n * `popstate`) and listens for `popstate`/`hashchange`, then re-fetches when\n * the scope key (`getPageScope().url` + template) actually changes.\n *\n * This re-fetches data only — it deliberately does NOT re-focus or re-scroll\n * to an annotation (deep-link focus stays initial-load only; see `deepLink`),\n * so normal browsing is never interrupted by a surprise scroll.\n *\n * - `true` (default) — watch navigation and re-fetch on route change.\n * - `false` — never touch the History API; hosts drive updates manually via\n * `instance.refresh()`.\n */\n watchNavigation?: boolean | undefined;\n /**\n * Pre-fill author identity from the host application — typically the\n * currently signed-in user. When set, the widget uses these values\n * directly and never shows the identity modal, even on first feedback.\n *\n * Use case: SSO-integrated apps where the end user is already\n * authenticated by the host. Avoids the awkward \"enter your name and\n * email\" prompt for users the host already knows.\n *\n * When unset (default), the widget falls back to localStorage and shows\n * the modal on first feedback as before — existing behavior unchanged.\n *\n * Note: `config.identity` is **not** persisted to localStorage. It is\n * read at widget init time, not on every render. Hosts that need live\n * identity updates after sign-in/sign-out should currently remount the\n * widget (e.g. via a React `key` on the wrapping component). See\n * https://github.com/NeosiaNexus/SitePing/issues/85 for tracking a\n * future enhancement that propagates identity updates without a remount.\n */\n identity?: SitepingIdentity | undefined;\n\n // Events\n /** Called when the feedback panel is opened. */\n onOpen?: (() => void) | undefined;\n /** Called when the feedback panel is closed. */\n onClose?: (() => void) | undefined;\n /** Called after a feedback is successfully submitted. */\n onFeedbackSent?: ((feedback: FeedbackResponse) => void) | undefined;\n /**\n * Called when a feedback API call fails.\n *\n * The widget always emits a `SitepingError` (or a subclass:\n * `SitepingNetworkError`, `SitepingValidationError`, `SitepingAuthError`)\n * for HTTP-mode failures — host apps can `instanceof` to drive retry\n * logic, or read `error.code` (`\"NETWORK\" | \"VALIDATION\" | \"AUTH\" |\n * \"SERVER\"`) and `error.retryable`. The type is widened to `Error` so\n * direct-store callers can still surface raw errors without breaking the\n * contract.\n */\n onError?: ((error: Error) => void) | undefined;\n /** Called when the user starts drawing an annotation. */\n onAnnotationStart?: (() => void) | undefined;\n /** Called when the user finishes drawing an annotation. */\n onAnnotationEnd?: (() => void) | undefined;\n}\n\n/**\n * HTTP mode — the widget talks to a server endpoint backed by a store\n * adapter (e.g. `@beezping/adapter-prisma` request handlers).\n */\nexport interface SitepingHttpConfig extends SitepingBaseConfig {\n /** HTTP endpoint that receives feedbacks (e.g. '/api/siteping'). */\n endpoint: string;\n /**\n * Convenience auth for HTTP mode — sent as `Authorization: Bearer <apiKey>`\n * on every request to `endpoint`.\n *\n * **WARNING: the widget runs in every visitor's browser, so a static key\n * configured here is public** — anyone can read it from your page source\n * and replay it against your API. Only use `apiKey` for internal tools\n * already behind your own login. On public sites, prefer `headers` with a\n * per-request factory returning a short-lived session token.\n */\n apiKey?: string | undefined;\n /**\n * Extra headers for every HTTP-mode request — a static map, or a factory\n * (sync or async) called once per request (e.g. to fetch a fresh session\n * token). Merged over the widget's generated headers, so an explicit\n * `Authorization` entry overrides `apiKey`. A throwing/rejecting factory\n * fails the request like a network error.\n */\n headers?: SitepingHeadersOption | undefined;\n /**\n * Cookie policy for every HTTP-mode request (submit, list, update, delete\n * and the offline retry-queue replay). Defaults to `\"same-origin\"` — the\n * browser default, so existing setups are unchanged.\n *\n * Set `\"include\"` when `endpoint` lives on **another origin** and the\n * server authenticates with a session cookie (e.g. a custom\n * `access.authenticate` in `@beezping/server`): without it the browser\n * never attaches the cookie and every request is rejected as\n * unauthenticated. The server must allow the page's origin explicitly with\n * credentialed CORS (`allowedOrigins`) — a wildcard origin never works\n * with cookies. Only opt in for origins you trust: cookie-authenticated\n * cross-origin requests rely on the server's CSRF defenses (strict origin\n * allowlist, `SameSite` cookies).\n *\n * An unknown value (untyped script-tag config) is rejected at init: the\n * widget logs an error and does not load.\n */\n credentials?: SitepingRequestCredentials | undefined;\n /** Not available in HTTP mode — use either `endpoint` or `store`, never both. */\n store?: never;\n}\n\n/**\n * Store mode — the widget talks to a `SitepingStore` directly in the\n * browser, no server needed (demos, prototypes, localStorage persistence).\n */\nexport interface SitepingStoreConfig extends SitepingBaseConfig {\n /** Direct store for client-side mode. Bypasses HTTP entirely. */\n store: SitepingStore;\n /** Not available in store mode — use either `endpoint` or `store`, never both. */\n endpoint?: never;\n /** HTTP-mode only — meaningless without an `endpoint`. */\n apiKey?: never;\n /** HTTP-mode only — meaningless without an `endpoint`. */\n headers?: never;\n /** HTTP-mode only — meaningless without an `endpoint`. */\n credentials?: never;\n}\n\n/**\n * Configuration options for the Siteping widget.\n *\n * A discriminated union over the two transport modes: pass `endpoint`\n * (HTTP mode, optionally with `apiKey`/`headers`/`credentials`) **or** `store` (direct\n * client-side mode) — never both, never neither. Invalid combinations are\n * compile errors instead of runtime warnings.\n */\nexport type SitepingConfig = SitepingHttpConfig | SitepingStoreConfig;\n\n/** Instance returned by initSiteping() with lifecycle methods. */\nexport interface SitepingInstance {\n /** Remove the widget from the DOM and clean up all listeners. */\n destroy: () => void;\n /** Open the panel programmatically */\n open: () => void;\n /** Close the panel */\n close: () => void;\n /** Reload feedbacks from server */\n refresh: () => void;\n /**\n * Scroll the matching annotation into view, pin its highlight, and\n * pulse its marker. Returns `true` when a visible feedback matched the\n * given ID, `false` otherwise (unknown ID, feedback on another URL when\n * `scopeAnnotationsByUrl` filtered it out, or markers not yet loaded).\n *\n * Counterpart to the `deepLink` config option for hosts that prefer to\n * drive focus from JS (e.g., a notification click handler) instead of a\n * URL query parameter.\n */\n focusFeedback: (feedbackId: string) => boolean;\n /** Subscribe to a public widget event */\n on: <K extends keyof SitepingPublicEvents>(event: K, listener: SitepingPublicEventListener<K>) => SitepingUnsubscribe;\n /** Unsubscribe from a public widget event */\n off: <K extends keyof SitepingPublicEvents>(event: K, listener: SitepingPublicEventListener<K>) => void;\n}\n\n/** Listener signature for a single `SitepingPublicEvents` key. */\nexport type SitepingPublicEventListener<K extends keyof SitepingPublicEvents> = (\n ...args: SitepingPublicEvents[K]\n) => void;\n\n/** Disposer returned by `SitepingInstance.on` — call once to detach the listener. */\nexport type SitepingUnsubscribe = () => void;\n\n/** Events exposed to consumers via SitepingInstance.on / .off */\nexport interface SitepingPublicEvents {\n \"feedback:sent\": [FeedbackResponse];\n \"feedback:deleted\": [FeedbackResponse[\"id\"]];\n /**\n * A feedback API call failed. Same payload contract as\n * `SitepingConfig.onError` — a `SitepingError` subclass in HTTP mode,\n * possibly a raw `Error` in store mode.\n */\n \"feedback:error\": [Error];\n \"panel:open\": [];\n \"panel:close\": [];\n /** The user started drawing an annotation. */\n \"annotation:start\": [];\n /** The user finished drawing an annotation. */\n \"annotation:end\": [];\n}\n\n// ---------------------------------------------------------------------------\n// Feedback\n// ---------------------------------------------------------------------------\n\n/** Single source of truth for feedback types — used by both TS types and Zod schemas. */\nexport const FEEDBACK_TYPES = [\"question\", \"change\", \"bug\", \"other\"] as const;\nexport type FeedbackType = (typeof FEEDBACK_TYPES)[number];\n\n/** Single source of truth for feedback statuses. */\nexport const FEEDBACK_STATUSES = [\"open\", \"in_progress\", \"resolved\", \"wont_fix\"] as const;\nexport type FeedbackStatus = (typeof FEEDBACK_STATUSES)[number];\n\n/**\n * Terminal statuses — the feedback needs no further action. `resolvedAt` is\n * the closure timestamp: set when a feedback enters a closed status, null\n * while it is open or in progress. The derivation happens at the edge (HTTP\n * handler, dashboard) — store adapters persist whatever they are given.\n */\nexport const CLOSED_FEEDBACK_STATUSES = [\"resolved\", \"wont_fix\"] as const;\n/** A terminal status — `resolved` or `wont_fix`. */\nexport type ClosedFeedbackStatus = (typeof CLOSED_FEEDBACK_STATUSES)[number];\n\n/** Non-terminal statuses — the feedback still needs attention. */\nexport const OPEN_FEEDBACK_STATUSES = [\"open\", \"in_progress\"] as const;\n/** A non-terminal status — `open` or `in_progress`. */\nexport type OpenFeedbackStatus = (typeof OPEN_FEEDBACK_STATUSES)[number];\n\n// Adding a fifth status without assigning it to exactly one bucket is a\n// compile error here.\nconst _statusBucketsCoverAll: AssertEqual<OpenFeedbackStatus | ClosedFeedbackStatus, FeedbackStatus> = true;\nvoid _statusBucketsCoverAll;\n\n/** Whether a status is terminal (`resolved` or `wont_fix`). Narrows the status type. */\nexport function isClosedStatus(status: FeedbackStatus): status is ClosedFeedbackStatus {\n return (CLOSED_FEEDBACK_STATUSES as readonly FeedbackStatus[]).includes(status);\n}\n\n/**\n * Page scope returned by `SitepingConfig.getPageScope()`.\n *\n * - `url`: concrete page identifier — usually `window.location.pathname`,\n * used as the strict scope for marker rendering.\n * - `urlPattern`: optional parameterized template (e.g. `/orders/:orderId`)\n * used by the panel's \"this type of page\" filter to group feedbacks across\n * instances of the same page kind.\n */\nexport interface PageScope {\n url: string;\n urlPattern: string | null;\n}\n\n// ---------------------------------------------------------------------------\n// Abstract Store — adapter pattern\n// ---------------------------------------------------------------------------\n\n/** Input for creating a feedback record in the store. */\nexport interface FeedbackCreateInput {\n projectName: string;\n type: FeedbackType;\n message: string;\n status: FeedbackStatus;\n url: string;\n /**\n * Optional parameterized URL template (e.g. `/orders/:orderId`) for the page\n * where the feedback was created. Allows the panel to filter feedbacks by\n * \"this type of page\" across different instances. Null when the host did not\n * provide a `getPageScope` callback or the route has no template.\n */\n urlPattern?: string | null | undefined;\n viewport: string;\n userAgent: string;\n authorName: string;\n authorEmail: string;\n clientId: string;\n annotations: AnnotationCreateInput[];\n /**\n * Base64 JPEG `data:` URL captured by the widget at submit time.\n *\n * Adapters with a configured `ScreenshotStorage` are expected to upload\n * this and persist the returned URL on `FeedbackRecord.screenshotUrl`.\n * Adapters without storage may persist the data URL inline (memory /\n * localStorage / dev) — the widget then renders it directly.\n */\n screenshotDataUrl?: string | null | undefined;\n /**\n * Where the client's annotation rect sits within the screenshot image,\n * as fractions [0, 1] of the image dimensions. Present when the widget\n * captured context around the drawn rect; null for legacy captures that\n * were cropped exactly to the rect (dashboards then render the image\n * without an overlay).\n */\n screenshotRegion?: ScreenshotRegion | null | undefined;\n /**\n * Optional console + failed-network snapshot captured by the widget when\n * `SitepingConfig.captureDiagnostics` is enabled. Stored as JSON on\n * `FeedbackRecord.diagnostics` so reviewers can replay the context.\n */\n diagnostics?: DiagnosticsSnapshot | null | undefined;\n}\n\n/** Input for a single annotation when creating a feedback. */\nexport interface AnnotationCreateInput {\n cssSelector: string;\n xpath: string;\n textSnippet: string;\n elementTag: string;\n elementId?: string | undefined;\n textPrefix: string;\n textSuffix: string;\n fingerprint: string;\n neighborText: string;\n /**\n * Semantic anchor identifier from the closest ancestor's `data-feedback-anchor`\n * attribute. When set, this is the most stable re-anchoring signal because\n * hosts deliberately place these on layout/section roots that survive DOM\n * refactors and viewport changes. Null when no semantic ancestor exists.\n */\n anchorKey?: string | null | undefined;\n xPct: number;\n yPct: number;\n wPct: number;\n hPct: number;\n scrollX: number;\n scrollY: number;\n viewportW: number;\n viewportH: number;\n devicePixelRatio: number;\n}\n\n/** Query parameters for fetching feedbacks. */\nexport interface FeedbackQuery {\n projectName: string;\n type?: FeedbackType | undefined;\n /** Exact single-status filter. For \"any of a set\" (bucket) semantics, use `statuses`. */\n status?: FeedbackStatus | undefined;\n /**\n * Filter to feedbacks whose status is any of the listed values — bucket\n * semantics used by the panel's binary tabs (e.g. \"Open\" passes\n * `[\"open\", \"in_progress\"]`). When both `status` and `statuses` are set,\n * `statuses` wins. An empty array is treated as absent (no status filter).\n */\n statuses?: readonly FeedbackStatus[] | undefined;\n search?: string | undefined;\n page?: number | undefined;\n limit?: number | undefined;\n /**\n * Filter to feedbacks created on this exact URL (path). Used by the panel's\n * \"this page\" filter and by the markers loader to keep page scopes isolated.\n */\n url?: string | undefined;\n /**\n * Filter to feedbacks created on this URL pattern (e.g. `/orders/:orderId`).\n * Used by the panel's \"this type of page\" filter to group feedbacks across\n * different concrete instances of the same template.\n */\n urlPattern?: string | undefined;\n}\n\n/**\n * Update payload for patching a feedback.\n *\n * A discriminated union encoding the closure invariant: a feedback entering\n * a closed status carries its closure timestamp, an open one carries `null`.\n * `{ status: \"resolved\", resolvedAt: null }` is a compile error instead of a\n * silent data bug. Build it from a plain `FeedbackStatus` with\n * {@link toFeedbackUpdate}.\n */\nexport type FeedbackUpdateInput =\n | { status: OpenFeedbackStatus; resolvedAt: null }\n | { status: ClosedFeedbackStatus; resolvedAt: Date };\n\n/**\n * Derive the {@link FeedbackUpdateInput} for a status change — the closure\n * timestamp is stamped for closed statuses and cleared otherwise. This is\n * the edge derivation described on {@link CLOSED_FEEDBACK_STATUSES}; store\n * adapters persist the result verbatim.\n */\nexport function toFeedbackUpdate(status: FeedbackStatus, closedAt: Date = new Date()): FeedbackUpdateInput {\n return isClosedStatus(status) ? { status, resolvedAt: closedAt } : { status, resolvedAt: null };\n}\n\n/** A persisted feedback record returned by the store. */\nexport interface FeedbackRecord {\n id: string;\n type: FeedbackType;\n message: string;\n status: FeedbackStatus;\n projectName: string;\n url: string;\n /**\n * Parameterized URL template the feedback was created on.\n * Null for legacy records or hosts without `getPageScope`.\n */\n urlPattern: string | null;\n authorName: string;\n authorEmail: string;\n viewport: string;\n userAgent: string;\n clientId: string;\n resolvedAt: Date | null;\n createdAt: Date;\n updatedAt: Date;\n annotations: AnnotationRecord[];\n /**\n * URL the widget renders as `<img src>`. Either an `https://...` from a\n * configured `ScreenshotStorage`, or a `data:image/jpeg;base64,...` URL\n * inline-persisted by adapters without storage. Null when no screenshot\n * was captured (legacy records, capture failed, or host disabled it).\n */\n screenshotUrl: string | null;\n /**\n * Annotation rect position within the screenshot image, as fractions of\n * its dimensions. Null for legacy captures cropped exactly to the rect.\n */\n screenshotRegion: ScreenshotRegion | null;\n /**\n * Console + failed-network snapshot captured at submit time. Null when\n * diagnostics weren't enabled on the widget side.\n */\n diagnostics: DiagnosticsSnapshot | null;\n}\n\n/** A persisted annotation record returned by the store. */\nexport interface AnnotationRecord {\n id: string;\n feedbackId: string;\n cssSelector: string;\n xpath: string;\n textSnippet: string;\n elementTag: string;\n elementId: string | null;\n textPrefix: string;\n textSuffix: string;\n fingerprint: string;\n neighborText: string;\n /**\n * Semantic anchor identifier from `data-feedback-anchor`. Null for legacy\n * annotations or those drawn outside any anchored region.\n */\n anchorKey: string | null;\n xPct: number;\n yPct: number;\n wPct: number;\n hPct: number;\n scrollX: number;\n scrollY: number;\n viewportW: number;\n viewportH: number;\n devicePixelRatio: number;\n createdAt: Date;\n}\n\n// ---------------------------------------------------------------------------\n// Store errors — throw these from adapter implementations\n// ---------------------------------------------------------------------------\n\n/**\n * Thrown when a record is not found during update or delete.\n *\n * Handlers translate this to HTTP 404. Adapters MUST throw this (not\n * ORM-specific errors) so the handler layer remains ORM-agnostic.\n */\nexport class StoreNotFoundError extends Error {\n readonly code = \"STORE_NOT_FOUND\" as const;\n constructor(message = \"Record not found\", options?: ErrorOptions) {\n super(message, options);\n this.name = \"StoreNotFoundError\";\n }\n}\n\n/**\n * Thrown when a unique constraint is violated (e.g. duplicate `clientId`).\n *\n * Handlers use this to return the existing record instead of failing.\n */\nexport class StoreDuplicateError extends Error {\n readonly code = \"STORE_DUPLICATE\" as const;\n constructor(message = \"Duplicate record\", options?: ErrorOptions) {\n super(message, options);\n this.name = \"StoreDuplicateError\";\n }\n}\n\n/**\n * Thrown when a store accepts a mutation but cannot persist it — e.g.\n * `localStorage` is full (QuotaExceededError). Adapters MUST throw this rather\n * than swallow the failure, so callers learn the write was lost instead of\n * seeing a phantom success.\n */\nexport class StorePersistenceError extends Error {\n readonly code = \"STORE_PERSISTENCE\" as const;\n constructor(message = \"Failed to persist store mutation\", options?: ErrorOptions) {\n super(message, options);\n this.name = \"StorePersistenceError\";\n }\n}\n\n/** Shape of any ORM error that carries a Prisma-style `code` field. */\ntype CodedError<C extends string = string> = { code: C };\n\nfunction hasErrorCode<C extends string>(error: unknown, code: C): error is CodedError<C> {\n return hasOwn(error, \"code\") && error.code === code;\n}\n\n/**\n * Type guard — works for `StoreNotFoundError` and ORM-specific equivalents\n * (e.g. Prisma P2025). Matches the stable `STORE_NOT_FOUND` code as well as\n * `instanceof`: every consumer package bundles its own copy of core (tsup\n * `noExternal`), so an adapter's instance fails `instanceof` against the\n * server's class identity.\n */\nexport function isStoreNotFound(\n error: unknown,\n): error is StoreNotFoundError | CodedError<\"STORE_NOT_FOUND\"> | CodedError<\"P2025\"> {\n if (error instanceof StoreNotFoundError) return true;\n // Backwards compat: Prisma's P2025\n return hasErrorCode(error, \"STORE_NOT_FOUND\") || hasErrorCode(error, \"P2025\");\n}\n\n/**\n * Type guard — works for `StoreDuplicateError` and ORM-specific equivalents\n * (e.g. Prisma P2002). Matches the stable `STORE_DUPLICATE` code as well as\n * `instanceof`, for the same cross-bundle reason as {@link isStoreNotFound}.\n */\nexport function isStoreDuplicate(\n error: unknown,\n): error is StoreDuplicateError | CodedError<\"STORE_DUPLICATE\"> | CodedError<\"P2002\"> {\n if (error instanceof StoreDuplicateError) return true;\n // Backwards compat: Prisma's P2002\n return hasErrorCode(error, \"STORE_DUPLICATE\") || hasErrorCode(error, \"P2002\");\n}\n\n/**\n * Type guard for `StorePersistenceError`. Matches on the stable `code` field\n * in addition to `instanceof`: every consumer package bundles its own copy of\n * core (tsup `noExternal`), so an instance thrown by one package fails an\n * `instanceof` check against another package's class identity.\n */\nexport function isStorePersistence(error: unknown): error is StorePersistenceError | CodedError<\"STORE_PERSISTENCE\"> {\n if (error instanceof StorePersistenceError) return true;\n return hasErrorCode(error, \"STORE_PERSISTENCE\");\n}\n\n// ---------------------------------------------------------------------------\n// Store helpers — shared conversion logic for adapters\n// ---------------------------------------------------------------------------\n\n/** Flatten a widget `AnnotationPayload` (nested anchor + rect) into a flat `AnnotationCreateInput`. */\nexport function flattenAnnotation(ann: AnnotationPayload): AnnotationCreateInput {\n return {\n cssSelector: ann.anchor.cssSelector,\n xpath: ann.anchor.xpath,\n textSnippet: ann.anchor.textSnippet,\n elementTag: ann.anchor.elementTag,\n elementId: ann.anchor.elementId,\n textPrefix: ann.anchor.textPrefix,\n textSuffix: ann.anchor.textSuffix,\n fingerprint: ann.anchor.fingerprint,\n neighborText: ann.anchor.neighborText,\n anchorKey: ann.anchor.anchorKey ?? null,\n xPct: ann.rect.xPct,\n yPct: ann.rect.yPct,\n wPct: ann.rect.wPct,\n hPct: ann.rect.hPct,\n scrollX: ann.scrollX,\n scrollY: ann.scrollY,\n viewportW: ann.viewportW,\n viewportH: ann.viewportH,\n devicePixelRatio: ann.devicePixelRatio,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Abstract Store — adapter pattern\n// ---------------------------------------------------------------------------\n\n/**\n * Outcome of `SitepingStore.createFeedbackIfAbsent` — the record plus whether\n * this very call inserted it.\n */\nexport interface FeedbackCreateOutcome {\n feedback: FeedbackRecord;\n /**\n * `true` when this call inserted the record, `false` when a record with the\n * same `clientId` already existed and is returned instead (a replay, or a\n * concurrent request that won the race).\n */\n created: boolean;\n}\n\n/** Paginated result returned by `SitepingStore.getFeedbacks`. */\nexport interface FeedbackPage {\n feedbacks: FeedbackRecord[];\n total: number;\n}\n\n/**\n * Abstract storage interface for Siteping.\n *\n * Any adapter (Prisma, Drizzle, raw SQL, localStorage, etc.) implements this\n * interface. The HTTP handler and widget `StoreClient` operate against\n * `SitepingStore`, decoupled from the storage backend.\n *\n * ## Error contract\n *\n * - **`updateFeedback` / `deleteFeedback`**: throw `StoreNotFoundError` when\n * the record does not exist.\n * - **`createFeedback`**: either return the existing record on duplicate\n * `clientId` (idempotent) or throw `StoreDuplicateError`. The handler\n * handles both patterns — but only a throw, or the optional\n * `createFeedbackIfAbsent`, tells it the record was not inserted by this\n * call; stores that return the existing record should implement\n * `createFeedbackIfAbsent` so creation side effects never run twice.\n * - **All mutations**: when a write is accepted but cannot be persisted\n * (e.g. storage quota), throw `StorePersistenceError` instead of reporting\n * a phantom success. Detect it with `isStorePersistence`.\n * - Other methods should not throw on empty results — return empty arrays or `null`.\n */\nexport interface SitepingStore {\n /** Create a feedback with its annotations. Idempotent on `clientId` — return existing record on duplicate, or throw `StoreDuplicateError`. Throws `StorePersistenceError` when the write cannot be persisted. */\n createFeedback(data: FeedbackCreateInput): Promise<FeedbackRecord>;\n /** Paginated query with optional filters. Returns empty array (not error) when no results. */\n getFeedbacks(query: FeedbackQuery): Promise<FeedbackPage>;\n /** Lookup by client-generated UUID. Returns `null` (not error) when not found. */\n findByClientId(clientId: string): Promise<FeedbackRecord | null>;\n /** Update status/resolvedAt. Throws `StoreNotFoundError` if `id` does not exist, `StorePersistenceError` when the write cannot be persisted. */\n updateFeedback(id: string, data: FeedbackUpdateInput): Promise<FeedbackRecord>;\n /** Delete a single record. Throws `StoreNotFoundError` if `id` does not exist, `StorePersistenceError` when the write cannot be persisted. */\n deleteFeedback(id: string): Promise<void>;\n /** Bulk delete all feedbacks for a project. No-op (not error) if none exist. Throws `StorePersistenceError` when the write cannot be persisted. */\n deleteAllFeedbacks(projectName: string): Promise<void>;\n /**\n * Optional — return `true` when the record with `id` belongs to\n * `projectName`, `false` otherwise (including when it does not exist).\n *\n * HTTP handlers use this to reject cross-project PATCH/DELETE requests.\n * Implement it whenever your store serves multiple projects. When absent,\n * handlers rely on `id` alone — so `createSitepingHandler` refuses to start\n * with a custom `access.authorize` (which may scope callers to projects)\n * over a store that lacks it.\n */\n verifyProjectOwnership?(id: string, projectName: string): Promise<boolean>;\n /**\n * Optional — `createFeedback` that reports whether this call inserted the\n * record (`created: true`) or found an existing one with the same\n * `clientId` (`created: false`). The dedup check and the insert must be\n * atomic, like `createFeedback`'s: of N concurrent calls with the same\n * `clientId`, exactly one may report `created: true`, and all must return\n * that same record. `createCollectionStore` guarantees this within one\n * store instance by serializing its mutations; stores shared across\n * processes need an atomic backend primitive (unique constraint,\n * transaction, compare-and-set).\n *\n * HTTP handlers prefer it over `createFeedback` to fire creation side\n * effects (webhooks, `onCreated`) exactly once when concurrent requests\n * race on the same `clientId`. Stores whose `createFeedback` throws\n * `StoreDuplicateError` on a duplicate already give that signal and may\n * leave it out.\n */\n createFeedbackIfAbsent?(data: FeedbackCreateInput): Promise<FeedbackCreateOutcome>;\n}\n\n/** Payload sent from the widget to the server when submitting feedback. */\nexport interface FeedbackPayload {\n projectName: string;\n type: FeedbackType;\n message: string;\n url: string;\n /**\n * Parameterized URL template (e.g. `/orders/:orderId`) supplied by\n * `SitepingConfig.getPageScope()`. Null when the host did not provide one.\n */\n urlPattern?: string | null | undefined;\n viewport: string;\n userAgent: string;\n authorName: string;\n authorEmail: string;\n annotations: AnnotationPayload[];\n /** Client-generated UUID for deduplication */\n clientId: string;\n /**\n * Base64 JPEG `data:` URL of the annotated area. Captured by the widget\n * when `enableScreenshot: true` is set in `SitepingConfig`. Null when\n * disabled or when capture failed silently.\n */\n screenshotDataUrl?: string | null | undefined;\n /**\n * Annotation rect position within the screenshot image — see\n * `ScreenshotRegion`. Null/absent when no screenshot was captured or the\n * capture predates contextual framing.\n */\n screenshotRegion?: ScreenshotRegion | null | undefined;\n /**\n * Snapshot of the last few console messages and failed network requests\n * captured at submit time when `captureDiagnostics` is enabled.\n */\n diagnostics?: DiagnosticsSnapshot | null | undefined;\n}\n\n/** Single source of truth for console diagnostic severity levels. */\nexport const CONSOLE_DIAGNOSTIC_LEVELS = [\"log\", \"info\", \"warn\", \"error\"] as const;\n/** Severity levels persisted in `ConsoleDiagnosticEntry`. */\nexport type ConsoleDiagnosticLevel = (typeof CONSOLE_DIAGNOSTIC_LEVELS)[number];\n\n/** A single console entry captured by `ConsoleBuffer`. */\nexport interface ConsoleDiagnosticEntry {\n level: ConsoleDiagnosticLevel;\n /** ISO 8601 timestamp captured at log time. */\n timestamp: string;\n /** Best-effort string representation of the original console args. */\n message: string;\n}\n\n/** A single failed network request captured by `NetworkBuffer`. */\nexport interface NetworkDiagnosticEntry {\n url: string;\n method: string;\n /** HTTP status; 0 when the request never reached the server. */\n status: number;\n /** End-to-end duration in ms. */\n durationMs: number;\n /** ISO 8601 timestamp at the moment the request was initiated. */\n timestamp: string;\n}\n\n/**\n * Diagnostics captured by the widget when `captureDiagnostics` is enabled.\n *\n * Both arrays are bounded (default: 50 console / 20 network). Adapters that\n * support diagnostics should persist this as a JSON blob alongside the\n * feedback so reviewers can replay the context that led to the report.\n */\nexport interface DiagnosticsSnapshot {\n console: ConsoleDiagnosticEntry[];\n network: NetworkDiagnosticEntry[];\n}\n\n// ---------------------------------------------------------------------------\n// Annotation — multi-selector anchoring (Hypothesis / W3C Web Annotation)\n// ---------------------------------------------------------------------------\n\n/** DOM anchoring data for re-attaching annotations to page elements. */\nexport interface AnchorData {\n /** CSS selector generated by @medv/finder — primary anchor */\n cssSelector: string;\n /** XPath — fallback 1 */\n xpath: string;\n /** First ~120 chars of element innerText — empty string if none */\n textSnippet: string;\n /** Tag name for validation (e.g. \"DIV\", \"SECTION\") */\n elementTag: string;\n /** Element id attribute if available — most stable */\n elementId?: string | undefined;\n /** ~32 chars of text before this element in document flow (disambiguation) */\n textPrefix: string;\n /** ~32 chars of text after this element in document flow (disambiguation) */\n textSuffix: string;\n /** Structural fingerprint: \"childCount:siblingIdx:attrHash\" */\n fingerprint: string;\n /** Text content of adjacent sibling elements (context) */\n neighborText: string;\n /**\n * Semantic anchor identifier from the closest ancestor's `data-feedback-anchor`\n * attribute. When set, this is the highest-priority re-anchoring signal —\n * hosts deliberately place these on layout/section roots that survive\n * viewport changes and DOM refactors.\n */\n anchorKey?: string | null | undefined;\n}\n\n/**\n * Where the client's annotation rect sits within the captured screenshot,\n * as fractions [0, 1] of the image dimensions. The widget captures context\n * around the drawn rect and records the rect's position here so dashboards\n * can re-render the annotation on top of the image. Survives downscaling\n * (fractions are resolution-independent).\n */\nexport interface ScreenshotRegion {\n /** X offset of the rect as fraction of image width — [0, 1] */\n xPct: number;\n /** Y offset of the rect as fraction of image height — [0, 1] */\n yPct: number;\n /** Rect width as fraction of image width — [0, 1] */\n wPct: number;\n /** Rect height as fraction of image height — [0, 1] */\n hPct: number;\n}\n\n/** Drawn rectangle coordinates as percentages relative to the anchor element. */\nexport interface RectData {\n /** X offset as fraction of anchor element width — must be in range [0, 1] */\n xPct: number;\n /** Y offset as fraction of anchor element height — must be in range [0, 1] */\n yPct: number;\n /** Width as fraction of anchor element width — must be in range [0, 1] */\n wPct: number;\n /** Height as fraction of anchor element height — must be in range [0, 1] */\n hPct: number;\n}\n\n/** Annotation data sent as part of a feedback submission. */\nexport interface AnnotationPayload {\n anchor: AnchorData;\n rect: RectData;\n scrollX: number;\n scrollY: number;\n viewportW: number;\n viewportH: number;\n devicePixelRatio: number;\n}\n\n// ---------------------------------------------------------------------------\n// API responses\n// ---------------------------------------------------------------------------\n\n/**\n * Feedback record as returned by the API — derived from\n * {@link FeedbackRecord}: dates are serialized to ISO strings and `clientId`\n * is omitted (server-side dedup concern, never exposed on the wire). Adding\n * a field to `FeedbackRecord` updates this type automatically.\n *\n * Note: `authorEmail` may be an empty string — HTTP adapters redact it for\n * unauthenticated requests; the full value requires a Bearer-authenticated\n * request.\n */\nexport type FeedbackResponse = Prettify<Serialized<Omit<FeedbackRecord, \"clientId\">>>;\n\n/**\n * Annotation record as returned by the API — {@link AnnotationRecord} with\n * `createdAt` serialized to an ISO string.\n */\nexport type AnnotationResponse = Prettify<Serialized<AnnotationRecord>>;\n\n/** Paginated `FeedbackResponse` shape returned by the API. */\nexport interface FeedbackResponseList {\n feedbacks: FeedbackResponse[];\n total: number;\n}\n","/** Most annotations accepted on one feedback (also enforced by the create schema). */\nexport const MAX_ANNOTATIONS_PER_FEEDBACK = 50;\n\n/** Longest refused `Origin` value written to the log (untrusted input). */\nexport const MAX_LOGGED_ORIGIN_LENGTH = 128;\n\n/** Longest invalid `allowedHeaders` entry echoed back in a configuration error. */\nexport const MAX_REPORTED_HEADER_NAME_LENGTH = 64;\n","import { MAX_ANNOTATIONS_PER_FEEDBACK } from \"./limits.js\";\n\n/**\n * `error` strings of the HTTP API. Part of the wire contract: widgets and\n * existing `adapter-prisma` consumers match on some of them.\n */\nexport const SITEPING_ERROR_MESSAGES = {\n invalidJson: \"Invalid JSON\",\n unauthorized: \"Unauthorized\",\n apiKeyRequiredForDestructive: \"apiKey required for destructive operations\",\n forbidden: \"Forbidden\",\n unsupportedMediaType: \"Content-Type must be application/json\",\n feedbackNotFound: \"Feedback not found\",\n clientIdUsedByAnotherProject: \"clientId already used by another project\",\n tooManyAnnotations: `Too many annotations (max ${MAX_ANNOTATIONS_PER_FEEDBACK})`,\n deletionAborted: \"Deletion aborted: a linked resource could not be cleaned up\",\n internalServerError: \"Internal server error\",\n} as const satisfies Record<string, string>;\n\n/**\n * Startup errors thrown by `createSitepingHandler` when its options would\n * leave a security guard inoperative. Not part of the wire contract.\n */\nexport const SITEPING_CONFIGURATION_ERROR_MESSAGES = {\n ownershipVerificationRequired:\n \"[siteping] createSitepingHandler: `access.authorize` needs a store implementing `verifyProjectOwnership`. \" +\n \"Without it, a caller authorized for one project could PATCH or DELETE another project's feedback by id. \" +\n \"Implement `verifyProjectOwnership` on the store (createCollectionStore and PrismaStore already do).\",\n invalidAllowedHeader:\n \"[siteping] allowedHeaders: every entry must be a valid HTTP header name (letters, digits and !#$%&'*+-.^_`|~, no spaces or commas). Invalid entry:\",\n} as const satisfies Record<string, string>;\n","import { SITEPING_ERROR_MESSAGES } from \"./constants/error-messages.js\";\n/** HTTP methods served by `createSitepingHandler`. */\nexport type SitepingHttpMethod = \"GET\" | \"POST\" | \"PATCH\" | \"DELETE\" | \"OPTIONS\";\n\n/** Operation a request performs, passed to `SitepingAccessControl.authorize`. */\nexport type SitepingAction = \"create\" | \"list\" | \"update\" | \"delete\" | \"deleteAll\";\n\n/** Context shared by authorization, transforms and lifecycle hooks. */\nexport interface SitepingRequestContext<Principal> {\n request: Request;\n /** Whoever `authenticate` resolved — `null` for requests the access policy leaves anonymous. */\n principal: Principal | null;\n}\n\n/** What `authorize` decides about. */\nexport interface SitepingAuthorizationContext<Principal> extends SitepingRequestContext<Principal> {\n action: SitepingAction;\n /** Project the request targets (from the body for writes, the query for reads). */\n projectName: string;\n /** Target record id for `update` / `delete`. */\n feedbackId?: string;\n}\n\n/**\n * Pluggable access policy — how the handler knows who is calling and what\n * they may do. Framework- and provider-agnostic: resolve the principal from\n * the standard `Request` (session cookie, JWT, API key, proxy header…).\n *\n * - `authenticate` returning `null` → 401.\n * - `authorize` returning `false` → 403. Defaults to allowing every\n * authenticated principal. When set, the store must implement\n * `verifyProjectOwnership`: PATCH/DELETE address records by id, and the\n * check is what keeps the authorized `projectName` bound to the record.\n * - `canReadAuthorEmail` decides whether responses include `authorEmail`\n * (reviewer PII) — every response, including the `POST` answer for a\n * fresh or replayed submission (`beforeCreate` may have replaced the\n * submitted email). Defaults to `true` for authenticated principals; a\n * throw answers `500`.\n */\nexport interface SitepingAccessControl<Principal> {\n authenticate(request: Request): Principal | null | Promise<Principal | null>;\n authorize?(context: SitepingAuthorizationContext<Principal>): boolean | Promise<boolean>;\n canReadAuthorEmail?(principal: Principal): boolean;\n}\n\n/** Outcome of authenticating one request. @internal */\nexport type AuthenticationOutcome<Principal> =\n | { ok: true; principal: Principal | null; canReadAuthorEmail: boolean }\n | { ok: false; status: 401 | 403; error: string };\n\n/**\n * Normalized policy the handler runs: authenticate once per request, then\n * authorize once the target project is known (after body/query parsing).\n * @internal\n */\nexport interface AccessGate<Principal> {\n /**\n * Whether a successful `POST` echoes `authorEmail` whatever the\n * requester's read permission. Only the legacy api-key policy sets it: its\n * public `POST` has always returned the email to the submitter.\n */\n echoesAuthorEmailOnCreate: boolean;\n authenticate(request: Request, method: SitepingHttpMethod): Promise<AuthenticationOutcome<Principal>>;\n authorize(context: SitepingAuthorizationContext<Principal>): Promise<boolean>;\n}\n\n/** Wrap a public `SitepingAccessControl` into the handler's gate. @internal */\nexport function accessGateFromControl<Principal>(access: SitepingAccessControl<Principal>): AccessGate<Principal> {\n return {\n echoesAuthorEmailOnCreate: false,\n async authenticate(request) {\n const principal = await access.authenticate(request);\n if (principal === null) return { ok: false, status: 401, error: SITEPING_ERROR_MESSAGES.unauthorized };\n return { ok: true, principal, canReadAuthorEmail: access.canReadAuthorEmail?.(principal) ?? true };\n },\n async authorize(context) {\n return access.authorize ? access.authorize(context) : true;\n },\n };\n}\n","import type { AccessGate, SitepingHttpMethod } from \"./access.js\";\nimport { SITEPING_ERROR_MESSAGES } from \"./constants/error-messages.js\";\n\n/** Options of the built-in shared-secret policy (the historical `adapter-prisma` behavior). */\nexport interface ApiKeyAccessOptions {\n /**\n * Shared secret expected as `Authorization: Bearer {apiKey}`.\n *\n * - **When set:** every request not listed in `publicEndpoints` must carry it (401 otherwise).\n * - **When not set:** the API is public, except DELETE/PATCH while\n * `requireAuthForDestructive` is on.\n */\n apiKey?: string | undefined;\n /** Methods that skip the key. Defaults to `['POST', 'OPTIONS']` when `apiKey` is set. */\n publicEndpoints?: ReadonlyArray<SitepingHttpMethod>;\n /**\n * Whether DELETE/PATCH require `apiKey`. Defaults to `true`: without `apiKey`\n * the handler refuses to start in production and answers 401 elsewhere.\n * Set `false` only behind your own auth layer — or pass `access` instead.\n */\n requireAuthForDestructive?: boolean;\n /**\n * Blank `authorEmail` in responses to requests without a valid key.\n * Defaults to `true` — reviewer emails are PII and GET must stay reachable\n * by the widget.\n */\n redactUnauthenticatedEmails?: boolean;\n}\n\nconst textEncoder = new TextEncoder();\n\n/**\n * Constant-time comparison without `node:crypto`, so the handler runs on any\n * runtime with Web APIs (Node, Bun, Deno, edge workers). A length difference\n * returns early (unavoidable leak); comparing bytes keeps multi-byte input\n * from turning into an exception.\n */\nfunction constantTimeEqual(received: string, expected: string): boolean {\n const receivedBytes = textEncoder.encode(received);\n const expectedBytes = textEncoder.encode(expected);\n if (receivedBytes.length !== expectedBytes.length) return false;\n let difference = 0;\n for (let index = 0; index < receivedBytes.length; index++) {\n difference |= (receivedBytes[index] ?? 0) ^ (expectedBytes[index] ?? 0);\n }\n return difference === 0;\n}\n\n/** `NODE_ENV === \"production\"`, tolerating runtimes without `process` (edge workers, Deno). */\nfunction isProductionEnvironment(): boolean {\n return typeof process !== \"undefined\" && process.env?.NODE_ENV === \"production\";\n}\n\n/**\n * Build the shared-secret gate. Throws at startup when it would expose an\n * unauthenticated destructive surface in production.\n * @internal\n */\nexport function createApiKeyGate({\n apiKey,\n publicEndpoints = apiKey ? [\"POST\", \"OPTIONS\"] : undefined,\n requireAuthForDestructive = true,\n redactUnauthenticatedEmails = true,\n}: ApiKeyAccessOptions): AccessGate<never> {\n // Without this guard anyone could `DELETE { deleteAll: true }` against the API.\n if (!apiKey && requireAuthForDestructive && isProductionEnvironment()) {\n throw new Error(\n \"[siteping] createSitepingHandler: apiKey is required in production. \" +\n \"Set `apiKey` to enable destructive endpoints, pass `access` to plug your own auth, or pass \" +\n \"`requireAuthForDestructive: false` if SitePing sits behind your own auth middleware.\",\n );\n }\n\n const publicMethods: ReadonlySet<SitepingHttpMethod> | null = publicEndpoints ? new Set(publicEndpoints) : null;\n\n const isBearerAuthenticated = (request: Request): boolean => {\n if (!apiKey) return false;\n const header = request.headers.get(\"Authorization\");\n return header !== null && constantTimeEqual(header, `Bearer ${apiKey}`);\n };\n\n return {\n // Legacy contract: a successful POST echoes the email back to its author.\n echoesAuthorEmailOnCreate: true,\n async authenticate(request, method) {\n const canReadAuthorEmail = !redactUnauthenticatedEmails || isBearerAuthenticated(request);\n if (!apiKey) {\n // GET/POST/OPTIONS stay open by default so the widget works in dev without config.\n if (requireAuthForDestructive && (method === \"DELETE\" || method === \"PATCH\")) {\n return { ok: false, status: 401, error: SITEPING_ERROR_MESSAGES.apiKeyRequiredForDestructive };\n }\n return { ok: true, principal: null, canReadAuthorEmail };\n }\n if (publicMethods?.has(method) || isBearerAuthenticated(request)) {\n return { ok: true, principal: null, canReadAuthorEmail };\n }\n return { ok: false, status: 401, error: SITEPING_ERROR_MESSAGES.unauthorized };\n },\n async authorize() {\n return true;\n },\n };\n}\n","/**\n * `Cache-Control` of the list endpoint, whatever the access policy. The\n * response depends on the caller's credentials — the `Authorization` key\n * (authentication, `authorEmail` redaction) or a session cookie (principal,\n * `presentFeedback`) — none of which the browser cache varies on, so a\n * cached copy could be replayed to a request that lost those credentials.\n */\nexport const LIST_CACHE_CONTROL = \"no-store\";\n\n/** `Cache-Control` of the identity endpoint — depends on the session, never cached. */\nexport const IDENTITY_CACHE_CONTROL = \"no-store\";\n\n/**\n * Methods that change data, guarded against cross-site request forgery\n * (origin allowlist + JSON content type) before any other processing.\n */\nexport const CSRF_PROTECTED_METHODS: ReadonlyArray<string> = [\"POST\", \"PATCH\", \"DELETE\"];\n\n/**\n * Only media type accepted on mutating requests. Not CORS-safelisted, so a\n * cross-origin browser must preflight it — a forged `text/plain` form or\n * `fetch` is refused instead of being parsed as JSON.\n */\nexport const JSON_MEDIA_TYPE = \"application/json\";\n\n/** Methods announced in CORS preflight responses of the feedback endpoint. */\nexport const CORS_ALLOWED_METHODS = \"GET, POST, PATCH, DELETE, OPTIONS\";\n\n/** Methods announced in CORS preflight responses of the identity endpoint. */\nexport const IDENTITY_CORS_ALLOWED_METHODS = \"GET, OPTIONS\";\n\n/**\n * Request headers the widget sends cross-origin — always allowed. The\n * `allowedHeaders` option extends this list (e.g. a custom session header\n * read by `access.authenticate`), never replaces it.\n */\nexport const CORS_DEFAULT_ALLOWED_HEADERS: ReadonlyArray<string> = [\"Content-Type\", \"Authorization\"];\n\n/**\n * Valid HTTP header field name (RFC 9110 `token`). Guards `allowedHeaders`\n * so a typo cannot inject separators into `Access-Control-Allow-Headers`.\n */\nexport const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$/;\n\n/** How long browsers may cache a preflight answer, in seconds (24 h). */\nexport const CORS_MAX_AGE_SECONDS = 86_400;\n\n/** Query parameters the list endpoint reads (everything else is ignored). */\nexport const LIST_QUERY_KEYS = [\n \"projectName\",\n \"page\",\n \"limit\",\n \"type\",\n \"status\",\n \"statuses\",\n \"search\",\n \"url\",\n \"urlPattern\",\n] as const;\n","import { SITEPING_CONFIGURATION_ERROR_MESSAGES } from \"./constants/error-messages.js\";\nimport { CORS_DEFAULT_ALLOWED_HEADERS, CORS_MAX_AGE_SECONDS, HTTP_HEADER_NAME_PATTERN } from \"./constants/http.js\";\nimport { MAX_REPORTED_HEADER_NAME_LENGTH } from \"./constants/limits.js\";\n\nexport type CorsHeaders = Readonly<Record<string, string>>;\n\n/**\n * Resolved CORS configuration of one endpoint, built once at startup by\n * `createCorsPolicy` so requests never re-validate it.\n */\nexport interface CorsPolicy {\n /** Exact-match origin allowlist; `undefined` disables CORS headers entirely. */\n readonly allowedOrigins: ReadonlyArray<string> | undefined;\n /** `Access-Control-Allow-Methods` value. */\n readonly allowedMethods: string;\n /** `Access-Control-Allow-Headers` value — defaults plus the configured extras. */\n readonly allowedHeaders: string;\n}\n\n/** CORS options an endpoint factory receives from its caller. */\nexport interface CorsPolicyOptions {\n allowedOrigins: ReadonlyArray<string> | undefined;\n /** Extra request headers to allow on top of `CORS_DEFAULT_ALLOWED_HEADERS`. */\n allowedHeaders: ReadonlyArray<string> | undefined;\n /** Methods this endpoint serves, announced to preflights. */\n allowedMethods: string;\n}\n\n/**\n * Merge `extraHeaders` into the default allowlist, case-insensitively and\n * without duplicates (the first spelling wins).\n *\n * @throws Error when an entry is not a valid HTTP header name — a comma or\n * space would otherwise smuggle arbitrary values into the response header.\n */\nexport function resolveAllowedHeaders(extraHeaders: ReadonlyArray<string> | undefined): string {\n const headersByLowercaseName = new Map<string, string>();\n for (const header of [...CORS_DEFAULT_ALLOWED_HEADERS, ...(extraHeaders ?? [])]) {\n if (typeof header !== \"string\" || !HTTP_HEADER_NAME_PATTERN.test(header)) {\n const reported = String(header).slice(0, MAX_REPORTED_HEADER_NAME_LENGTH);\n throw new Error(`${SITEPING_CONFIGURATION_ERROR_MESSAGES.invalidAllowedHeader} ${JSON.stringify(reported)}`);\n }\n const lowercaseName = header.toLowerCase();\n if (!headersByLowercaseName.has(lowercaseName)) headersByLowercaseName.set(lowercaseName, header);\n }\n return [...headersByLowercaseName.values()].join(\", \");\n}\n\n/**\n * Build the CORS policy of an endpoint. The allowed headers are a fixed,\n * validated list: the request's `Access-Control-Request-Headers` is never\n * reflected, so only headers the integrator declared can be preflighted.\n *\n * @throws Error when `allowedHeaders` contains an invalid header name.\n */\nexport function createCorsPolicy({ allowedOrigins, allowedHeaders, allowedMethods }: CorsPolicyOptions): CorsPolicy {\n return { allowedOrigins, allowedMethods, allowedHeaders: resolveAllowedHeaders(allowedHeaders) };\n}\n\n/**\n * CORS headers for `request`: only origins listed in `policy.allowedOrigins`\n * are reflected; without the option no CORS headers are emitted (no\n * permissive wildcard by default).\n */\nexport function buildCorsHeaders(request: Request, policy: CorsPolicy): CorsHeaders {\n if (!policy.allowedOrigins) return {};\n const origin = request.headers.get(\"Origin\");\n if (!origin || !policy.allowedOrigins.includes(origin)) return {};\n return {\n \"Access-Control-Allow-Origin\": origin,\n \"Access-Control-Allow-Methods\": policy.allowedMethods,\n \"Access-Control-Allow-Headers\": policy.allowedHeaders,\n \"Access-Control-Allow-Credentials\": \"true\",\n \"Access-Control-Max-Age\": String(CORS_MAX_AGE_SECONDS),\n Vary: \"Origin\",\n };\n}\n\n/** `204` answer to a CORS preflight (`OPTIONS`), with the policy's headers for allowed origins. */\nexport function preflightResponse(request: Request, policy: CorsPolicy): Response {\n return new Response(null, { status: 204, headers: buildCorsHeaders(request, policy) });\n}\n\n/** Attach CORS headers to an existing Response. */\nexport function withCors(response: Response, corsHeaders: CorsHeaders): Response {\n for (const [key, value] of Object.entries(corsHeaders)) {\n response.headers.set(key, value);\n }\n return response;\n}\n","import { SITEPING_ERROR_MESSAGES } from \"./constants/error-messages.js\";\nimport { type CorsHeaders, withCors } from \"./cors.js\";\nimport type { SitepingLogger } from \"./options.js\";\n\n/** Logs to `console.error` — the logger of handlers created without one. */\nexport const defaultLogger: SitepingLogger = {\n error(message, context) {\n console.error(message, context);\n },\n};\n\n/** Everything needed to report one unexpected failure of a request. */\nexport interface InternalErrorReport {\n logger: SitepingLogger;\n /** Optional safe hint returned to the caller instead of the generic message. */\n describeError?: ((error: unknown) => string | undefined) | undefined;\n /** Factory the failure happened in, e.g. `createSitepingHandler`. */\n source: string;\n /** What was running, e.g. `create feedback` or `authenticate request`. */\n operation: string;\n request: Request;\n corsHeaders: CorsHeaders;\n /** Extra response headers (e.g. `Cache-Control`). */\n headers?: HeadersInit | undefined;\n failure: unknown;\n}\n\n/**\n * Log an unexpected failure with its request context (operation, method,\n * path — never the query, headers or body) and the original error, then\n * answer a JSON 500 carrying the request's CORS headers. The body holds the\n * generic message unless `describeError` supplies a safe hint, so the\n * failure's own details never reach the caller.\n */\nexport function internalErrorResponse({\n logger,\n describeError,\n source,\n operation,\n request,\n corsHeaders,\n headers,\n failure,\n}: InternalErrorReport): Response {\n logger.error(`[siteping] ${source}: ${operation} failed`, {\n error: failure,\n method: request.method,\n path: new URL(request.url).pathname,\n });\n const message = describeError?.(failure) ?? SITEPING_ERROR_MESSAGES.internalServerError;\n return withCors(Response.json({ error: message }, { status: 500, ...(headers ? { headers } : {}) }), corsHeaders);\n}\n","/**\n * The one email pattern every Siteping surface validates against.\n *\n * The widget's identity modal and the HTTP handler's schema used to disagree\n * (a permissive regex on one side, an ASCII-only default on the other), so an\n * address the modal accepted — and persisted in localStorage for good — came\n * back as a 400 on every submission afterwards. One pattern, imported on both\n * sides, makes that drift impossible.\n *\n * Deliberately Unicode-aware: internationalised local parts and domains\n * (`françois@exemple.fr`, `user@münchen.de`) are real addresses and common in\n * the audience this widget serves. Structure follows the usual rules — a\n * local part of 1 to 64 characters with no leading, trailing or doubled dot,\n * dot-separated domain labels of at most 63 characters that neither start nor\n * end with a hyphen, and a final label of at least two characters.\n */\nexport const EMAIL_PATTERN =\n /^(?!\\.)(?!.*\\.\\.)[\\p{L}\\p{N}!#$%&'*+/=?^_`{|}~.-]{0,63}[\\p{L}\\p{N}!#$%&'*+/=?^_`{|}~-]@(?:[\\p{L}\\p{N}](?:[\\p{L}\\p{N}-]{0,61}[\\p{L}\\p{N}])?\\.)+[\\p{L}\\p{N}-]{2,63}$/u;\n\n/** Whether `value` is an email address Siteping accepts — see {@link EMAIL_PATTERN}. */\nexport function isValidEmail(value: string): boolean {\n return EMAIL_PATTERN.test(value);\n}\n","/**\n * General-purpose TypeScript utility types used across `@beezping/*`.\n *\n * These are kept dependency-free and re-exported from the package entry\n * so adapters and integrators can rely on the same primitives the core\n * uses internally.\n */\n\n/**\n * Force TypeScript to expand a computed type into a flat object literal in\n * tooltips and error messages. Purely cosmetic — same structural type, just\n * easier to read.\n *\n * @example\n * type Raw = Omit<FeedbackRecord, \"annotations\"> & { annotations: number };\n * type Pretty = Prettify<Raw>; // displayed as a flat object\n */\nexport type Prettify<T> = { [K in keyof T]: T[K] } & {};\n\n/**\n * Returns `Y` when `A` is exactly assignable to `B` and vice-versa,\n * otherwise `N`. Powers compile-time equality assertions.\n */\nexport type IfEquals<A, B, Y = true, N = false> =\n (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? Y : N;\n\n/**\n * Compile-time exact-type guard — resolves to `true` when `Actual` and\n * `Expected` are identical, `never` otherwise. Assign the result to a\n * `const _lock: AssertEqual<A, B> = true;` so any drift becomes a compile\n * error at the declaration site.\n */\nexport type AssertEqual<Actual, Expected> = IfEquals<Actual, Expected, true, never>;\n\n/**\n * JSON-serialized shape of `T` — the wire form produced by `Response.json()`\n * / `JSON.stringify`: `Date` becomes ISO `string` (nullability preserved),\n * arrays are serialized element-wise, everything else is untouched.\n *\n * Used to derive the `*Response` API types from the `*Record` store types so\n * the two can never drift: add a field to `FeedbackRecord` and\n * `FeedbackResponse` follows automatically.\n */\nexport type Serialized<T> = {\n [K in keyof T]: T[K] extends Date\n ? string\n : T[K] extends Date | null\n ? string | null\n : T[K] extends (infer U)[]\n ? Serialized<U>[]\n : T[K];\n};\n\n/**\n * Type guard that narrows `value` to a non-null `Record<PropertyKey, unknown>`.\n * Useful when validating arbitrary inputs before reading fields.\n */\nexport function isRecord(value: unknown): value is Record<PropertyKey, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n\n/**\n * Returns true when `value` is an object that exposes the requested key.\n * Type-narrows `value` so the property can be accessed without further\n * casting — a strictly typed replacement for `\"k\" in obj`.\n *\n * Named after the standardised `Object.hasOwn` helper rather than the\n * legacy `Object.prototype.hasOwnProperty`, which the linter forbids\n * shadowing.\n */\nexport function hasOwn<K extends PropertyKey>(value: unknown, key: K): value is Record<K, unknown> {\n return isRecord(value) && key in value;\n}\n","import { type AssertEqual, hasOwn, type Prettify, type Serialized } from \"./type-utils.js\";\n\n// ---------------------------------------------------------------------------\n// Config\n// ---------------------------------------------------------------------------\n\n/** FAB anchor — bottom-corner placement supported by the widget. */\nexport type SitepingPosition = \"bottom-right\" | \"bottom-left\";\n\n/** Visual theme — `auto` resolves to `light` or `dark` via system preference. */\nexport type SitepingTheme = \"light\" | \"dark\" | \"auto\";\n\n/** Built-in UI locales shipped with the widget. */\nexport const BUILTIN_LOCALES = [\"en\", \"fr\", \"de\", \"es\", \"it\", \"pt\", \"ru\"] as const;\nexport type BuiltinLocale = (typeof BUILTIN_LOCALES)[number];\n\n/**\n * Locale identifier accepted by the widget. Built-in locales are kept as\n * literal strings so editors auto-complete them, but arbitrary BCP-47 tags\n * are also accepted (custom dictionaries registered via `registerLocale`).\n */\nexport type SitepingLocale = BuiltinLocale | (string & {});\n\n/**\n * Reasons reported through `SitepingConfig.onSkip` — production environment,\n * mobile viewport, or server-side rendering (no `window`/`document`).\n */\nexport type SitepingSkipReason = \"production\" | \"mobile\" | \"ssr\";\n\n/** Per-channel + per-buffer-size diagnostics configuration. */\nexport interface DiagnosticsCaptureOptions {\n console?: boolean | undefined;\n network?: boolean | undefined;\n maxConsoleEntries?: number | undefined;\n maxNetworkEntries?: number | undefined;\n}\n\n/** Identity payload supplied by the host application — bypasses the modal. */\nexport interface SitepingIdentity {\n name: string;\n email: string;\n}\n\n/** Deep-link configuration — controls how a feedback id is read from the URL. */\nexport interface SitepingDeepLinkOptions {\n /** Query parameter name carrying the feedback id. Defaults to `\"siteping\"`. */\n param?: string | undefined;\n}\n\n/**\n * Extra request headers for HTTP mode — a static map, or a factory (sync or\n * async) invoked once per request to produce fresh values (e.g. a short-lived\n * session token).\n */\nexport type SitepingHeadersOption =\n | Record<string, string>\n | (() => Record<string, string> | Promise<Record<string, string>>);\n\n/**\n * Cookie policy for HTTP-mode requests — forwarded verbatim as the\n * `credentials` option of every `fetch` the widget (or the dashboard's\n * endpoint source) makes. Mirrors the DOM `RequestCredentials` union,\n * declared here so core stays free of DOM lib types.\n *\n * - `\"same-origin\"` (default): cookies only when the endpoint shares the\n * page's origin — the browser's own default.\n * - `\"include\"`: also send cookies to a cross-origin endpoint. Required when a\n * server on another origin authenticates with a session cookie; the server\n * must answer with credentialed CORS (the page's exact origin plus\n * `Access-Control-Allow-Credentials: true`, e.g. `@beezping/server`'s\n * `allowedOrigins`).\n * - `\"omit\"`: never send cookies, even same-origin.\n */\nexport type SitepingRequestCredentials = \"omit\" | \"same-origin\" | \"include\";\n\n/** Every accepted {@link SitepingRequestCredentials} value — runtime guard source for untyped (script-tag) consumers. */\nexport const REQUEST_CREDENTIALS_MODES = [\n \"omit\",\n \"same-origin\",\n \"include\",\n] as const satisfies readonly SitepingRequestCredentials[];\n\n/**\n * Credentials mode used when none is configured — the browser's own `fetch`\n * default, so leaving the option unset never changes cookie behavior (and\n * never opts a cross-origin endpoint into cookie-carrying, CSRF-prone requests).\n */\nexport const DEFAULT_REQUEST_CREDENTIALS = \"same-origin\" satisfies SitepingRequestCredentials;\n\n/**\n * Narrow an untyped config value to a {@link SitepingRequestCredentials} mode.\n *\n * @param value - Raw `credentials` option (may come from an untyped script-tag config).\n * @returns `true` when `value` is one of {@link REQUEST_CREDENTIALS_MODES}.\n */\nexport function isRequestCredentials(value: unknown): value is SitepingRequestCredentials {\n return typeof value === \"string\" && (REQUEST_CREDENTIALS_MODES as readonly string[]).includes(value);\n}\n\n/**\n * Actionable description of a rejected `credentials` option — shared by the\n * widget init guard and the dashboard's endpoint source so both report the\n * same wording.\n *\n * @param value - The rejected raw value (a config value, never user content).\n * @returns e.g. `invalid \\`credentials\\` \"all\". Expected one of \"omit\", \"same-origin\", \"include\".`\n */\nexport function describeInvalidRequestCredentials(value: unknown): string {\n const accepted = REQUEST_CREDENTIALS_MODES.map((mode) => `\"${mode}\"`).join(\", \");\n return `invalid \\`credentials\\` ${String(JSON.stringify(value))}. Expected one of ${accepted}.`;\n}\n\n/**\n * Options shared by both widget modes (HTTP and direct store).\n *\n * Do not use this type directly — use {@link SitepingConfig}, the\n * discriminated union that adds the mode-specific fields.\n */\nexport interface SitepingBaseConfig {\n /** Required — project identifier used to scope feedbacks */\n projectName: string;\n /** FAB position — defaults to 'bottom-right' */\n position?: SitepingPosition | undefined;\n /**\n * Show the \"toggle markers visibility\" item in the FAB radial menu.\n * Defaults to `true` (current behavior). Set to `false` to hide that\n * item entirely — useful for hosts that always want markers visible\n * (e.g. dedicated review tools) or that find the eye icon redundant\n * when no marker is on screen.\n *\n * Hiding the item also removes its keyboard navigation slot — the\n * remaining two items still respond to ArrowUp/ArrowDown/Home/End.\n * The marker-visibility state itself is unaffected; markers stay\n * visible (the previous default state) and `annotations:toggle` is\n * simply never emitted from the FAB.\n */\n showAnnotationsToggle?: boolean | undefined;\n /** Accent color for the widget UI — defaults to '#0066ff' */\n accentColor?: string | undefined;\n /**\n * Render the widget even when it would normally be skipped — this bypasses\n * BOTH the production-environment guard AND the mobile-viewport guard.\n * It does NOT bypass the SSR guard: without `window`/`document` the widget\n * never renders and `onSkip(\"ssr\")` fires instead.\n * Defaults to false. Use it for dedicated review tools, staging environments,\n * or responsive testing where you always want the widget present.\n */\n forceShow?: boolean | undefined;\n /**\n * Minimum viewport width (px) at or above which the widget renders. Below it,\n * the widget is skipped and `onSkip(\"mobile\")` fires. Defaults to `768`.\n *\n * Set lower (e.g. `0`) to allow narrow/mobile viewports, or use `forceShow`\n * to bypass the viewport check entirely.\n */\n minViewportWidth?: number | undefined;\n /** Enable debug logging of lifecycle events — defaults to false */\n debug?: boolean | undefined;\n /** Color theme — defaults to 'light' */\n theme?: SitepingTheme | undefined;\n /** UI locale — defaults to 'en'. Built-in: en, fr, de, es, it, pt (Brazilian), ru. Any other string falls back to English. */\n locale?: SitepingLocale | undefined;\n /**\n * Returns the current page scope for annotations and panel filtering.\n * Called on initial markers load and on `instance.refresh()`.\n *\n * Default: `{ url: window.location.pathname, urlPattern: null }` — annotations\n * are scoped strictly to the current pathname.\n *\n * Apps with parameterized routes (e.g. React Router) should return both the\n * concrete URL and the route template (e.g. `/orders/:orderId`) so the panel\n * can offer a \"this type of page\" filter that groups feedbacks by template.\n */\n getPageScope?: (() => PageScope) | undefined;\n /**\n * When true (default), the widget filters initial markers and panel results\n * by `feedback.url === scope.url`, so annotations created on one page never\n * leak to other pages — even if their CSS selector accidentally matches.\n * Set to `false` to revert to the legacy project-wide behavior.\n */\n scopeAnnotationsByUrl?: boolean | undefined;\n /**\n * Capture a JPEG screenshot of the annotated area on submit. Defaults to\n * `false` — opt-in because:\n *\n * - it adds runtime weight (~60 KB gzip dynamic chunk for html2canvas-pro,\n * loaded only on first capture),\n * - it embeds page content in the feedback (privacy/GDPR consideration —\n * inform end users in your widget host UI when enabling).\n *\n * `html2canvas-pro` ships as a regular dependency of `@beezping/widget` so the\n * dynamic import always resolves; you don't need to install anything extra.\n *\n * **Masking sensitive elements:** add `data-siteping-ignore=\"true\"` to any\n * element you do NOT want captured (password fields, credit-card forms,\n * API tokens shown in the UI, etc.). The capture predicate skips matching\n * elements *and their descendants*. Do this BEFORE turning on screenshots\n * in production — once a feedback is saved, the screenshot is in your DB\n * (or object storage) regardless of what was on the page.\n */\n enableScreenshot?: boolean | undefined;\n /**\n * Enable right-click (`contextmenu`) to instantly open the comment composer\n * at the cursor location. When enabled, a document-level listener intercepts\n * right-clicks, prevents the browser's native context menu, and enters the\n * annotation flow anchored to the element under the cursor. Defaults to\n * `false` — the browser's native context menu is never hijacked unless the\n * host explicitly opts in.\n *\n * Keyboard-triggered context menus (≣ Menu key, Shift+F10) always get the\n * native menu; only mouse right-click and touch/pen long-press open the\n * composer.\n *\n * **Modifier-key escape hatch:** holding Shift, Ctrl, Alt, or Meta while\n * right-clicking always falls through to the native context menu, giving\n * users (and devtools) an escape hatch regardless of this setting.\n *\n * Right-clicks on SitePing's own UI (FAB, panel, markers, popup) are\n * ignored — the native menu is shown as expected.\n *\n * Note: on Android, `contextmenu` fires on long-press. The widget already\n * hides below `minViewportWidth` (default 768 px), but tablets above that\n * threshold will trigger this flow on long-press.\n */\n enableRightClickComment?: boolean | undefined;\n /**\n * Capture the last few `console.*` calls and failed network requests\n * (HTTP >= 400 or network error) at the moment a feedback is submitted.\n *\n * Lets reviewers replay the technical context that led to the report —\n * stack traces, 500 responses, dead third-party scripts. Great for the\n * \"the page just doesn't work\" feedback that contains zero detail.\n *\n * - `true` — capture with defaults (50 console / 20 network entries).\n * - `false` (default) — no capture, no monkey-patching.\n * - object — per-channel toggles + custom buffer sizes.\n *\n * **Privacy considerations:** console messages may contain anything the\n * host page logs, including user data. Failed network requests record the\n * URL (with query string) but never the response body. Inform end users\n * before enabling in environments where they might log sensitive values.\n */\n captureDiagnostics?: boolean | DiagnosticsCaptureOptions | undefined;\n /** Called when the widget is skipped (production mode, mobile viewport, SSR — no DOM) */\n onSkip?: (reason: SitepingSkipReason) => void;\n /**\n * Auto-focus a specific annotation when its ID appears in the URL query\n * string. Lets hosts deeplink directly into a feedback from external\n * systems (Zammad tickets, Slack notifications, dashboard rows).\n *\n * When enabled, the widget reads the configured query parameter from\n * `window.location.search` right after the initial markers load. If the\n * value matches a visible feedback ID, the widget scrolls the annotation\n * into view, pins its highlight, and pulses the marker — the same visual\n * affordance a marker click produces.\n *\n * - `false` / `undefined` (default): no URL parsing. Existing behavior\n * unchanged, no host URL inspection.\n * - `true`: enabled with default query parameter name `siteping`.\n * - object: enabled with a custom parameter name. Use this to avoid\n * clashes with host-app query keys.\n *\n * Only the initial load triggers focus. Subsequent URL changes (SPA\n * navigation, `history.pushState`, hash updates) are ignored —\n * deliberate, to avoid surprising re-scrolls during normal browsing.\n * Hosts that need re-focus on route change can call\n * `instance.focusFeedback(id)` explicitly.\n */\n deepLink?: boolean | SitepingDeepLinkOptions | undefined;\n /**\n * Automatically re-fetch feedbacks when the page changes during client-side\n * (SPA) navigation. Enabled by default.\n *\n * The widget is normally mounted once (singleton) inside a persistent layout\n * — e.g. a Next.js App Router `layout.tsx`, which does NOT remount on\n * client-side navigation. Without this, init runs a single time and both the\n * panel list and the page markers stay frozen on the page where the widget\n * first mounted. With it on, the widget patches the History API\n * (`pushState`/`replaceState`, which SPA routers call instead of triggering\n * `popstate`) and listens for `popstate`/`hashchange`, then re-fetches when\n * the scope key (`getPageScope().url` + template) actually changes.\n *\n * This re-fetches data only — it deliberately does NOT re-focus or re-scroll\n * to an annotation (deep-link focus stays initial-load only; see `deepLink`),\n * so normal browsing is never interrupted by a surprise scroll.\n *\n * - `true` (default) — watch navigation and re-fetch on route change.\n * - `false` — never touch the History API; hosts drive updates manually via\n * `instance.refresh()`.\n */\n watchNavigation?: boolean | undefined;\n /**\n * Pre-fill author identity from the host application — typically the\n * currently signed-in user. When set, the widget uses these values\n * directly and never shows the identity modal, even on first feedback.\n *\n * Use case: SSO-integrated apps where the end user is already\n * authenticated by the host. Avoids the awkward \"enter your name and\n * email\" prompt for users the host already knows.\n *\n * When unset (default), the widget falls back to localStorage and shows\n * the modal on first feedback as before — existing behavior unchanged.\n *\n * Note: `config.identity` is **not** persisted to localStorage. It is\n * read at widget init time, not on every render. Hosts that need live\n * identity updates after sign-in/sign-out should currently remount the\n * widget (e.g. via a React `key` on the wrapping component). See\n * https://github.com/NeosiaNexus/SitePing/issues/85 for tracking a\n * future enhancement that propagates identity updates without a remount.\n */\n identity?: SitepingIdentity | undefined;\n\n // Events\n /** Called when the feedback panel is opened. */\n onOpen?: (() => void) | undefined;\n /** Called when the feedback panel is closed. */\n onClose?: (() => void) | undefined;\n /** Called after a feedback is successfully submitted. */\n onFeedbackSent?: ((feedback: FeedbackResponse) => void) | undefined;\n /**\n * Called when a feedback API call fails.\n *\n * The widget always emits a `SitepingError` (or a subclass:\n * `SitepingNetworkError`, `SitepingValidationError`, `SitepingAuthError`)\n * for HTTP-mode failures — host apps can `instanceof` to drive retry\n * logic, or read `error.code` (`\"NETWORK\" | \"VALIDATION\" | \"AUTH\" |\n * \"SERVER\"`) and `error.retryable`. The type is widened to `Error` so\n * direct-store callers can still surface raw errors without breaking the\n * contract.\n */\n onError?: ((error: Error) => void) | undefined;\n /** Called when the user starts drawing an annotation. */\n onAnnotationStart?: (() => void) | undefined;\n /** Called when the user finishes drawing an annotation. */\n onAnnotationEnd?: (() => void) | undefined;\n}\n\n/**\n * HTTP mode — the widget talks to a server endpoint backed by a store\n * adapter (e.g. `@beezping/adapter-prisma` request handlers).\n */\nexport interface SitepingHttpConfig extends SitepingBaseConfig {\n /** HTTP endpoint that receives feedbacks (e.g. '/api/siteping'). */\n endpoint: string;\n /**\n * Convenience auth for HTTP mode — sent as `Authorization: Bearer <apiKey>`\n * on every request to `endpoint`.\n *\n * **WARNING: the widget runs in every visitor's browser, so a static key\n * configured here is public** — anyone can read it from your page source\n * and replay it against your API. Only use `apiKey` for internal tools\n * already behind your own login. On public sites, prefer `headers` with a\n * per-request factory returning a short-lived session token.\n */\n apiKey?: string | undefined;\n /**\n * Extra headers for every HTTP-mode request — a static map, or a factory\n * (sync or async) called once per request (e.g. to fetch a fresh session\n * token). Merged over the widget's generated headers, so an explicit\n * `Authorization` entry overrides `apiKey`. A throwing/rejecting factory\n * fails the request like a network error.\n */\n headers?: SitepingHeadersOption | undefined;\n /**\n * Cookie policy for every HTTP-mode request (submit, list, update, delete\n * and the offline retry-queue replay). Defaults to `\"same-origin\"` — the\n * browser default, so existing setups are unchanged.\n *\n * Set `\"include\"` when `endpoint` lives on **another origin** and the\n * server authenticates with a session cookie (e.g. a custom\n * `access.authenticate` in `@beezping/server`): without it the browser\n * never attaches the cookie and every request is rejected as\n * unauthenticated. The server must allow the page's origin explicitly with\n * credentialed CORS (`allowedOrigins`) — a wildcard origin never works\n * with cookies. Only opt in for origins you trust: cookie-authenticated\n * cross-origin requests rely on the server's CSRF defenses (strict origin\n * allowlist, `SameSite` cookies).\n *\n * An unknown value (untyped script-tag config) is rejected at init: the\n * widget logs an error and does not load.\n */\n credentials?: SitepingRequestCredentials | undefined;\n /** Not available in HTTP mode — use either `endpoint` or `store`, never both. */\n store?: never;\n}\n\n/**\n * Store mode — the widget talks to a `SitepingStore` directly in the\n * browser, no server needed (demos, prototypes, localStorage persistence).\n */\nexport interface SitepingStoreConfig extends SitepingBaseConfig {\n /** Direct store for client-side mode. Bypasses HTTP entirely. */\n store: SitepingStore;\n /** Not available in store mode — use either `endpoint` or `store`, never both. */\n endpoint?: never;\n /** HTTP-mode only — meaningless without an `endpoint`. */\n apiKey?: never;\n /** HTTP-mode only — meaningless without an `endpoint`. */\n headers?: never;\n /** HTTP-mode only — meaningless without an `endpoint`. */\n credentials?: never;\n}\n\n/**\n * Configuration options for the Siteping widget.\n *\n * A discriminated union over the two transport modes: pass `endpoint`\n * (HTTP mode, optionally with `apiKey`/`headers`/`credentials`) **or** `store` (direct\n * client-side mode) — never both, never neither. Invalid combinations are\n * compile errors instead of runtime warnings.\n */\nexport type SitepingConfig = SitepingHttpConfig | SitepingStoreConfig;\n\n/** Instance returned by initSiteping() with lifecycle methods. */\nexport interface SitepingInstance {\n /** Remove the widget from the DOM and clean up all listeners. */\n destroy: () => void;\n /** Open the panel programmatically */\n open: () => void;\n /** Close the panel */\n close: () => void;\n /** Reload feedbacks from server */\n refresh: () => void;\n /**\n * Scroll the matching annotation into view, pin its highlight, and\n * pulse its marker. Returns `true` when a visible feedback matched the\n * given ID, `false` otherwise (unknown ID, feedback on another URL when\n * `scopeAnnotationsByUrl` filtered it out, or markers not yet loaded).\n *\n * Counterpart to the `deepLink` config option for hosts that prefer to\n * drive focus from JS (e.g., a notification click handler) instead of a\n * URL query parameter.\n */\n focusFeedback: (feedbackId: string) => boolean;\n /** Subscribe to a public widget event */\n on: <K extends keyof SitepingPublicEvents>(event: K, listener: SitepingPublicEventListener<K>) => SitepingUnsubscribe;\n /** Unsubscribe from a public widget event */\n off: <K extends keyof SitepingPublicEvents>(event: K, listener: SitepingPublicEventListener<K>) => void;\n}\n\n/** Listener signature for a single `SitepingPublicEvents` key. */\nexport type SitepingPublicEventListener<K extends keyof SitepingPublicEvents> = (\n ...args: SitepingPublicEvents[K]\n) => void;\n\n/** Disposer returned by `SitepingInstance.on` — call once to detach the listener. */\nexport type SitepingUnsubscribe = () => void;\n\n/** Events exposed to consumers via SitepingInstance.on / .off */\nexport interface SitepingPublicEvents {\n \"feedback:sent\": [FeedbackResponse];\n \"feedback:deleted\": [FeedbackResponse[\"id\"]];\n /**\n * A feedback API call failed. Same payload contract as\n * `SitepingConfig.onError` — a `SitepingError` subclass in HTTP mode,\n * possibly a raw `Error` in store mode.\n */\n \"feedback:error\": [Error];\n \"panel:open\": [];\n \"panel:close\": [];\n /** The user started drawing an annotation. */\n \"annotation:start\": [];\n /** The user finished drawing an annotation. */\n \"annotation:end\": [];\n}\n\n// ---------------------------------------------------------------------------\n// Feedback\n// ---------------------------------------------------------------------------\n\n/** Single source of truth for feedback types — used by both TS types and Zod schemas. */\nexport const FEEDBACK_TYPES = [\"question\", \"change\", \"bug\", \"other\"] as const;\nexport type FeedbackType = (typeof FEEDBACK_TYPES)[number];\n\n/** Single source of truth for feedback statuses. */\nexport const FEEDBACK_STATUSES = [\"open\", \"in_progress\", \"resolved\", \"wont_fix\"] as const;\nexport type FeedbackStatus = (typeof FEEDBACK_STATUSES)[number];\n\n/**\n * Terminal statuses — the feedback needs no further action. `resolvedAt` is\n * the closure timestamp: set when a feedback enters a closed status, null\n * while it is open or in progress. The derivation happens at the edge (HTTP\n * handler, dashboard) — store adapters persist whatever they are given.\n */\nexport const CLOSED_FEEDBACK_STATUSES = [\"resolved\", \"wont_fix\"] as const;\n/** A terminal status — `resolved` or `wont_fix`. */\nexport type ClosedFeedbackStatus = (typeof CLOSED_FEEDBACK_STATUSES)[number];\n\n/** Non-terminal statuses — the feedback still needs attention. */\nexport const OPEN_FEEDBACK_STATUSES = [\"open\", \"in_progress\"] as const;\n/** A non-terminal status — `open` or `in_progress`. */\nexport type OpenFeedbackStatus = (typeof OPEN_FEEDBACK_STATUSES)[number];\n\n// Adding a fifth status without assigning it to exactly one bucket is a\n// compile error here.\nconst _statusBucketsCoverAll: AssertEqual<OpenFeedbackStatus | ClosedFeedbackStatus, FeedbackStatus> = true;\nvoid _statusBucketsCoverAll;\n\n/** Whether a status is terminal (`resolved` or `wont_fix`). Narrows the status type. */\nexport function isClosedStatus(status: FeedbackStatus): status is ClosedFeedbackStatus {\n return (CLOSED_FEEDBACK_STATUSES as readonly FeedbackStatus[]).includes(status);\n}\n\n/**\n * Page scope returned by `SitepingConfig.getPageScope()`.\n *\n * - `url`: concrete page identifier — usually `window.location.pathname`,\n * used as the strict scope for marker rendering.\n * - `urlPattern`: optional parameterized template (e.g. `/orders/:orderId`)\n * used by the panel's \"this type of page\" filter to group feedbacks across\n * instances of the same page kind.\n */\nexport interface PageScope {\n url: string;\n urlPattern: string | null;\n}\n\n// ---------------------------------------------------------------------------\n// Abstract Store — adapter pattern\n// ---------------------------------------------------------------------------\n\n/** Input for creating a feedback record in the store. */\nexport interface FeedbackCreateInput {\n projectName: string;\n type: FeedbackType;\n message: string;\n status: FeedbackStatus;\n url: string;\n /**\n * Optional parameterized URL template (e.g. `/orders/:orderId`) for the page\n * where the feedback was created. Allows the panel to filter feedbacks by\n * \"this type of page\" across different instances. Null when the host did not\n * provide a `getPageScope` callback or the route has no template.\n */\n urlPattern?: string | null | undefined;\n viewport: string;\n userAgent: string;\n authorName: string;\n authorEmail: string;\n clientId: string;\n annotations: AnnotationCreateInput[];\n /**\n * Base64 JPEG `data:` URL captured by the widget at submit time.\n *\n * Adapters with a configured `ScreenshotStorage` are expected to upload\n * this and persist the returned URL on `FeedbackRecord.screenshotUrl`.\n * Adapters without storage may persist the data URL inline (memory /\n * localStorage / dev) — the widget then renders it directly.\n */\n screenshotDataUrl?: string | null | undefined;\n /**\n * Where the client's annotation rect sits within the screenshot image,\n * as fractions [0, 1] of the image dimensions. Present when the widget\n * captured context around the drawn rect; null for legacy captures that\n * were cropped exactly to the rect (dashboards then render the image\n * without an overlay).\n */\n screenshotRegion?: ScreenshotRegion | null | undefined;\n /**\n * Optional console + failed-network snapshot captured by the widget when\n * `SitepingConfig.captureDiagnostics` is enabled. Stored as JSON on\n * `FeedbackRecord.diagnostics` so reviewers can replay the context.\n */\n diagnostics?: DiagnosticsSnapshot | null | undefined;\n}\n\n/** Input for a single annotation when creating a feedback. */\nexport interface AnnotationCreateInput {\n cssSelector: string;\n xpath: string;\n textSnippet: string;\n elementTag: string;\n elementId?: string | undefined;\n textPrefix: string;\n textSuffix: string;\n fingerprint: string;\n neighborText: string;\n /**\n * Semantic anchor identifier from the closest ancestor's `data-feedback-anchor`\n * attribute. When set, this is the most stable re-anchoring signal because\n * hosts deliberately place these on layout/section roots that survive DOM\n * refactors and viewport changes. Null when no semantic ancestor exists.\n */\n anchorKey?: string | null | undefined;\n xPct: number;\n yPct: number;\n wPct: number;\n hPct: number;\n scrollX: number;\n scrollY: number;\n viewportW: number;\n viewportH: number;\n devicePixelRatio: number;\n}\n\n/** Query parameters for fetching feedbacks. */\nexport interface FeedbackQuery {\n projectName: string;\n type?: FeedbackType | undefined;\n /** Exact single-status filter. For \"any of a set\" (bucket) semantics, use `statuses`. */\n status?: FeedbackStatus | undefined;\n /**\n * Filter to feedbacks whose status is any of the listed values — bucket\n * semantics used by the panel's binary tabs (e.g. \"Open\" passes\n * `[\"open\", \"in_progress\"]`). When both `status` and `statuses` are set,\n * `statuses` wins. An empty array is treated as absent (no status filter).\n */\n statuses?: readonly FeedbackStatus[] | undefined;\n search?: string | undefined;\n page?: number | undefined;\n limit?: number | undefined;\n /**\n * Filter to feedbacks created on this exact URL (path). Used by the panel's\n * \"this page\" filter and by the markers loader to keep page scopes isolated.\n */\n url?: string | undefined;\n /**\n * Filter to feedbacks created on this URL pattern (e.g. `/orders/:orderId`).\n * Used by the panel's \"this type of page\" filter to group feedbacks across\n * different concrete instances of the same template.\n */\n urlPattern?: string | undefined;\n}\n\n/**\n * Update payload for patching a feedback.\n *\n * A discriminated union encoding the closure invariant: a feedback entering\n * a closed status carries its closure timestamp, an open one carries `null`.\n * `{ status: \"resolved\", resolvedAt: null }` is a compile error instead of a\n * silent data bug. Build it from a plain `FeedbackStatus` with\n * {@link toFeedbackUpdate}.\n */\nexport type FeedbackUpdateInput =\n | { status: OpenFeedbackStatus; resolvedAt: null }\n | { status: ClosedFeedbackStatus; resolvedAt: Date };\n\n/**\n * Derive the {@link FeedbackUpdateInput} for a status change — the closure\n * timestamp is stamped for closed statuses and cleared otherwise. This is\n * the edge derivation described on {@link CLOSED_FEEDBACK_STATUSES}; store\n * adapters persist the result verbatim.\n */\nexport function toFeedbackUpdate(status: FeedbackStatus, closedAt: Date = new Date()): FeedbackUpdateInput {\n return isClosedStatus(status) ? { status, resolvedAt: closedAt } : { status, resolvedAt: null };\n}\n\n/** A persisted feedback record returned by the store. */\nexport interface FeedbackRecord {\n id: string;\n type: FeedbackType;\n message: string;\n status: FeedbackStatus;\n projectName: string;\n url: string;\n /**\n * Parameterized URL template the feedback was created on.\n * Null for legacy records or hosts without `getPageScope`.\n */\n urlPattern: string | null;\n authorName: string;\n authorEmail: string;\n viewport: string;\n userAgent: string;\n clientId: string;\n resolvedAt: Date | null;\n createdAt: Date;\n updatedAt: Date;\n annotations: AnnotationRecord[];\n /**\n * URL the widget renders as `<img src>`. Either an `https://...` from a\n * configured `ScreenshotStorage`, or a `data:image/jpeg;base64,...` URL\n * inline-persisted by adapters without storage. Null when no screenshot\n * was captured (legacy records, capture failed, or host disabled it).\n */\n screenshotUrl: string | null;\n /**\n * Annotation rect position within the screenshot image, as fractions of\n * its dimensions. Null for legacy captures cropped exactly to the rect.\n */\n screenshotRegion: ScreenshotRegion | null;\n /**\n * Console + failed-network snapshot captured at submit time. Null when\n * diagnostics weren't enabled on the widget side.\n */\n diagnostics: DiagnosticsSnapshot | null;\n}\n\n/** A persisted annotation record returned by the store. */\nexport interface AnnotationRecord {\n id: string;\n feedbackId: string;\n cssSelector: string;\n xpath: string;\n textSnippet: string;\n elementTag: string;\n elementId: string | null;\n textPrefix: string;\n textSuffix: string;\n fingerprint: string;\n neighborText: string;\n /**\n * Semantic anchor identifier from `data-feedback-anchor`. Null for legacy\n * annotations or those drawn outside any anchored region.\n */\n anchorKey: string | null;\n xPct: number;\n yPct: number;\n wPct: number;\n hPct: number;\n scrollX: number;\n scrollY: number;\n viewportW: number;\n viewportH: number;\n devicePixelRatio: number;\n createdAt: Date;\n}\n\n// ---------------------------------------------------------------------------\n// Store errors — throw these from adapter implementations\n// ---------------------------------------------------------------------------\n\n/**\n * Thrown when a record is not found during update or delete.\n *\n * Handlers translate this to HTTP 404. Adapters MUST throw this (not\n * ORM-specific errors) so the handler layer remains ORM-agnostic.\n */\nexport class StoreNotFoundError extends Error {\n readonly code = \"STORE_NOT_FOUND\" as const;\n constructor(message = \"Record not found\", options?: ErrorOptions) {\n super(message, options);\n this.name = \"StoreNotFoundError\";\n }\n}\n\n/**\n * Thrown when a unique constraint is violated (e.g. duplicate `clientId`).\n *\n * Handlers use this to return the existing record instead of failing.\n */\nexport class StoreDuplicateError extends Error {\n readonly code = \"STORE_DUPLICATE\" as const;\n constructor(message = \"Duplicate record\", options?: ErrorOptions) {\n super(message, options);\n this.name = \"StoreDuplicateError\";\n }\n}\n\n/**\n * Thrown when a store accepts a mutation but cannot persist it — e.g.\n * `localStorage` is full (QuotaExceededError). Adapters MUST throw this rather\n * than swallow the failure, so callers learn the write was lost instead of\n * seeing a phantom success.\n */\nexport class StorePersistenceError extends Error {\n readonly code = \"STORE_PERSISTENCE\" as const;\n constructor(message = \"Failed to persist store mutation\", options?: ErrorOptions) {\n super(message, options);\n this.name = \"StorePersistenceError\";\n }\n}\n\n/** Shape of any ORM error that carries a Prisma-style `code` field. */\ntype CodedError<C extends string = string> = { code: C };\n\nfunction hasErrorCode<C extends string>(error: unknown, code: C): error is CodedError<C> {\n return hasOwn(error, \"code\") && error.code === code;\n}\n\n/**\n * Type guard — works for `StoreNotFoundError` and ORM-specific equivalents\n * (e.g. Prisma P2025). Matches the stable `STORE_NOT_FOUND` code as well as\n * `instanceof`: every consumer package bundles its own copy of core (tsup\n * `noExternal`), so an adapter's instance fails `instanceof` against the\n * server's class identity.\n */\nexport function isStoreNotFound(\n error: unknown,\n): error is StoreNotFoundError | CodedError<\"STORE_NOT_FOUND\"> | CodedError<\"P2025\"> {\n if (error instanceof StoreNotFoundError) return true;\n // Backwards compat: Prisma's P2025\n return hasErrorCode(error, \"STORE_NOT_FOUND\") || hasErrorCode(error, \"P2025\");\n}\n\n/**\n * Type guard — works for `StoreDuplicateError` and ORM-specific equivalents\n * (e.g. Prisma P2002). Matches the stable `STORE_DUPLICATE` code as well as\n * `instanceof`, for the same cross-bundle reason as {@link isStoreNotFound}.\n */\nexport function isStoreDuplicate(\n error: unknown,\n): error is StoreDuplicateError | CodedError<\"STORE_DUPLICATE\"> | CodedError<\"P2002\"> {\n if (error instanceof StoreDuplicateError) return true;\n // Backwards compat: Prisma's P2002\n return hasErrorCode(error, \"STORE_DUPLICATE\") || hasErrorCode(error, \"P2002\");\n}\n\n/**\n * Type guard for `StorePersistenceError`. Matches on the stable `code` field\n * in addition to `instanceof`: every consumer package bundles its own copy of\n * core (tsup `noExternal`), so an instance thrown by one package fails an\n * `instanceof` check against another package's class identity.\n */\nexport function isStorePersistence(error: unknown): error is StorePersistenceError | CodedError<\"STORE_PERSISTENCE\"> {\n if (error instanceof StorePersistenceError) return true;\n return hasErrorCode(error, \"STORE_PERSISTENCE\");\n}\n\n// ---------------------------------------------------------------------------\n// Store helpers — shared conversion logic for adapters\n// ---------------------------------------------------------------------------\n\n/** Flatten a widget `AnnotationPayload` (nested anchor + rect) into a flat `AnnotationCreateInput`. */\nexport function flattenAnnotation(ann: AnnotationPayload): AnnotationCreateInput {\n return {\n cssSelector: ann.anchor.cssSelector,\n xpath: ann.anchor.xpath,\n textSnippet: ann.anchor.textSnippet,\n elementTag: ann.anchor.elementTag,\n elementId: ann.anchor.elementId,\n textPrefix: ann.anchor.textPrefix,\n textSuffix: ann.anchor.textSuffix,\n fingerprint: ann.anchor.fingerprint,\n neighborText: ann.anchor.neighborText,\n anchorKey: ann.anchor.anchorKey ?? null,\n xPct: ann.rect.xPct,\n yPct: ann.rect.yPct,\n wPct: ann.rect.wPct,\n hPct: ann.rect.hPct,\n scrollX: ann.scrollX,\n scrollY: ann.scrollY,\n viewportW: ann.viewportW,\n viewportH: ann.viewportH,\n devicePixelRatio: ann.devicePixelRatio,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Abstract Store — adapter pattern\n// ---------------------------------------------------------------------------\n\n/**\n * Outcome of `SitepingStore.createFeedbackIfAbsent` — the record plus whether\n * this very call inserted it.\n */\nexport interface FeedbackCreateOutcome {\n feedback: FeedbackRecord;\n /**\n * `true` when this call inserted the record, `false` when a record with the\n * same `clientId` already existed and is returned instead (a replay, or a\n * concurrent request that won the race).\n */\n created: boolean;\n}\n\n/** Paginated result returned by `SitepingStore.getFeedbacks`. */\nexport interface FeedbackPage {\n feedbacks: FeedbackRecord[];\n total: number;\n}\n\n/**\n * Abstract storage interface for Siteping.\n *\n * Any adapter (Prisma, Drizzle, raw SQL, localStorage, etc.) implements this\n * interface. The HTTP handler and widget `StoreClient` operate against\n * `SitepingStore`, decoupled from the storage backend.\n *\n * ## Error contract\n *\n * - **`updateFeedback` / `deleteFeedback`**: throw `StoreNotFoundError` when\n * the record does not exist.\n * - **`createFeedback`**: either return the existing record on duplicate\n * `clientId` (idempotent) or throw `StoreDuplicateError`. The handler\n * handles both patterns — but only a throw, or the optional\n * `createFeedbackIfAbsent`, tells it the record was not inserted by this\n * call; stores that return the existing record should implement\n * `createFeedbackIfAbsent` so creation side effects never run twice.\n * - **All mutations**: when a write is accepted but cannot be persisted\n * (e.g. storage quota), throw `StorePersistenceError` instead of reporting\n * a phantom success. Detect it with `isStorePersistence`.\n * - Other methods should not throw on empty results — return empty arrays or `null`.\n */\nexport interface SitepingStore {\n /** Create a feedback with its annotations. Idempotent on `clientId` — return existing record on duplicate, or throw `StoreDuplicateError`. Throws `StorePersistenceError` when the write cannot be persisted. */\n createFeedback(data: FeedbackCreateInput): Promise<FeedbackRecord>;\n /** Paginated query with optional filters. Returns empty array (not error) when no results. */\n getFeedbacks(query: FeedbackQuery): Promise<FeedbackPage>;\n /** Lookup by client-generated UUID. Returns `null` (not error) when not found. */\n findByClientId(clientId: string): Promise<FeedbackRecord | null>;\n /** Update status/resolvedAt. Throws `StoreNotFoundError` if `id` does not exist, `StorePersistenceError` when the write cannot be persisted. */\n updateFeedback(id: string, data: FeedbackUpdateInput): Promise<FeedbackRecord>;\n /** Delete a single record. Throws `StoreNotFoundError` if `id` does not exist, `StorePersistenceError` when the write cannot be persisted. */\n deleteFeedback(id: string): Promise<void>;\n /** Bulk delete all feedbacks for a project. No-op (not error) if none exist. Throws `StorePersistenceError` when the write cannot be persisted. */\n deleteAllFeedbacks(projectName: string): Promise<void>;\n /**\n * Optional — return `true` when the record with `id` belongs to\n * `projectName`, `false` otherwise (including when it does not exist).\n *\n * HTTP handlers use this to reject cross-project PATCH/DELETE requests.\n * Implement it whenever your store serves multiple projects. When absent,\n * handlers rely on `id` alone — so `createSitepingHandler` refuses to start\n * with a custom `access.authorize` (which may scope callers to projects)\n * over a store that lacks it.\n */\n verifyProjectOwnership?(id: string, projectName: string): Promise<boolean>;\n /**\n * Optional — `createFeedback` that reports whether this call inserted the\n * record (`created: true`) or found an existing one with the same\n * `clientId` (`created: false`). The dedup check and the insert must be\n * atomic, like `createFeedback`'s: of N concurrent calls with the same\n * `clientId`, exactly one may report `created: true`, and all must return\n * that same record. `createCollectionStore` guarantees this within one\n * store instance by serializing its mutations; stores shared across\n * processes need an atomic backend primitive (unique constraint,\n * transaction, compare-and-set).\n *\n * HTTP handlers prefer it over `createFeedback` to fire creation side\n * effects (webhooks, `onCreated`) exactly once when concurrent requests\n * race on the same `clientId`. Stores whose `createFeedback` throws\n * `StoreDuplicateError` on a duplicate already give that signal and may\n * leave it out.\n */\n createFeedbackIfAbsent?(data: FeedbackCreateInput): Promise<FeedbackCreateOutcome>;\n}\n\n/** Payload sent from the widget to the server when submitting feedback. */\nexport interface FeedbackPayload {\n projectName: string;\n type: FeedbackType;\n message: string;\n url: string;\n /**\n * Parameterized URL template (e.g. `/orders/:orderId`) supplied by\n * `SitepingConfig.getPageScope()`. Null when the host did not provide one.\n */\n urlPattern?: string | null | undefined;\n viewport: string;\n userAgent: string;\n authorName: string;\n authorEmail: string;\n annotations: AnnotationPayload[];\n /** Client-generated UUID for deduplication */\n clientId: string;\n /**\n * Base64 JPEG `data:` URL of the annotated area. Captured by the widget\n * when `enableScreenshot: true` is set in `SitepingConfig`. Null when\n * disabled or when capture failed silently.\n */\n screenshotDataUrl?: string | null | undefined;\n /**\n * Annotation rect position within the screenshot image — see\n * `ScreenshotRegion`. Null/absent when no screenshot was captured or the\n * capture predates contextual framing.\n */\n screenshotRegion?: ScreenshotRegion | null | undefined;\n /**\n * Snapshot of the last few console messages and failed network requests\n * captured at submit time when `captureDiagnostics` is enabled.\n */\n diagnostics?: DiagnosticsSnapshot | null | undefined;\n}\n\n/** Single source of truth for console diagnostic severity levels. */\nexport const CONSOLE_DIAGNOSTIC_LEVELS = [\"log\", \"info\", \"warn\", \"error\"] as const;\n/** Severity levels persisted in `ConsoleDiagnosticEntry`. */\nexport type ConsoleDiagnosticLevel = (typeof CONSOLE_DIAGNOSTIC_LEVELS)[number];\n\n/** A single console entry captured by `ConsoleBuffer`. */\nexport interface ConsoleDiagnosticEntry {\n level: ConsoleDiagnosticLevel;\n /** ISO 8601 timestamp captured at log time. */\n timestamp: string;\n /** Best-effort string representation of the original console args. */\n message: string;\n}\n\n/** A single failed network request captured by `NetworkBuffer`. */\nexport interface NetworkDiagnosticEntry {\n url: string;\n method: string;\n /** HTTP status; 0 when the request never reached the server. */\n status: number;\n /** End-to-end duration in ms. */\n durationMs: number;\n /** ISO 8601 timestamp at the moment the request was initiated. */\n timestamp: string;\n}\n\n/**\n * Diagnostics captured by the widget when `captureDiagnostics` is enabled.\n *\n * Both arrays are bounded (default: 50 console / 20 network). Adapters that\n * support diagnostics should persist this as a JSON blob alongside the\n * feedback so reviewers can replay the context that led to the report.\n */\nexport interface DiagnosticsSnapshot {\n console: ConsoleDiagnosticEntry[];\n network: NetworkDiagnosticEntry[];\n}\n\n// ---------------------------------------------------------------------------\n// Annotation — multi-selector anchoring (Hypothesis / W3C Web Annotation)\n// ---------------------------------------------------------------------------\n\n/** DOM anchoring data for re-attaching annotations to page elements. */\nexport interface AnchorData {\n /** CSS selector generated by @medv/finder — primary anchor */\n cssSelector: string;\n /** XPath — fallback 1 */\n xpath: string;\n /** First ~120 chars of element innerText — empty string if none */\n textSnippet: string;\n /** Tag name for validation (e.g. \"DIV\", \"SECTION\") */\n elementTag: string;\n /** Element id attribute if available — most stable */\n elementId?: string | undefined;\n /** ~32 chars of text before this element in document flow (disambiguation) */\n textPrefix: string;\n /** ~32 chars of text after this element in document flow (disambiguation) */\n textSuffix: string;\n /** Structural fingerprint: \"childCount:siblingIdx:attrHash\" */\n fingerprint: string;\n /** Text content of adjacent sibling elements (context) */\n neighborText: string;\n /**\n * Semantic anchor identifier from the closest ancestor's `data-feedback-anchor`\n * attribute. When set, this is the highest-priority re-anchoring signal —\n * hosts deliberately place these on layout/section roots that survive\n * viewport changes and DOM refactors.\n */\n anchorKey?: string | null | undefined;\n}\n\n/**\n * Where the client's annotation rect sits within the captured screenshot,\n * as fractions [0, 1] of the image dimensions. The widget captures context\n * around the drawn rect and records the rect's position here so dashboards\n * can re-render the annotation on top of the image. Survives downscaling\n * (fractions are resolution-independent).\n */\nexport interface ScreenshotRegion {\n /** X offset of the rect as fraction of image width — [0, 1] */\n xPct: number;\n /** Y offset of the rect as fraction of image height — [0, 1] */\n yPct: number;\n /** Rect width as fraction of image width — [0, 1] */\n wPct: number;\n /** Rect height as fraction of image height — [0, 1] */\n hPct: number;\n}\n\n/** Drawn rectangle coordinates as percentages relative to the anchor element. */\nexport interface RectData {\n /** X offset as fraction of anchor element width — must be in range [0, 1] */\n xPct: number;\n /** Y offset as fraction of anchor element height — must be in range [0, 1] */\n yPct: number;\n /** Width as fraction of anchor element width — must be in range [0, 1] */\n wPct: number;\n /** Height as fraction of anchor element height — must be in range [0, 1] */\n hPct: number;\n}\n\n/** Annotation data sent as part of a feedback submission. */\nexport interface AnnotationPayload {\n anchor: AnchorData;\n rect: RectData;\n scrollX: number;\n scrollY: number;\n viewportW: number;\n viewportH: number;\n devicePixelRatio: number;\n}\n\n// ---------------------------------------------------------------------------\n// API responses\n// ---------------------------------------------------------------------------\n\n/**\n * Feedback record as returned by the API — derived from\n * {@link FeedbackRecord}: dates are serialized to ISO strings and `clientId`\n * is omitted (server-side dedup concern, never exposed on the wire). Adding\n * a field to `FeedbackRecord` updates this type automatically.\n *\n * Note: `authorEmail` may be an empty string — HTTP adapters redact it for\n * unauthenticated requests; the full value requires a Bearer-authenticated\n * request.\n */\nexport type FeedbackResponse = Prettify<Serialized<Omit<FeedbackRecord, \"clientId\">>>;\n\n/**\n * Annotation record as returned by the API — {@link AnnotationRecord} with\n * `createdAt` serialized to an ISO string.\n */\nexport type AnnotationResponse = Prettify<Serialized<AnnotationRecord>>;\n\n/** Paginated `FeedbackResponse` shape returned by the API. */\nexport interface FeedbackResponseList {\n feedbacks: FeedbackResponse[];\n total: number;\n}\n","import type { AssertEqual, FeedbackPayload, FeedbackStatus, FeedbackType, Prettify } from \"@beezping/core\";\nimport { CONSOLE_DIAGNOSTIC_LEVELS, EMAIL_PATTERN, FEEDBACK_STATUSES, FEEDBACK_TYPES } from \"@beezping/core\";\nimport * as zod from \"zod\";\n\n// Namespace import required: Zod publishes dual CJS/ESM, and bundlers (tsup, vitest) may\n// resolve the CJS entry where `import { z } from \"zod\"` fails because CJS wraps\n// the entire module under a default/namespace key. This workaround normalizes access\n// regardless of which entry point the bundler resolves.\n// See: https://github.com/colinhacks/zod/issues/2697\nconst z: typeof zod.z = (\"z\" in zod ? zod.z : zod) as typeof zod.z;\n\nconst anchorSchema = z.object({\n cssSelector: z.string().min(1).max(2000),\n xpath: z.string().min(1).max(2000),\n textSnippet: z.string().max(500),\n elementTag: z.string().min(1),\n elementId: z.string().optional(),\n textPrefix: z.string().max(200),\n textSuffix: z.string().max(200),\n fingerprint: z.string().max(200),\n neighborText: z.string().max(500),\n // Optional semantic anchor identifier from `data-feedback-anchor`.\n // Null when no semantic ancestor exists; widget always sends this field.\n anchorKey: z.string().max(200).nullable().optional(),\n});\n\nconst rectSchema = z.object({\n xPct: z.number().min(0).max(1),\n yPct: z.number().min(0).max(1),\n wPct: z.number().min(0).max(1),\n hPct: z.number().min(0).max(1),\n});\n\nconst annotationSchema = z.object({\n anchor: anchorSchema,\n rect: rectSchema,\n scrollX: z.number().min(0),\n scrollY: z.number().min(0),\n viewportW: z.number().int().positive(),\n viewportH: z.number().int().positive(),\n devicePixelRatio: z.number().positive().default(1),\n});\n\n// Diagnostics caps mirror the widget defaults — the widget never sends more\n// than these, but the schema enforces it server-side too so a tampered\n// client can't blow up the JSON column with megabytes of garbage.\nconst consoleEntrySchema = z.object({\n level: z.enum(CONSOLE_DIAGNOSTIC_LEVELS),\n timestamp: z.string().max(50),\n message: z.string().max(600),\n});\n\nconst networkEntrySchema = z.object({\n url: z.string().max(2000),\n method: z.string().max(20),\n status: z.number().int().min(0).max(599),\n durationMs: z.number().min(0).max(600_000),\n timestamp: z.string().max(50),\n});\n\nconst diagnosticsSchema = z.object({\n console: z.array(consoleEntrySchema).max(50),\n network: z.array(networkEntrySchema).max(20),\n});\n\n// Annotation rect position within the screenshot image, as fractions [0, 1]\n// of the image dimensions (see `ScreenshotRegion` in core). Strict: this is\n// persisted verbatim into a JSON column, so unknown keys are rejected rather\n// than silently stored.\nexport const screenshotRegionSchema = z.strictObject({\n xPct: z.number().min(0).max(1),\n yPct: z.number().min(0).max(1),\n wPct: z.number().min(0).max(1),\n hPct: z.number().min(0).max(1),\n});\n\nexport const feedbackCreateSchema = z.object({\n projectName: z.string().min(1).max(200),\n type: z.enum(FEEDBACK_TYPES),\n message: z.string().min(1).max(5000),\n // Page-scope identifier the widget uses to group feedbacks. Defaults to\n // `window.location.pathname` (\"/orders/42\"), but hosts can override\n // `getPageScope()` to return a full URL, an opaque slug, anything they\n // want. We trim + require non-empty so whitespace-only payloads don't\n // leak into the DB; otherwise the value is opaque to the server and only\n // used as a literal Prisma equality filter, so the loose shape is safe.\n url: z.string().trim().min(1).max(2000),\n // Optional parameterized URL template (e.g. \"/orders/:orderId\") provided\n // by the host via `getPageScope()`. Null when host omits it.\n urlPattern: z.string().max(2000).nullable().optional(),\n viewport: z.string().min(1).max(50),\n userAgent: z.string().min(1).max(500),\n authorName: z.string().min(1).max(200),\n // The widget's identity modal validates against the same core pattern, so\n // an address the modal accepts (and persists) is never a 400 here.\n authorEmail: z.email({ pattern: EMAIL_PATTERN }).max(200),\n annotations: z.array(annotationSchema).max(50),\n // Restrict to URL-safe identifiers. The widget generates UUIDs (or a\n // Date+Math.random fallback), both of which match. Anything outside this\n // alphabet — `..`, `/`, NUL, etc. — would be a path-traversal vector once\n // the adapter forwards clientId to `screenshotStorage.upload({ feedbackId })`\n // (e.g. an S3 key prefix or a local FS path).\n clientId: z\n .string()\n .min(1)\n .max(200)\n .regex(/^[a-zA-Z0-9_-]+$/, \"clientId must be alphanumeric (a-z, A-Z, 0-9, _, -)\"),\n // Optional base64 JPEG data URL captured by the widget when\n // `enableScreenshot: true`. ~1.5 MB cap = roughly a 1.1 MB JPEG, well\n // above typical sizes (the widget downscales to 1200px). Rejects abuse\n // without truncating legitimate captures.\n screenshotDataUrl: z\n .string()\n .max(1_500_000)\n .regex(/^data:image\\/(jpeg|png|webp);base64,/, \"screenshotDataUrl must be a data:image/* base64 URL\")\n .nullable()\n .optional(),\n // Optional annotation-rect position within the screenshot. Sent by widgets\n // that capture context around the drawn rect; null + omitted are both\n // accepted so legacy clients (and captures without a screenshot) keep\n // working unchanged.\n screenshotRegion: screenshotRegionSchema.nullable().optional(),\n // Optional console + failed-network snapshot. The widget only attaches\n // this when `captureDiagnostics` is enabled; null + omitted are both\n // accepted so existing clients keep working unchanged.\n diagnostics: diagnosticsSchema.nullable().optional(),\n});\n\nexport const feedbackPatchSchema = z.object({\n id: z.string().min(1),\n projectName: z.string().min(1).max(200),\n status: z.enum(FEEDBACK_STATUSES),\n});\n\nexport const feedbackDeleteSchema = z.union([\n z.object({ id: z.string().min(1), projectName: z.string().min(1).max(200) }),\n z.object({ projectName: z.string().min(1).max(200), deleteAll: z.literal(true) }),\n]);\n\nexport const getQuerySchema = z.object({\n projectName: z.string().min(1).max(200),\n page: z.coerce.number().int().min(1).default(1),\n limit: z.coerce.number().int().min(1).max(100).default(50),\n type: z.enum(FEEDBACK_TYPES).optional(),\n status: z.enum(FEEDBACK_STATUSES).optional(),\n // Bucket status filter serialized as CSV over the wire (e.g.\n // `statuses=open,in_progress`). Non-empty strings are split before each value\n // is validated against the known statuses; `statuses` wins over the exact\n // `status` filter downstream. Capped at 4 — the number of known statuses.\n statuses: z\n .preprocess(\n (val) => (typeof val === \"string\" && val.length > 0 ? val.split(\",\") : val),\n z.array(z.enum(FEEDBACK_STATUSES)).max(4),\n )\n .optional(),\n search: z.string().max(200).optional(),\n // Page scope filters — used by the panel's \"this page / this type\" controls\n url: z.string().max(2000).optional(),\n urlPattern: z.string().max(2000).optional(),\n});\n\n// ---------------------------------------------------------------------------\n// Explicit public interfaces — decoupled from Zod to keep .d.ts clean.\n//\n// The create payload needs no local interface at all: the schema validates\n// core's `FeedbackPayload` wire shape, and the compile-time lock below keeps\n// the two identical. Only the wire shapes that exist solely at this HTTP\n// boundary (PATCH / DELETE / GET query) are declared here.\n// ---------------------------------------------------------------------------\n\nexport interface FeedbackPatchInput {\n id: string;\n projectName: string;\n status: FeedbackStatus;\n}\n\nexport interface FeedbackDeleteSingle {\n id: string;\n projectName: string;\n}\n\nexport interface FeedbackDeleteAll {\n projectName: string;\n deleteAll: true;\n}\n\nexport type FeedbackDeleteInput = FeedbackDeleteSingle | FeedbackDeleteAll;\n\nexport interface GetQueryInput {\n projectName: string;\n /** Set to 1 by schema default when omitted from raw input. */\n page: number;\n /** Set to 50 by schema default when omitted from raw input. */\n limit: number;\n type?: FeedbackType | undefined;\n status?: FeedbackStatus | undefined;\n statuses?: FeedbackStatus[] | undefined;\n search?: string | undefined;\n url?: string | undefined;\n urlPattern?: string | undefined;\n}\n\n// ---------------------------------------------------------------------------\n// Type-level locks: the Zod schemas validate exactly the shapes the rest of\n// the monorepo speaks.\n//\n// The create schema is locked against core's `FeedbackPayload` — the actual\n// wire contract the widget sends — so schema drift from the real payload is\n// a compile error, not a runtime 400. The boundary-only shapes (PATCH /\n// DELETE / GET query) are locked against their local interfaces the same\n// way. `Prettify<>` flattens the inferred shape so the equality check\n// compares clean object types rather than intersection chains.\n// ---------------------------------------------------------------------------\n\nconst _createSchemaMatchesPayload: AssertEqual<\n Prettify<zod.z.infer<typeof feedbackCreateSchema>>,\n FeedbackPayload\n> = true;\nvoid _createSchemaMatchesPayload;\nconst _patchSchemaMatchesInput: AssertEqual<\n Prettify<zod.z.infer<typeof feedbackPatchSchema>>,\n FeedbackPatchInput\n> = true;\nvoid _patchSchemaMatchesInput;\nconst _deleteSchemaMatchesInput: AssertEqual<\n Prettify<zod.z.infer<typeof feedbackDeleteSchema>>,\n FeedbackDeleteInput\n> = true;\nvoid _deleteSchemaMatchesInput;\nconst _querySchemaMatchesInput: AssertEqual<Prettify<zod.z.infer<typeof getQuerySchema>>, GetQueryInput> = true;\nvoid _querySchemaMatchesInput;\n\n/** Single validation issue extracted from a `ZodError`. */\nexport interface ValidationIssue {\n field: string;\n message: string;\n}\n\n/**\n * Map Zod errors to a flat array of `{ field, message }` objects.\n * Safe: does not leak input values or schema structure.\n */\nexport function formatValidationErrors(error: zod.z.ZodError): ValidationIssue[] {\n // Zod 4 types `issue.path` as PropertyKey[] (symbols possible in theory);\n // String() keeps the join total instead of throwing on non-string keys.\n return error.issues.map((issue) => ({\n field: issue.path.map(String).join(\".\"),\n message: issue.message,\n }));\n}\n","/**\n * Outgoing webhook notifications for newly-created feedbacks.\n *\n * Plug a Slack, Discord, or generic HTTP endpoint into `createSitepingHandler`\n * to receive a payload whenever a feedback is successfully persisted. Webhooks\n * are dispatched as fire-and-forget (`void Promise.all(...)`) so a slow or\n * down receiver never blocks the client response — the feedback is already in\n * the DB by the time we dial out.\n *\n * - **Type-specific formatting**: Slack uses `{ text, blocks }`, Discord uses\n * `{ content, embeds }`, generic posts the record as JSON (minus `clientId`).\n * - **Untrusted input**: `message` and `authorName` come from anonymous\n * visitors. Slack text is escaped and Discord mention parsing is disabled,\n * so a public feedback form can never be turned into a channel-wide ping.\n * - **Timeout**: 5s by default (overridable per webhook).\n * - **Error handling**: `config.onError(err, feedback.id)` is invoked when\n * present; otherwise we log a one-liner to `console.warn` so the issue is\n * surfaced without crashing the request.\n */\n\nimport type { FeedbackRecord, FeedbackType } from \"@beezping/core\";\n\n/** Supported webhook integrations — drives the JSON body shape. */\nexport type WebhookType = \"slack\" | \"discord\" | \"generic\";\n\n/**\n * Outgoing webhook configuration.\n *\n * - `url` — required, the HTTPS endpoint to POST to.\n * - `type` — payload format. Defaults to `\"generic\"` (raw JSON).\n * - `headers` — extra headers merged on top of `Content-Type: application/json`.\n * Useful for signed-payload schemes (`X-Signature`, bearer tokens, …).\n * - `timeoutMs` — abort the fetch after this many ms. Defaults to 5000.\n * - `onError` — invoked with the underlying error and the feedback id when\n * the dispatch fails (network error, non-2xx, timeout). The webhook is\n * fire-and-forget, so this is your only chance to observe failures.\n */\nexport interface WebhookConfig {\n url: string;\n type?: WebhookType;\n headers?: Record<string, string>;\n timeoutMs?: number;\n onError?: (err: Error, feedbackId: string) => void;\n}\n\nconst DEFAULT_TIMEOUT_MS = 5000;\n\n/** Decimal RGB colour table used by Discord embeds — keyed by feedback type. */\nconst DISCORD_COLORS: Readonly<Record<FeedbackType, number>> = {\n bug: 0xef4444,\n question: 0x3b82f6,\n change: 0xf59e0b,\n other: 0x6b7280,\n};\n\nconst DEFAULT_DISCORD_COLOR = 0x6b7280;\n\n// ---------------------------------------------------------------------------\n// Payload shapes — narrow types let TypeScript catch malformed bodies at\n// compile time rather than only at the receiving end.\n// ---------------------------------------------------------------------------\n\n/** Block Kit envelope used by Slack incoming webhooks. */\nexport interface SlackWebhookPayload {\n text: string;\n blocks: ReadonlyArray<SlackHeaderBlock | SlackSectionBlock | SlackContextBlock>;\n}\n\ninterface SlackHeaderBlock {\n type: \"header\";\n text: { type: \"plain_text\"; text: string; emoji: true };\n}\n\ninterface SlackSectionBlock {\n type: \"section\";\n text: { type: \"mrkdwn\"; text: string };\n}\n\ninterface SlackContextBlock {\n type: \"context\";\n elements: ReadonlyArray<{ type: \"mrkdwn\"; text: string }>;\n}\n\n/** Embed envelope used by Discord incoming webhooks. */\nexport interface DiscordWebhookPayload {\n content: string;\n embeds: ReadonlyArray<{\n title: string;\n description: string;\n color: number;\n fields: ReadonlyArray<{ name: string; value: string; inline: boolean }>;\n timestamp: string;\n }>;\n /**\n * Mention parsing is switched off: `content` carries end-user text, so an\n * author called `@everyone` must render as text, never as a notification.\n */\n allowed_mentions: { parse: ReadonlyArray<\"roles\" | \"users\" | \"everyone\"> };\n}\n\n/**\n * Generic webhook body — the stored record as JSON. `clientId` is stripped like\n * on every other output: it is the browser-local dedup secret and the POST\n * replay path hands the full record to whoever presents it.\n */\nexport type GenericWebhookPayload = Omit<FeedbackRecord, \"clientId\">;\n\n/** Mapping from webhook type to its concrete body shape. */\nexport interface WebhookPayloadMap {\n slack: SlackWebhookPayload;\n discord: DiscordWebhookPayload;\n generic: GenericWebhookPayload;\n}\n\n/** Truncate a message for chat-platform previews (Slack/Discord look bad with walls of text). */\nfunction truncate(text: string, max = 300): string {\n if (text.length <= max) return text;\n return `${text.slice(0, max - 1)}…`;\n}\n\n/** Block Kit caps `header` text at 150 characters — longer payloads are rejected outright. */\nconst SLACK_HEADER_MAX = 150;\n\n/**\n * Escape the three characters Slack parses as control characters in message\n * text (`&`, `<`, `>`) — the exact escaping Slack's formatting rules require\n * for user-provided content. Feedback text is typed by anonymous visitors:\n * unescaped, `<!channel>` notifies the whole channel and\n * `<https://evil.example|Reset your password>` renders as a disguised link.\n */\nfunction escapeSlackText(text: string): string {\n return text.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n}\n\n/**\n * Slack message: text fallback + Block Kit blocks for rich rendering. Every\n * `mrkdwn` field is escaped; the `plain_text` header is rendered verbatim by\n * Slack (no markup parsing), so it keeps the raw author name.\n */\nfunction buildSlackPayload(feedback: FeedbackRecord): SlackWebhookPayload {\n const preview = escapeSlackText(truncate(feedback.message));\n const author = escapeSlackText(feedback.authorName);\n const headline = `New ${feedback.type} feedback from ${feedback.authorName}`;\n return {\n text: `${escapeSlackText(headline)}: ${preview}`,\n blocks: [\n {\n type: \"header\",\n text: { type: \"plain_text\", text: truncate(headline, SLACK_HEADER_MAX), emoji: true },\n },\n {\n type: \"section\",\n text: { type: \"mrkdwn\", text: preview },\n },\n {\n type: \"context\",\n elements: [\n { type: \"mrkdwn\", text: `*Project:* ${escapeSlackText(feedback.projectName)}` },\n { type: \"mrkdwn\", text: `*Type:* ${feedback.type}` },\n { type: \"mrkdwn\", text: `*URL:* ${escapeSlackText(feedback.url)}` },\n { type: \"mrkdwn\", text: `*From:* ${author} (${escapeSlackText(feedback.authorEmail)})` },\n ],\n },\n ],\n };\n}\n\n/**\n * Discord message: content fallback + embed for rich rendering. Sent with\n * mention parsing disabled — `content` embeds the author name, and Discord\n * would otherwise turn `@everyone` / `@here` into a server-wide ping.\n */\nfunction buildDiscordPayload(feedback: FeedbackRecord): DiscordWebhookPayload {\n const preview = truncate(feedback.message);\n return {\n content: `New **${feedback.type}** feedback from **${feedback.authorName}**`,\n embeds: [\n {\n title: `${feedback.type} — ${feedback.projectName}`,\n description: preview,\n color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,\n fields: [\n { name: \"URL\", value: feedback.url, inline: false },\n { name: \"Author\", value: `${feedback.authorName} (${feedback.authorEmail})`, inline: true },\n { name: \"Viewport\", value: feedback.viewport, inline: true },\n ],\n timestamp: new Date(feedback.createdAt).toISOString(),\n },\n ],\n allowed_mentions: { parse: [] },\n };\n}\n\n/** Generic JSON body — the record minus its `clientId`. */\nfunction buildGenericPayload(feedback: FeedbackRecord): GenericWebhookPayload {\n const { clientId: _clientId, ...payload } = feedback;\n return payload;\n}\n\n/**\n * Build the JSON body for a single webhook based on its `type`.\n * Exported for tests; not part of the public API.\n *\n * @internal\n */\nexport function buildWebhookPayload<T extends WebhookType | undefined>(\n type: T,\n feedback: FeedbackRecord,\n): T extends \"slack\" ? SlackWebhookPayload : T extends \"discord\" ? DiscordWebhookPayload : GenericWebhookPayload {\n switch (type) {\n case \"slack\":\n return buildSlackPayload(feedback) as never;\n case \"discord\":\n return buildDiscordPayload(feedback) as never;\n default:\n return buildGenericPayload(feedback) as never;\n }\n}\n\n/**\n * Dispatch a single webhook. Fire-and-forget: never throws, never rejects.\n *\n * - Builds the type-specific payload.\n * - POSTs with an `AbortSignal` timeout.\n * - On any error (network, non-2xx, timeout, exception), invokes\n * `config.onError(err, feedbackId)` if provided; otherwise logs a one-liner.\n */\nexport async function dispatchWebhook(config: WebhookConfig, feedback: FeedbackRecord): Promise<void> {\n const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const body = JSON.stringify(buildWebhookPayload(config.type ?? \"generic\", feedback));\n\n // Build merged headers — caller-supplied entries override `Content-Type`\n // when they explicitly need a different mime (rare for chat webhooks, but\n // possible for some generic receivers).\n const headers: Record<string, string> = { \"Content-Type\": \"application/json\", ...(config.headers ?? {}) };\n\n // Use AbortSignal.timeout when available (Node 17.3+, all modern browsers).\n // Fall back to a manual controller for environments lacking it.\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n const response = await fetch(config.url, {\n method: \"POST\",\n headers,\n body,\n signal: controller.signal,\n });\n clearTimeout(timer);\n\n if (!response.ok) {\n const err = new Error(`Webhook responded with HTTP ${response.status}`);\n reportError(config, err, feedback.id);\n }\n } catch (rawError) {\n clearTimeout(timer);\n const err = rawError instanceof Error ? rawError : new Error(String(rawError));\n reportError(config, err, feedback.id);\n }\n}\n\nfunction reportError(config: WebhookConfig, err: Error, feedbackId: string): void {\n if (config.onError) {\n try {\n config.onError(err, feedbackId);\n } catch (callbackErr) {\n // Defense-in-depth: a thrown user callback must not bubble back up\n // and crash the request that already succeeded persisting the\n // feedback. Surface the original error too so it isn't silently lost.\n console.warn(\n `[siteping] webhook onError() callback threw for feedback ${feedbackId}: ${String(callbackErr)} (original error: ${err.message})`,\n );\n }\n return;\n }\n console.warn(`[siteping] webhook to ${config.url} failed for feedback ${feedbackId}: ${err.message}`);\n}\n\n/**\n * Dispatch every configured webhook in parallel. Awaiting the returned promise\n * lets tests synchronize on completion, but production callers should drop the\n * promise on the floor (`void dispatchWebhooks(...)`) so the HTTP response\n * isn't held back on slow receivers.\n */\nexport async function dispatchWebhooks(configs: readonly WebhookConfig[], feedback: FeedbackRecord): Promise<void> {\n if (configs.length === 0) return;\n await Promise.all(configs.map((c) => dispatchWebhook(c, feedback)));\n}\n","import {\n type FeedbackCreateInput,\n type FeedbackRecord,\n flattenAnnotation,\n isStoreDuplicate,\n type SitepingStore,\n} from \"@beezping/core\";\nimport { SITEPING_ERROR_MESSAGES } from \"../constants/error-messages.js\";\nimport { MAX_ANNOTATIONS_PER_FEEDBACK } from \"../constants/limits.js\";\nimport type { SitepingHandlerBaseOptions } from \"../options.js\";\nimport type { AuthenticatedRequest, RequestPipeline } from \"../request-pipeline.js\";\nimport { feedbackCreateSchema } from \"../validation.js\";\nimport { dispatchWebhooks, type WebhookConfig } from \"../webhooks.js\";\n\ntype FeedbackCreatePayload = ReturnType<typeof feedbackCreateSchema.parse>;\n\nexport interface CreateFeedbackDependencies<Principal> {\n store: SitepingStore;\n pipeline: RequestPipeline<Principal>;\n beforeCreate: SitepingHandlerBaseOptions<Principal>[\"beforeCreate\"];\n onCreated: NonNullable<SitepingHandlerBaseOptions<Principal>[\"hooks\"]>[\"onCreated\"];\n webhooks: ReadonlyArray<WebhookConfig>;\n}\n\n/** Store input from the validated payload — the store never sees wire-only shapes. */\nfunction toCreateInput(payload: FeedbackCreatePayload): FeedbackCreateInput {\n return {\n projectName: payload.projectName,\n type: payload.type,\n message: payload.message,\n status: \"open\",\n url: payload.url,\n urlPattern: payload.urlPattern ?? null,\n viewport: payload.viewport,\n userAgent: payload.userAgent,\n authorName: payload.authorName,\n authorEmail: payload.authorEmail,\n clientId: payload.clientId,\n annotations: payload.annotations.map(flattenAnnotation),\n screenshotDataUrl: payload.screenshotDataUrl ?? null,\n screenshotRegion: payload.screenshotRegion ?? null,\n diagnostics: payload.diagnostics ?? null,\n };\n}\n\n/** `POST` — create a feedback, idempotent on `clientId`. */\nexport function createFeedbackOperation<Principal>({\n store,\n pipeline,\n beforeCreate,\n onCreated,\n webhooks,\n}: CreateFeedbackDependencies<Principal>) {\n /**\n * A clientId is unique across the store, so a replay resolving to another\n * project's record is a boundary violation, not a dedup. `authorEmail`\n * follows the access policy (`canReadAuthorEmail`): `beforeCreate` may have\n * stored an email the requester never sent. The legacy api-key policy\n * still echoes it — see `AccessGate.echoesAuthorEmailOnCreate`.\n */\n const respondCreated = (scope: AuthenticatedRequest<Principal>, feedback: FeedbackRecord, projectName: string) =>\n feedback.projectName === projectName\n ? pipeline.json(scope, pipeline.presentCreated(scope, feedback), { status: 201 })\n : pipeline.error(scope, 409, SITEPING_ERROR_MESSAGES.clientIdUsedByAnotherProject);\n\n /**\n * Insert, resolving a race on clientId to the winning record. `isNew` gates\n * the creation side effects, so it must only be `true` when this call\n * inserted: stores returning the existing record on a duplicate say so\n * through `createFeedbackIfAbsent`; the others throw `StoreDuplicateError`.\n * A store that returns the existing record from plain `createFeedback`\n * cannot be told apart from an insert — see the `SitepingStore` contract.\n */\n const insert = async (input: FeedbackCreateInput): Promise<{ feedback: FeedbackRecord; isNew: boolean }> => {\n try {\n if (store.createFeedbackIfAbsent) {\n const { feedback, created } = await store.createFeedbackIfAbsent(input);\n return { feedback, isNew: created };\n }\n return { feedback: await store.createFeedback(input), isNew: true };\n } catch (error) {\n if (isStoreDuplicate(error)) {\n const existing = await store.findByClientId(input.clientId);\n if (existing) return { feedback: existing, isNew: false };\n }\n throw error;\n }\n };\n\n return async (request: Request): Promise<Response> => {\n const authentication = await pipeline.authenticate(request, \"POST\");\n if (!authentication.ok) return authentication.response;\n const scope = authentication.value;\n\n const payload = await pipeline.readBody(scope, feedbackCreateSchema);\n if (!payload.ok) return payload.response;\n // Defense-in-depth on top of the schema limit.\n if (payload.value.annotations.length > MAX_ANNOTATIONS_PER_FEEDBACK) {\n return pipeline.error(scope, 400, SITEPING_ERROR_MESSAGES.tooManyAnnotations);\n }\n\n try {\n const validatedInput = toCreateInput(payload.value);\n const input = beforeCreate ? await beforeCreate(validatedInput, scope.context) : validatedInput;\n\n const refusal = await pipeline.authorize(scope, { action: \"create\", projectName: input.projectName });\n if (refusal) return refusal;\n\n // Replay detection up front: a replayed submission must not notify\n // hooks or webhooks a second time.\n const replayed = await store.findByClientId(input.clientId);\n if (replayed) return respondCreated(scope, replayed, input.projectName);\n\n const { feedback, isNew } = await insert(input);\n if (isNew && feedback.projectName === input.projectName) {\n if (webhooks.length > 0) void dispatchWebhooks(webhooks, feedback);\n if (onCreated) await pipeline.runHook(\"onCreated\", () => onCreated(feedback, scope.context));\n }\n return respondCreated(scope, feedback, input.projectName);\n } catch (error) {\n return pipeline.internalError(scope, \"create feedback\", error);\n }\n };\n}\n","import { isStoreNotFound, type SitepingStore } from \"@beezping/core\";\nimport { SITEPING_ERROR_MESSAGES } from \"../constants/error-messages.js\";\nimport type { SitepingDeletionTarget, SitepingHandlerBaseOptions } from \"../options.js\";\nimport type { RequestPipeline } from \"../request-pipeline.js\";\nimport { type FeedbackDeleteInput, feedbackDeleteSchema } from \"../validation.js\";\n\ntype LifecycleHooks<Principal> = NonNullable<SitepingHandlerBaseOptions<Principal>[\"hooks\"]>;\n\nexport interface DeleteFeedbackDependencies<Principal> {\n store: SitepingStore;\n pipeline: RequestPipeline<Principal>;\n onDeleting: LifecycleHooks<Principal>[\"onDeleting\"];\n onDeleted: LifecycleHooks<Principal>[\"onDeleted\"];\n}\n\nfunction toDeletionTarget(deletion: FeedbackDeleteInput): SitepingDeletionTarget {\n return \"deleteAll\" in deletion\n ? { kind: \"project\", projectName: deletion.projectName }\n : { kind: \"single\", id: deletion.id, projectName: deletion.projectName };\n}\n\n/** `DELETE` — remove one feedback, or every feedback of a project (`deleteAll`). */\nexport function deleteFeedbackOperation<Principal>({\n store,\n pipeline,\n onDeleting,\n onDeleted,\n}: DeleteFeedbackDependencies<Principal>) {\n const removeTarget = (target: SitepingDeletionTarget): Promise<void> =>\n target.kind === \"project\" ? store.deleteAllFeedbacks(target.projectName) : store.deleteFeedback(target.id);\n\n return async (request: Request): Promise<Response> => {\n const authentication = await pipeline.authenticate(request, \"DELETE\");\n if (!authentication.ok) return authentication.response;\n const scope = authentication.value;\n\n const payload = await pipeline.readBody(scope, feedbackDeleteSchema);\n if (!payload.ok) return payload.response;\n const target = toDeletionTarget(payload.value as FeedbackDeleteInput);\n\n try {\n const refusal = await pipeline.authorize(\n scope,\n target.kind === \"project\"\n ? { action: \"deleteAll\", projectName: target.projectName }\n : { action: \"delete\", projectName: target.projectName, feedbackId: target.id },\n );\n if (refusal) return refusal;\n\n // Cross-project guard — see the PATCH operation for why it may be absent.\n if (\n target.kind === \"single\" &&\n store.verifyProjectOwnership &&\n !(await store.verifyProjectOwnership(target.id, target.projectName))\n ) {\n return pipeline.error(scope, 404, SITEPING_ERROR_MESSAGES.feedbackNotFound);\n }\n\n if (onDeleting) {\n try {\n await onDeleting(target, scope.context);\n } catch (error) {\n pipeline.logger.error(\"[siteping] createSitepingHandler: hook onDeleting aborted the deletion\", {\n error,\n target,\n });\n return pipeline.error(scope, 502, SITEPING_ERROR_MESSAGES.deletionAborted);\n }\n }\n\n await removeTarget(target);\n if (onDeleted) await pipeline.runHook(\"onDeleted\", () => onDeleted(target, scope.context));\n return pipeline.json(scope, { deleted: true });\n } catch (error) {\n if (isStoreNotFound(error)) return pipeline.error(scope, 404, SITEPING_ERROR_MESSAGES.feedbackNotFound);\n return pipeline.internalError(scope, \"delete feedback\", error);\n }\n };\n}\n","import type { SitepingStore } from \"@beezping/core\";\nimport { LIST_CACHE_CONTROL, LIST_QUERY_KEYS } from \"../constants/http.js\";\nimport type { RequestPipeline } from \"../request-pipeline.js\";\nimport { getQuerySchema } from \"../validation.js\";\n\nexport interface ListFeedbacksDependencies<Principal> {\n store: SitepingStore;\n pipeline: RequestPipeline<Principal>;\n}\n\n/** Only the query parameters the endpoint understands, as raw strings. */\nfunction readListQuery(request: Request): Record<string, string> {\n const searchParams = new URL(request.url).searchParams;\n const rawQuery: Record<string, string> = {};\n for (const key of LIST_QUERY_KEYS) {\n const value = searchParams.get(key);\n if (value !== null) rawQuery[key] = value;\n }\n return rawQuery;\n}\n\n/** `GET` — paginated, filtered list of a project's feedbacks. */\nexport function listFeedbacksOperation<Principal>({ store, pipeline }: ListFeedbacksDependencies<Principal>) {\n return async (request: Request): Promise<Response> => {\n const authentication = await pipeline.authenticate(request, \"GET\");\n if (!authentication.ok) return authentication.response;\n const scope = authentication.value;\n\n const query = pipeline.validate(scope, getQuerySchema, readListQuery(request));\n if (!query.ok) return query.response;\n\n try {\n const refusal = await pipeline.authorize(scope, { action: \"list\", projectName: query.value.projectName });\n if (refusal) return refusal;\n const page = await store.getFeedbacks(query.value);\n return pipeline.json(\n scope,\n { ...page, feedbacks: page.feedbacks.map((feedback) => pipeline.present(scope, feedback)) },\n { headers: { \"Cache-Control\": LIST_CACHE_CONTROL } },\n );\n } catch (error) {\n return pipeline.internalError(scope, \"list feedbacks\", error);\n }\n };\n}\n","import { isStoreNotFound, type SitepingStore, toFeedbackUpdate } from \"@beezping/core\";\nimport { SITEPING_ERROR_MESSAGES } from \"../constants/error-messages.js\";\nimport type { SitepingHandlerBaseOptions } from \"../options.js\";\nimport type { RequestPipeline } from \"../request-pipeline.js\";\nimport { feedbackPatchSchema } from \"../validation.js\";\n\nexport interface UpdateFeedbackDependencies<Principal> {\n store: SitepingStore;\n pipeline: RequestPipeline<Principal>;\n onUpdated: NonNullable<SitepingHandlerBaseOptions<Principal>[\"hooks\"]>[\"onUpdated\"];\n}\n\n/** `PATCH` — change a feedback's status. */\nexport function updateFeedbackOperation<Principal>({\n store,\n pipeline,\n onUpdated,\n}: UpdateFeedbackDependencies<Principal>) {\n return async (request: Request): Promise<Response> => {\n const authentication = await pipeline.authenticate(request, \"PATCH\");\n if (!authentication.ok) return authentication.response;\n const scope = authentication.value;\n\n const payload = await pipeline.readBody(scope, feedbackPatchSchema);\n if (!payload.ok) return payload.response;\n const { id, projectName, status } = payload.value;\n\n try {\n const refusal = await pipeline.authorize(scope, { action: \"update\", projectName, feedbackId: id });\n if (refusal) return refusal;\n // Cross-project guard. `createSitepingHandler` refuses to start with a\n // custom `authorize` over a store lacking the check, so it is only ever\n // skipped when no policy scopes callers to projects.\n if (store.verifyProjectOwnership && !(await store.verifyProjectOwnership(id, projectName))) {\n return pipeline.error(scope, 404, SITEPING_ERROR_MESSAGES.feedbackNotFound);\n }\n // resolvedAt (closure timestamp) is derived here at the edge.\n const feedback = await store.updateFeedback(id, toFeedbackUpdate(status));\n if (onUpdated) await pipeline.runHook(\"onUpdated\", () => onUpdated(feedback, scope.context));\n return pipeline.json(scope, pipeline.present(scope, feedback));\n } catch (error) {\n if (isStoreNotFound(error)) return pipeline.error(scope, 404, SITEPING_ERROR_MESSAGES.feedbackNotFound);\n return pipeline.internalError(scope, \"update feedback\", error);\n }\n };\n}\n","import type { SitepingHttpMethod } from \"./access.js\";\nimport { CSRF_PROTECTED_METHODS, JSON_MEDIA_TYPE } from \"./constants/http.js\";\nimport { MAX_LOGGED_ORIGIN_LENGTH } from \"./constants/limits.js\";\nimport type { CorsPolicy } from \"./cors.js\";\n\n/**\n * Cross-site request forgery guards of the mutating methods. CORS only hides\n * a response from a foreign page — it never stops a \"simple\" request (e.g. a\n * credentialed `POST` with `Content-Type: text/plain`) from reaching the\n * handler, so both checks run before the body is parsed, the caller is\n * authenticated or any hook fires.\n */\n\n/** Whether `method` changes data and therefore goes through the CSRF guards. */\nexport function isCsrfProtectedMethod(method: SitepingHttpMethod): boolean {\n return CSRF_PROTECTED_METHODS.includes(method);\n}\n\n/**\n * Whether the request's `Origin` may mutate data under `policy`.\n *\n * - No `allowedOrigins` → no origin check (the JSON content-type guard still\n * forces cross-origin browsers through a preflight, which fails without\n * CORS headers).\n * - No `Origin` header → allowed: server-to-server calls, curl and\n * same-origin navigations of older browsers do not send one, and a browser\n * always sends it on cross-origin `POST`/`PATCH`/`DELETE`.\n * - Same origin as the request URL, or listed in `allowedOrigins` → allowed.\n * - Anything else, including the opaque `null` origin → refused.\n */\nexport function isMutationOriginAllowed(request: Request, policy: CorsPolicy): boolean {\n if (!policy.allowedOrigins) return true;\n const origin = request.headers.get(\"Origin\");\n if (origin === null) return true;\n if (policy.allowedOrigins.includes(origin)) return true;\n return origin === new URL(request.url).origin;\n}\n\n/** Whether the request declares a JSON body (`application/json`, parameters such as `charset` allowed). */\nexport function hasJsonContentType(request: Request): boolean {\n const contentType = request.headers.get(\"Content-Type\");\n if (contentType === null) return false;\n const [mediaType = \"\"] = contentType.split(\";\");\n return mediaType.trim().toLowerCase() === JSON_MEDIA_TYPE;\n}\n\n/** The request's `Origin`, truncated and stripped of control characters, safe to log. */\nexport function describeOriginForLog(request: Request): string {\n const origin = request.headers.get(\"Origin\") ?? \"\";\n // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control characters is the point\n return origin.replace(/[\\u0000-\\u001f\\u007f]/g, \"\").slice(0, MAX_LOGGED_ORIGIN_LENGTH);\n}\n","import type { FeedbackRecord } from \"@beezping/core\";\nimport type {\n AccessGate,\n AuthenticationOutcome,\n SitepingAuthorizationContext,\n SitepingHttpMethod,\n SitepingRequestContext,\n} from \"./access.js\";\nimport { SITEPING_ERROR_MESSAGES } from \"./constants/error-messages.js\";\nimport { buildCorsHeaders, type CorsHeaders, type CorsPolicy, withCors } from \"./cors.js\";\nimport { internalErrorResponse } from \"./internal-error.js\";\nimport type { SitepingLogger } from \"./options.js\";\nimport {\n describeOriginForLog,\n hasJsonContentType,\n isCsrfProtectedMethod,\n isMutationOriginAllowed,\n} from \"./request-guards.js\";\nimport { formatValidationErrors } from \"./validation.js\";\n\n/** A request that passed authentication — what every operation works with. */\nexport interface AuthenticatedRequest<Principal> {\n context: SitepingRequestContext<Principal>;\n canReadAuthorEmail: boolean;\n corsHeaders: CorsHeaders;\n}\n\n/** Either a value to continue with, or the response to send right away. */\nexport type PipelineStep<Value> = { ok: true; value: Value } | { ok: false; response: Response };\n\n/** Minimal shape of a zod schema the pipeline validates with. */\ninterface ParseableSchema<Output> {\n safeParse(\n input: unknown,\n ): { success: true; data: Output } | { success: false; error: Parameters<typeof formatValidationErrors>[0] };\n}\n\nexport interface RequestPipelineDependencies<Principal> {\n gate: AccessGate<Principal>;\n corsPolicy: CorsPolicy;\n logger: SitepingLogger;\n describeError: ((error: unknown) => string | undefined) | undefined;\n presentFeedback:\n | ((feedback: FeedbackRecord, context: SitepingRequestContext<Principal>) => FeedbackRecord)\n | undefined;\n}\n\n/**\n * Serialize a record for the wire. `clientId` is always stripped — it is a\n * browser-local dedup secret, and the POST dedup path returns the full\n * record to whoever presents it. `authorEmail` is blanked unless allowed.\n */\nfunction toWireFeedback(feedback: FeedbackRecord, includeEmail: boolean): Omit<FeedbackRecord, \"clientId\"> {\n const { clientId: _clientId, ...wire } = feedback;\n return includeEmail ? wire : { ...wire, authorEmail: \"\" };\n}\n\n/**\n * The steps every HTTP operation shares — authenticate, parse, authorize,\n * respond, report failures — so each operation module only holds its own\n * logic. Every response carries the request's CORS headers.\n */\nexport function createRequestPipeline<Principal>({\n gate,\n corsPolicy,\n logger,\n describeError,\n presentFeedback,\n}: RequestPipelineDependencies<Principal>) {\n const json = (scope: Pick<AuthenticatedRequest<Principal>, \"corsHeaders\">, body: unknown, init?: ResponseInit) =>\n withCors(Response.json(body, init), scope.corsHeaders);\n\n const error = (scope: Pick<AuthenticatedRequest<Principal>, \"corsHeaders\">, status: number, message: string) =>\n json(scope, { error: message }, { status });\n\n /** Validate `input` (a parsed body or query) against `schema`. */\n const validate = <Output>(\n scope: AuthenticatedRequest<Principal>,\n schema: ParseableSchema<Output>,\n input: unknown,\n ): PipelineStep<Output> => {\n const parsed = schema.safeParse(input);\n if (parsed.success) return { ok: true, value: parsed.data };\n return { ok: false, response: json(scope, { errors: formatValidationErrors(parsed.error) }, { status: 400 }) };\n };\n\n /** Log an unexpected failure and answer 500 (with `describeError`'s hint when it has one). */\n const internalError = (request: Request, corsHeaders: CorsHeaders, operation: string, failure: unknown): Response =>\n internalErrorResponse({\n logger,\n describeError,\n source: \"createSitepingHandler\",\n operation,\n request,\n corsHeaders,\n failure,\n });\n\n /** Wire shape of a record for this requester (presentFeedback + email policy). */\n const present = (\n scope: AuthenticatedRequest<Principal>,\n feedback: FeedbackRecord,\n includeEmail = scope.canReadAuthorEmail,\n ) => toWireFeedback(presentFeedback ? presentFeedback(feedback, scope.context) : feedback, includeEmail);\n\n return {\n json,\n error,\n\n /**\n * Refuse forged mutations, then run the access gate. Mutating methods\n * from an origin outside `allowedOrigins` answer 403 and non-JSON bodies\n * 415, before the body is parsed, the caller authenticated or any hook\n * run (see `request-guards.ts`). A throwing `authenticate` (session store\n * down…) answers the logged 500 with CORS headers instead of rejecting\n * the handler, which a browser would only see as an opaque network error.\n */\n async authenticate(\n request: Request,\n method: SitepingHttpMethod,\n ): Promise<PipelineStep<AuthenticatedRequest<Principal>>> {\n const corsHeaders = buildCorsHeaders(request, corsPolicy);\n if (isCsrfProtectedMethod(method)) {\n if (!isMutationOriginAllowed(request, corsPolicy)) {\n logger.error(\"[siteping] createSitepingHandler: refused a mutation from an origin outside allowedOrigins\", {\n method,\n path: new URL(request.url).pathname,\n origin: describeOriginForLog(request),\n });\n return { ok: false, response: error({ corsHeaders }, 403, SITEPING_ERROR_MESSAGES.forbidden) };\n }\n if (!hasJsonContentType(request)) {\n return { ok: false, response: error({ corsHeaders }, 415, SITEPING_ERROR_MESSAGES.unsupportedMediaType) };\n }\n }\n let outcome: AuthenticationOutcome<Principal>;\n try {\n outcome = await gate.authenticate(request, method);\n } catch (failure) {\n return { ok: false, response: internalError(request, corsHeaders, \"authenticate request\", failure) };\n }\n if (!outcome.ok) return { ok: false, response: error({ corsHeaders }, outcome.status, outcome.error) };\n return {\n ok: true,\n value: {\n context: { request, principal: outcome.principal },\n canReadAuthorEmail: outcome.canReadAuthorEmail,\n corsHeaders,\n },\n };\n },\n\n validate,\n\n /** Read and validate a JSON body. */\n async readBody<Output>(\n scope: AuthenticatedRequest<Principal>,\n schema: ParseableSchema<Output>,\n ): Promise<PipelineStep<Output>> {\n const body: unknown = await scope.context.request.json().catch(() => null);\n if (!body) return { ok: false, response: error(scope, 400, SITEPING_ERROR_MESSAGES.invalidJson) };\n return validate(scope, schema, body);\n },\n\n /** `null` when allowed, the 403 response otherwise. */\n async authorize(\n scope: AuthenticatedRequest<Principal>,\n authorization: Omit<SitepingAuthorizationContext<Principal>, keyof SitepingRequestContext<Principal>>,\n ): Promise<Response | null> {\n const allowed = await gate.authorize({ ...scope.context, ...authorization });\n return allowed ? null : error(scope, 403, SITEPING_ERROR_MESSAGES.forbidden);\n },\n\n present,\n\n /**\n * Wire shape of a record answering a `POST` (fresh or replayed): the\n * requester's email policy, except under a gate that echoes the email to\n * its submitter (legacy api-key policy).\n */\n presentCreated(scope: AuthenticatedRequest<Principal>, feedback: FeedbackRecord) {\n return present(scope, feedback, gate.echoesAuthorEmailOnCreate || scope.canReadAuthorEmail);\n },\n\n /** Log an unexpected failure and answer 500 (with `describeError`'s hint when it has one). */\n internalError(scope: AuthenticatedRequest<Principal>, operation: string, failure: unknown): Response {\n return internalError(scope.context.request, scope.corsHeaders, operation, failure);\n },\n\n /** Run a post-write hook; its failure is logged, never surfaced. */\n async runHook(name: string, invoke: () => void | Promise<void>): Promise<void> {\n try {\n await invoke();\n } catch (failure) {\n logger.error(`[siteping] createSitepingHandler: hook ${name} failed`, { error: failure });\n }\n },\n\n logger,\n };\n}\n\nexport type RequestPipeline<Principal> = ReturnType<typeof createRequestPipeline<Principal>>;\n","import { type AccessGate, accessGateFromControl } from \"./access.js\";\nimport { createApiKeyGate } from \"./api-key-access.js\";\nimport { SITEPING_CONFIGURATION_ERROR_MESSAGES } from \"./constants/error-messages.js\";\nimport { CORS_ALLOWED_METHODS } from \"./constants/http.js\";\nimport { createCorsPolicy, preflightResponse } from \"./cors.js\";\nimport { defaultLogger } from \"./internal-error.js\";\nimport { createFeedbackOperation } from \"./operations/create-feedback.js\";\nimport { deleteFeedbackOperation } from \"./operations/delete-feedback.js\";\nimport { listFeedbacksOperation } from \"./operations/list-feedbacks.js\";\nimport { updateFeedbackOperation } from \"./operations/update-feedback.js\";\nimport type {\n SitepingAccessHandlerOptions,\n SitepingApiKeyHandlerOptions,\n SitepingHandler,\n SitepingHandlerBaseOptions,\n SitepingHandlerOptions,\n} from \"./options.js\";\nimport { createRequestPipeline } from \"./request-pipeline.js\";\nimport type { WebhookConfig } from \"./webhooks.js\";\n\nfunction toWebhookList(webhooks: SitepingHandlerBaseOptions<unknown>[\"webhooks\"]): ReadonlyArray<WebhookConfig> {\n if (!webhooks) return [];\n return Array.isArray(webhooks) ? (webhooks as ReadonlyArray<WebhookConfig>) : [webhooks as WebhookConfig];\n}\n\n/**\n * Create the SitePing HTTP API over any `SitepingStore`, using only the\n * Fetch API (`Request` / `Response`) — mount it in Next.js route handlers,\n * Hono, Remix, SvelteKit, Bun/Deno servers or edge workers.\n *\n * Rate limiting is not handled here; apply it at the framework or proxy level.\n *\n * @throws Error when `allowedHeaders` contains an invalid header name.\n * @throws Error when `access.authorize` is set and the store lacks\n * `verifyProjectOwnership` — per-record PATCH/DELETE could otherwise target\n * a record of a project the caller is not authorized for.\n *\n * @example Next.js App Router with your own session auth\n * ```ts\n * export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({\n * store,\n * access: {\n * authenticate: (request) => getSessionUser(request),\n * authorize: ({ principal, action }) => action === \"create\" || principal.isAdmin,\n * },\n * });\n * ```\n */\nexport function createSitepingHandler<Principal>(options: SitepingAccessHandlerOptions<Principal>): SitepingHandler;\nexport function createSitepingHandler(options: SitepingApiKeyHandlerOptions): SitepingHandler;\nexport function createSitepingHandler<Principal>(options: SitepingHandlerOptions<Principal>): SitepingHandler {\n // The api-key branch never resolves a principal (always `null`), so both\n // branches share the callbacks typed over `Principal`.\n const {\n store,\n allowedOrigins,\n allowedHeaders,\n beforeCreate,\n presentFeedback,\n hooks = {},\n logger = defaultLogger,\n describeError,\n } = options as SitepingHandlerBaseOptions<Principal>;\n if (!store) {\n throw new Error(\"[siteping] createSitepingHandler requires a `store`.\");\n }\n // A custom `authorize` may scope principals to projects, but PATCH/DELETE\n // address records by id: without an ownership check, the project a caller\n // claims (and is authorized for) need not be the record's. Fail closed at\n // startup rather than letting per-record mutations cross projects.\n if (options.access?.authorize && !store.verifyProjectOwnership) {\n throw new Error(SITEPING_CONFIGURATION_ERROR_MESSAGES.ownershipVerificationRequired);\n }\n const corsPolicy = createCorsPolicy({ allowedOrigins, allowedHeaders, allowedMethods: CORS_ALLOWED_METHODS });\n const gate: AccessGate<Principal> = options.access\n ? accessGateFromControl(options.access)\n : (createApiKeyGate(options) as AccessGate<Principal>);\n const pipeline = createRequestPipeline<Principal>({ gate, corsPolicy, logger, describeError, presentFeedback });\n\n return {\n /** CORS preflight. Configure `allowedOrigins` (and `allowedHeaders`) for cross-origin widgets. */\n OPTIONS: (request: Request): Response => preflightResponse(request, corsPolicy),\n POST: createFeedbackOperation({\n store,\n pipeline,\n beforeCreate,\n onCreated: hooks.onCreated?.bind(hooks),\n webhooks: toWebhookList(options.webhooks),\n }),\n GET: listFeedbacksOperation({ store, pipeline }),\n PATCH: updateFeedbackOperation({ store, pipeline, onUpdated: hooks.onUpdated?.bind(hooks) }),\n DELETE: deleteFeedbackOperation({\n store,\n pipeline,\n onDeleting: hooks.onDeleting?.bind(hooks),\n onDeleted: hooks.onDeleted?.bind(hooks),\n }),\n };\n}\n","import type { SitepingAccessControl } from \"./access.js\";\nimport { IDENTITY_CACHE_CONTROL, IDENTITY_CORS_ALLOWED_METHODS } from \"./constants/http.js\";\nimport { buildCorsHeaders, createCorsPolicy, preflightResponse, withCors } from \"./cors.js\";\nimport { defaultLogger, internalErrorResponse } from \"./internal-error.js\";\nimport type { SitepingLogger } from \"./options.js\";\n\n/** Reviewer identity the widget pre-fills (`SitepingConfig.identity`). */\nexport interface SitepingIdentity {\n name: string;\n email: string;\n}\n\n/**\n * Body of the identity endpoint. The host page fetches it to decide whether\n * to mount the widget and with which identity/project:\n *\n * ```ts\n * const { enabled, identity, projectName } = await (await fetch(\"/api/siteping/identity\")).json();\n * if (enabled && identity) initSiteping({ endpoint, projectName, identity, forceShow: true });\n * ```\n */\nexport interface SitepingIdentityResponse {\n enabled: boolean;\n identity: SitepingIdentity | null;\n projectName: string;\n}\n\nexport interface SitepingIdentityHandlerOptions<Principal> {\n /** Same `authenticate` as the feedback handler's `access`. */\n access: Pick<SitepingAccessControl<Principal>, \"authenticate\">;\n /** Map the principal to the identity shown in the widget; `null` hides the widget. */\n resolveIdentity(principal: Principal): SitepingIdentity | null | Promise<SitepingIdentity | null>;\n /** Project the widget reports into. */\n projectName: string;\n /**\n * Feature flag — globally (`boolean`) or per principal (e.g. an allowlist\n * of reviewers). Defaults to enabled for every resolved identity.\n */\n enabled?: boolean | ((principal: Principal) => boolean | Promise<boolean>);\n /** Allowed CORS origins, as in `createSitepingHandler`. */\n allowedOrigins?: ReadonlyArray<string> | undefined;\n /**\n * Extra request headers cross-origin callers may send, as in\n * `createSitepingHandler` — list the headers `access.authenticate` reads.\n */\n allowedHeaders?: ReadonlyArray<string> | undefined;\n /**\n * Receives failures of `authenticate`, `enabled` or `resolveIdentity`\n * (answered with a generic 500); defaults to `console.error`.\n */\n logger?: SitepingLogger | undefined;\n}\n\n/** Handlers of the identity endpoint — mount both on any Fetch-API router. */\nexport interface SitepingIdentityHandler {\n /** CORS preflight — needed as soon as the identity request carries a non-simple header (e.g. `Authorization`). */\n OPTIONS: (request: Request) => Response;\n GET: (request: Request) => Promise<Response>;\n}\n\n/**\n * `GET` endpoint telling the host page whether the current visitor may give\n * feedback, and as whom. Anonymous or disabled visitors get\n * `{ enabled: false, identity: null }` with 200 — the page simply does not\n * mount the widget. Responses are `no-store`: they depend on the session.\n * `OPTIONS` answers the CORS preflight of cross-origin callers with the\n * same `allowedOrigins` / `allowedHeaders` policy. A throwing callback\n * (session store down…) is logged and answered with a JSON 500 that keeps\n * the CORS headers, so cross-origin callers can read the failure.\n *\n * @throws Error when `allowedHeaders` contains an invalid header name.\n */\nexport function createSitepingIdentityHandler<Principal>({\n access,\n resolveIdentity,\n projectName,\n enabled = true,\n allowedOrigins,\n allowedHeaders,\n logger = defaultLogger,\n}: SitepingIdentityHandlerOptions<Principal>): SitepingIdentityHandler {\n const corsPolicy = createCorsPolicy({\n allowedOrigins,\n allowedHeaders,\n allowedMethods: IDENTITY_CORS_ALLOWED_METHODS,\n });\n const respond = (request: Request, body: SitepingIdentityResponse): Response =>\n withCors(\n Response.json(body, { headers: { \"Cache-Control\": IDENTITY_CACHE_CONTROL } }),\n buildCorsHeaders(request, corsPolicy),\n );\n const disabled = (request: Request) => respond(request, { enabled: false, identity: null, projectName });\n\n const resolveResponse = async (request: Request): Promise<Response> => {\n const principal = await access.authenticate(request);\n if (principal === null) return disabled(request);\n const isEnabled = typeof enabled === \"function\" ? await enabled(principal) : enabled;\n if (!isEnabled) return disabled(request);\n const identity = await resolveIdentity(principal);\n if (!identity) return disabled(request);\n return respond(request, { enabled: true, identity, projectName });\n };\n\n return {\n OPTIONS: (request: Request): Response => preflightResponse(request, corsPolicy),\n GET: async (request: Request): Promise<Response> => {\n try {\n return await resolveResponse(request);\n } catch (failure) {\n return internalErrorResponse({\n logger,\n source: \"createSitepingIdentityHandler\",\n operation: \"resolve identity\",\n request,\n corsHeaders: buildCorsHeaders(request, corsPolicy),\n headers: { \"Cache-Control\": IDENTITY_CACHE_CONTROL },\n failure,\n });\n }\n },\n };\n}\n"],"mappings":"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","names":["createSitepingHandler","isRecord","hasOwn","FEEDBACK_TYPES","FEEDBACK_STATUSES","CLOSED_FEEDBACK_STATUSES","isClosedStatus","toFeedbackUpdate","StoreNotFoundError","StoreDuplicateError","hasErrorCode","isStoreNotFound","isStoreDuplicate","flattenAnnotation","CONSOLE_DIAGNOSTIC_LEVELS","z","createSitepingHandler"]}