@bygghammar/cms-platform 0.94.0 → 0.96.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/docs/PAGES.md +72 -4
  3. package/package.json +8 -1
  4. package/src/admin/describe-log.ts +3 -0
  5. package/src/backoffice/BookingSettingsCard.tsx +252 -0
  6. package/src/backoffice/NavigationView.tsx +38 -0
  7. package/src/backoffice/PageBuilderView.tsx +149 -3
  8. package/src/backoffice/PageEditorView.tsx +47 -24
  9. package/src/backoffice/PagesListView.tsx +173 -57
  10. package/src/backoffice/SiteSettingsView.tsx +4 -0
  11. package/src/backoffice/StatusBadge.tsx +38 -2
  12. package/src/backoffice/media/ConfirmDialog.tsx +19 -1
  13. package/src/backoffice/routes/host.ts +24 -0
  14. package/src/backoffice/routes/index.tsx +54 -0
  15. package/src/backoffice/routes/pages.tsx +13 -0
  16. package/src/cms/booking-links.ts +176 -0
  17. package/src/cms/page-publication.ts +11 -6
  18. package/src/cms/page-visibility.ts +206 -0
  19. package/src/forms/form-audit.ts +22 -2
  20. package/src/html/html-tokenizer.ts +254 -0
  21. package/src/html/sanitize-html.ts +72 -37
  22. package/src/navigation/visibility.ts +235 -0
  23. package/src/popups/runtime.tsx +155 -4
  24. package/src/server/forms-core.ts +9 -0
  25. package/src/server/forms-delivery-core.ts +29 -22
  26. package/src/server/forms-submissions-core.ts +8 -5
  27. package/src/server/media-usage-core.ts +53 -13
  28. package/src/server/outbound-address.ts +131 -0
  29. package/src/server/outbound-http.ts +565 -0
  30. package/src/server/page-visibility-core.ts +450 -0
  31. package/src/server/pages-core.ts +238 -9
  32. package/src/server/popup-optin-webhook-core.ts +79 -140
  33. package/src/server/published-cache.ts +23 -3
  34. package/src/server/schemas.ts +17 -0
  35. package/src/site/PageRenderer.tsx +34 -5
  36. package/src/site/SiteFooter.tsx +11 -2
  37. package/src/site/SiteHeader.tsx +41 -21
  38. package/src/site/link-visibility.tsx +28 -0
  39. package/src/site/preview-messaging.ts +232 -0
  40. package/src/site/route-aliases.ts +74 -0
  41. package/src/site/routes/preview-page.tsx +75 -31
  42. package/src/site/routes/public-page.tsx +81 -9
  43. package/src/util/redact-url.ts +18 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Which saved menu and footer links a visitor is shown right now.
3
+ *
4
+ * Saved navigation is never edited by a page changing state: an item pointing
5
+ * at a page that is offline or link-only is simply not rendered, and comes back
6
+ * by itself when the page is published or listed again. The same goes for
7
+ * booking links while booking is switched off.
8
+ *
9
+ * Only links positively known to point at such a page are hidden. External
10
+ * links, `#fragment` links, `mailto:`/`tel:` and every internal address the CMS
11
+ * does not know about are left alone — an address missing from the CMS list is
12
+ * a route the site serves some other way, not an offline page.
13
+ */
14
+ import { isBookingHref } from "../cms/booking-links";
15
+ import type { HiddenPageReason } from "../cms/page-visibility";
16
+ import { normalizeOrigin, normalizeRoutePath } from "../site/route-index";
17
+ import type { NavigationItem } from "./index";
18
+
19
+ /** Why a saved link is not shown. */
20
+ export type HiddenLinkReason = HiddenPageReason | "booking" | "unknown";
21
+
22
+ export const HIDDEN_LINK_REASONS: Record<HiddenLinkReason, string> = {
23
+ offline: "Not shown on the site: the page it links to is offline.",
24
+ "link-only": "Not shown on the site: the page it links to is link-only.",
25
+ booking: "Not shown on the site: booking is switched off in Site settings.",
26
+ unknown: "Not shown right now: the site could not read which pages are public.",
27
+ };
28
+
29
+ /**
30
+ * What the chrome needs to decide, in a serializable shape a server can hand
31
+ * to the browser with the page.
32
+ */
33
+ export type LinkVisibilityPolicy = {
34
+ /** False when the server could not read the policy: see {@link linkHideReason}. */
35
+ known: boolean;
36
+ /** Booking links in site-wide chrome (menu, footer, header button). */
37
+ bookingVisible: boolean;
38
+ /** Normalized served paths of unlisted pages, every locale and alias. */
39
+ hiddenPaths: Readonly<Record<string, HiddenPageReason>>;
40
+ /** Origins that are this site, for absolute links written with the domain. */
41
+ origins: readonly string[];
42
+ /** Locale prefixes, so `/EN/...` and `/en/...` compare equal. */
43
+ locales?: readonly string[];
44
+ };
45
+
46
+ /** No filtering at all: what screens without a public policy render with. */
47
+ export const OPEN_LINK_POLICY: LinkVisibilityPolicy = Object.freeze({
48
+ known: true,
49
+ bookingVisible: true,
50
+ hiddenPaths: Object.freeze({}),
51
+ origins: Object.freeze([]) as readonly string[],
52
+ });
53
+
54
+ /**
55
+ * The site path an address leads to, or null when it leads off the site (or
56
+ * is not a navigable path at all).
57
+ */
58
+ export function internalPathOf(
59
+ href: string,
60
+ policy: Pick<LinkVisibilityPolicy, "origins" | "locales">,
61
+ ): string | null {
62
+ const value = href.trim();
63
+ if (!value || value.startsWith("#")) return null;
64
+ const pathPolicy = { locales: policy.locales ?? [] };
65
+ if (value.startsWith("/") && !value.startsWith("//")) {
66
+ return normalizeRoutePath(value, pathPolicy);
67
+ }
68
+ const absolute = value.startsWith("//") ? `https:${value}` : value;
69
+ if (!/^https?:\/\//i.test(absolute)) return null;
70
+ let url: URL;
71
+ try {
72
+ url = new URL(absolute);
73
+ } catch {
74
+ return null;
75
+ }
76
+ const own = new Set(policy.origins.map((origin) => normalizeOrigin(origin)).filter(Boolean));
77
+ if (!own.has(url.origin)) return null;
78
+ return normalizeRoutePath(url.pathname, pathPolicy);
79
+ }
80
+
81
+ /** Why this saved link is hidden, or null when it is shown. */
82
+ export function linkHideReason(href: string, policy: LinkVisibilityPolicy): HiddenLinkReason | null {
83
+ if (!policy.bookingVisible && isBookingHref(href)) return "booking";
84
+ const path = internalPathOf(href, policy);
85
+ if (path === null) return null;
86
+ /*
87
+ * A policy that could not be read hides every internal page link except the
88
+ * front page, rather than showing links to pages that may be offline or
89
+ * link-only. The pages themselves come from the same database, so a read
90
+ * that failed here is rarely the only thing failing.
91
+ */
92
+ if (!policy.known) return path === "/" ? null : "unknown";
93
+ return policy.hiddenPaths[path] ?? null;
94
+ }
95
+
96
+ /**
97
+ * Navigation with hidden links removed, order preserved.
98
+ *
99
+ * A hidden entry is removed with its submenu. A submenu whose entries are all
100
+ * hidden is removed and its parent stays an ordinary link — never an empty
101
+ * dropdown.
102
+ */
103
+ export function filterNavigationForPolicy(
104
+ items: readonly NavigationItem[] | undefined,
105
+ policy: LinkVisibilityPolicy,
106
+ ): NavigationItem[] {
107
+ const out: NavigationItem[] = [];
108
+ for (const item of items ?? []) {
109
+ if (linkHideReason(item.href ?? "", policy) !== null) continue;
110
+ if (!item.children || item.children.length === 0) {
111
+ out.push(item);
112
+ continue;
113
+ }
114
+ const children = filterNavigationForPolicy(item.children, policy);
115
+ out.push(children.length > 0 ? { ...item, children } : { ...item, children: undefined });
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** A footer column as the visibility filter needs to see it. */
121
+ type FooterColumnLike = { links: ReadonlyArray<{ href: string }> };
122
+
123
+ /** Footer columns with hidden links removed; a column left with no links is dropped. */
124
+ export function filterFooterColumnsForPolicy<C extends FooterColumnLike>(
125
+ columns: readonly C[] | undefined,
126
+ policy: LinkVisibilityPolicy,
127
+ ): C[] {
128
+ const out: C[] = [];
129
+ for (const column of columns ?? []) {
130
+ const links = column.links.filter((link) => linkHideReason(link.href ?? "", policy) === null);
131
+ if (links.length === 0 && column.links.length > 0) continue;
132
+ out.push(links.length === column.links.length ? column : { ...column, links });
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** A saved menu or footer link that is not shown, with the reason. */
138
+ export type HiddenSavedLink = { label: string; href: string; reason: HiddenLinkReason };
139
+
140
+ function labelText(label: unknown): string {
141
+ if (typeof label === "string") return label;
142
+ if (label && typeof label === "object") {
143
+ const first = Object.values(label as Record<string, unknown>).find(
144
+ (value) => typeof value === "string" && value.trim() !== "",
145
+ );
146
+ if (typeof first === "string") return first;
147
+ }
148
+ return "(no label)";
149
+ }
150
+
151
+ /**
152
+ * Every saved link in a menu (`nav`, an item list) or footer (`footer`, with
153
+ * `columns`) that the site is not showing right now, and why. For the editor,
154
+ * which explains rather than deletes: the saved item stays exactly as it is.
155
+ */
156
+ export function hiddenSavedLinks(
157
+ section: "nav" | "footer",
158
+ value: unknown,
159
+ policy: LinkVisibilityPolicy,
160
+ ): HiddenSavedLink[] {
161
+ const out: HiddenSavedLink[] = [];
162
+ const visit = (label: unknown, href: unknown) => {
163
+ if (typeof href !== "string" || href.trim() === "") return;
164
+ const reason = linkHideReason(href, policy);
165
+ if (reason) out.push({ label: labelText(label), href, reason });
166
+ };
167
+ if (section === "nav") {
168
+ const walk = (items: unknown) => {
169
+ if (!Array.isArray(items)) return;
170
+ for (const item of items as Array<Record<string, unknown>>) {
171
+ visit(item?.["label"], item?.["href"]);
172
+ walk(item?.["children"]);
173
+ }
174
+ };
175
+ walk(value);
176
+ } else {
177
+ const columns = (value as { columns?: unknown } | null)?.columns;
178
+ if (Array.isArray(columns)) {
179
+ for (const column of columns as Array<{ links?: unknown }>) {
180
+ if (!Array.isArray(column?.links)) continue;
181
+ for (const link of column.links as Array<Record<string, unknown>>) {
182
+ visit(link?.["label"], link?.["href"]);
183
+ }
184
+ }
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+
190
+ /** Every `href` in a menu (items with children) or a footer (`{ columns: [{ links }] }`). */
191
+ export function savedLinkHrefs(section: "nav" | "footer", value: unknown): string[] {
192
+ const out: string[] = [];
193
+ const add = (href: unknown) => {
194
+ if (typeof href === "string" && href.trim() !== "") out.push(href);
195
+ };
196
+ if (section === "nav") {
197
+ const walk = (items: unknown) => {
198
+ if (!Array.isArray(items)) return;
199
+ for (const item of items as Array<Record<string, unknown>>) {
200
+ add(item?.["href"]);
201
+ walk(item?.["children"]);
202
+ }
203
+ };
204
+ walk(value);
205
+ } else {
206
+ const columns = (value as { columns?: unknown } | null)?.columns;
207
+ if (Array.isArray(columns)) {
208
+ for (const column of columns as Array<{ links?: unknown }>) {
209
+ if (!Array.isArray(column?.links)) continue;
210
+ for (const link of column.links as Array<Record<string, unknown>>) add(link?.["href"]);
211
+ }
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+
217
+ /**
218
+ * The policy cut down to the addresses actually linked from `hrefs`.
219
+ *
220
+ * A policy sent to the browser with the page must not list every unlisted
221
+ * page: that would publish the address of each link-only page in the source of
222
+ * every other page. The chrome only needs answers for the links it has, and
223
+ * those addresses are already in the content that is sent anyway.
224
+ */
225
+ export function restrictToLinks(
226
+ policy: LinkVisibilityPolicy,
227
+ hrefs: readonly string[],
228
+ ): LinkVisibilityPolicy {
229
+ const hiddenPaths: Record<string, LinkVisibilityPolicy["hiddenPaths"][string]> = {};
230
+ for (const href of hrefs) {
231
+ const path = internalPathOf(href, policy);
232
+ if (path !== null && policy.hiddenPaths[path]) hiddenPaths[path] = policy.hiddenPaths[path]!;
233
+ }
234
+ return { ...policy, hiddenPaths };
235
+ }
@@ -10,7 +10,7 @@
10
10
  * design tokens — so neither the popup package nor the site has to know about
11
11
  * the other.
12
12
  */
13
- import { useCallback, useEffect, useMemo, useRef } from "react";
13
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
14
14
  import type { ReactNode } from "react";
15
15
  import { useRouterState } from "@tanstack/react-router";
16
16
  import {
@@ -25,6 +25,8 @@ import type {
25
25
  import { ManagedForm } from "../forms/FormRenderer";
26
26
  import { onFormSubmissionSuccess } from "../forms/success-listeners";
27
27
  import { siteConfig } from "../config/site-config";
28
+ import { containsBookingTarget, isBookingHref, stripBookingDeep } from "../cms/booking-links";
29
+ import { useLinkVisibility } from "../site/link-visibility";
28
30
  import type { PopupPublicHost } from "./host";
29
31
  import { popupPublicHost } from "./host";
30
32
 
@@ -95,6 +97,63 @@ export function popupExcludedPaths(
95
97
  export function PopupModuleRuntime(): ReactNode {
96
98
  const path = useRouterState({ select: (state) => state.location.pathname });
97
99
  const host = popupPublicHost();
100
+ /*
101
+ * The runtime keeps the popups it was handed until its `configs` adapter
102
+ * changes, and keeps everything else — the popup on screen, the form in it,
103
+ * the visitor's counts, its timers and its analytics queue. It is never
104
+ * remounted, which would throw away a newsletter form a visitor is typing in.
105
+ *
106
+ * What it is handed is decided here, in render, from what this module last
107
+ * had from the site's repository and the policy now. So a change of policy
108
+ * restricts what the runtime holds at once: before the refresh it also
109
+ * starts has answered, and whatever becomes of it — a slow or failed read
110
+ * changes nothing that booking-off takes away. A read only ever brings what
111
+ * it found through the same rules, and only the latest read counts, so one
112
+ * begun under the old policy cannot bring booking back. Without a policy
113
+ * provider booking is visible and nothing changes.
114
+ */
115
+ const bookingVisible = useLinkVisibility().bookingVisible;
116
+ const [loaded, setLoaded] = useState<readonly PopupConfig[] | null>(null);
117
+ const offered = useRef<Offered>(NOTHING_OFFERED);
118
+ const offer = useMemo(
119
+ () => popupsToOffer(loaded ?? [], offered.current, bookingVisible),
120
+ // `offered` is what the runtime was last handed, read for identity only.
121
+ [loaded, bookingVisible],
122
+ );
123
+ useEffect(() => {
124
+ offered.current = offer;
125
+ }, [offer]);
126
+
127
+ // The repository is asked again when the host or the policy changes — and
128
+ // only once the runtime asks for its popups, so never where it does not run.
129
+ const askedFor = useRef<{ configs: PopupPublicHost["configs"]; bookingVisible: boolean } | null>(null);
130
+ const latestRead = useRef(0);
131
+ const configs = useMemo<RuntimeAdapters["configs"]>(
132
+ () => ({
133
+ listActive: async () => {
134
+ const asked = askedFor.current;
135
+ if (asked === null || asked.configs !== host.configs || asked.bookingVisible !== bookingVisible) {
136
+ askedFor.current = { configs: host.configs, bookingVisible };
137
+ const read = ++latestRead.current;
138
+ Promise.resolve()
139
+ .then(() => host.configs.listActive())
140
+ .then(
141
+ (answer) => {
142
+ if (read !== latestRead.current) return;
143
+ setLoaded((previous) =>
144
+ previous !== null && JSON.stringify(previous) === JSON.stringify(answer) ? previous : answer,
145
+ );
146
+ },
147
+ // A failed read changes nothing: what the runtime holds already
148
+ // follows the policy, and stays as it is until a read succeeds.
149
+ () => undefined,
150
+ );
151
+ }
152
+ return offer.list;
153
+ },
154
+ }),
155
+ [host.configs, bookingVisible, offer],
156
+ );
98
157
 
99
158
  // The argument type comes from the port, not a copy of it: a package that
100
159
  // stopped supplying `popupKey` would fail to compile here rather than
@@ -105,14 +164,15 @@ export function PopupModuleRuntime(): ReactNode {
105
164
  ),
106
165
  [],
107
166
  );
167
+ const forms = useMemo(() => ({ renderForm }), [renderForm]);
108
168
 
109
169
  const adapters = useMemo<RuntimeAdapters>(
110
170
  () => ({
111
- configs: host.configs,
171
+ configs,
112
172
  analytics: host.analytics,
113
- forms: { renderForm },
173
+ forms,
114
174
  }),
115
- [host, renderForm],
175
+ [configs, host, forms],
116
176
  );
117
177
 
118
178
  return (
@@ -124,5 +184,96 @@ export function PopupModuleRuntime(): ReactNode {
124
184
  );
125
185
  }
126
186
 
187
+ type PopupConfig = Awaited<ReturnType<RuntimeAdapters["configs"]["listActive"]>>[number];
188
+ type ContentBlock = PopupConfig["steps"][number]["blocks"][number];
189
+ type PopupButton = Extract<ContentBlock, { type: "buttons" }>["buttons"][number];
190
+ type BlockAction = PopupButton["action"];
191
+
192
+ /** The list last handed to the runtime, and each popup's content, by key. */
193
+ type Offered = { list: readonly PopupConfig[]; content: ReadonlyMap<string, string> };
194
+ const NOTHING_OFFERED: Offered = { list: [], content: new Map() };
195
+
196
+ /**
197
+ * What the runtime is handed from a load, given what it already holds.
198
+ *
199
+ * - While booking is hidden, a popup that leads to booking is not offered: it
200
+ * is handed over retired (see {@link retire}).
201
+ * - A popup the runtime already holds is never dropped, even when the load no
202
+ * longer has it (a server that withholds booking popups, a popup paused or
203
+ * deleted since). The runtime tracks the open popup by key, so dropping the
204
+ * open one would leave it unclosable and stop every other popup for the
205
+ * rest of the visit. It stays, retired.
206
+ * - A popup whose content has not changed is handed over as the very object
207
+ * the runtime already has. The popup on screen then re-renders nothing:
208
+ * its focus trap is not re-installed (that would pull focus out of the
209
+ * field being typed in), and a list with nothing new is the same list, so
210
+ * the runtime's schedule is not even restarted.
211
+ */
212
+ function popupsToOffer(
213
+ loaded: readonly PopupConfig[],
214
+ previous: Offered,
215
+ bookingVisible: boolean,
216
+ ): Offered {
217
+ const held = new Map(previous.list.map((popup) => [popup.key, popup]));
218
+ const content = new Map<string, string>();
219
+ const list: PopupConfig[] = [];
220
+ const offer = (popup: PopupConfig) => {
221
+ const text = JSON.stringify(popup);
222
+ const same = held.get(popup.key);
223
+ content.set(popup.key, text);
224
+ list.push(same !== undefined && previous.content.get(popup.key) === text ? same : popup);
225
+ };
226
+ for (const popup of loaded) {
227
+ offer(bookingVisible || !containsBookingTarget(popup) ? popup : retire(popup, true));
228
+ }
229
+ for (const popup of previous.list) {
230
+ if (!content.has(popup.key)) offer(retire(popup, !bookingVisible));
231
+ }
232
+ const unchanged =
233
+ list.length === previous.list.length && list.every((popup, index) => popup === previous.list[index]);
234
+ return { list: unchanged ? previous.list : list, content };
235
+ }
236
+
237
+ /**
238
+ * A popup the runtime keeps but never offers again.
239
+ *
240
+ * Paused, which the runtime neither shows nor reports as suppressed. With
241
+ * `hideBooking`, everything in it that leads to booking is taken out too —
242
+ * links, buttons, an answer or a form that would open the booking site now
243
+ * simply closes — so if it is the one on screen, the visitor keeps what they
244
+ * were reading, cannot reach booking from it, and closes it the usual way.
245
+ * The stored popup is not touched; this is the copy the visitor sees.
246
+ */
247
+ function retire(popup: PopupConfig, hideBooking: boolean): PopupConfig {
248
+ const paused: PopupConfig = { ...popup, status: "paused" };
249
+ if (!hideBooking) return paused;
250
+ return stripBookingDeep({
251
+ ...paused,
252
+ steps: paused.steps.map((step) => ({ ...step, blocks: step.blocks.flatMap(withoutBookingTargets) })),
253
+ });
254
+ }
255
+
256
+ function withoutBookingTargets(block: ContentBlock): ContentBlock[] {
257
+ switch (block.type) {
258
+ case "link":
259
+ return isBookingHref(block.href) ? [] : [block];
260
+ case "buttons": {
261
+ const buttons = block.buttons.filter((button) => !opensBooking(button.action));
262
+ return buttons.length === 0 ? [] : [{ ...block, buttons }];
263
+ }
264
+ case "choice":
265
+ return [{ ...block, yes: closingInstead(block.yes), no: closingInstead(block.no) }];
266
+ case "form":
267
+ return [opensBooking(block.onSuccess) ? { ...block, onSuccess: { kind: "close" } } : block];
268
+ default:
269
+ return [block];
270
+ }
271
+ }
272
+
273
+ const opensBooking = (action: BlockAction) => action.kind === "openUrl" && isBookingHref(action.href);
274
+
275
+ const closingInstead = (button: PopupButton): PopupButton =>
276
+ opensBooking(button.action) ? { ...button, action: { kind: "close" } } : button;
277
+
127
278
  // Default export so the module host can load this file lazily.
128
279
  export default PopupModuleRuntime;
@@ -11,6 +11,7 @@
11
11
  * - a field key that already has stored answers can never be removed
12
12
  * - snapshots and audit entries never block the write they describe
13
13
  */
14
+ import { checkWebhookUrl } from "./webhook-url-guard";
14
15
  import {
15
16
  defaultFieldsFor,
16
17
  mergeFields,
@@ -410,6 +411,14 @@ export async function saveFormCore(
410
411
  }
411
412
  }
412
413
 
414
+ // A webhook address is checked when it is saved, not first when a visitor's
415
+ // submission tries to reach it (where it is checked again, on every hop).
416
+ const webhookUrl = (data.private_settings as { webhookUrl?: unknown }).webhookUrl;
417
+ if (typeof webhookUrl === "string" && webhookUrl.trim() !== "") {
418
+ const checked = checkWebhookUrl(webhookUrl);
419
+ if (!checked.ok) throw new Error(`Webhook URL: ${checked.message}`);
420
+ }
421
+
413
422
  const row = {
414
423
  key: data.key,
415
424
  name: data.name,
@@ -13,6 +13,8 @@
13
13
  */
14
14
 
15
15
  import type { FormFieldDef } from "../forms/field-sets";
16
+ import { safeRequest, WEBHOOK_POLICY, type Resolver, type Transport } from "./outbound-http";
17
+ import { checkWebhookUrl } from "./webhook-url-guard";
16
18
  import { sendViaProvider } from "./email-send-core";
17
19
  import { logEmail } from "./email-log-core";
18
20
  import { appendGoogleSheetRow, DEFAULT_WORKSHEET } from "./google-sheets-core";
@@ -489,6 +491,11 @@ export async function appendToSheetCore(opts: {
489
491
  * POST one submission to an external endpoint (CRM, Zapier, Make…).
490
492
  * Signed with an optional shared secret so the receiver can verify origin.
491
493
  * Never throws: the submission is already stored and must never be lost.
494
+ *
495
+ * The endpoint is reached only through the shared outbound policy: https
496
+ * only, public addresses only (looked up, pinned, every redirect hop checked),
497
+ * one deadline and a capped reply. A failure says what kind it was; the
498
+ * endpoint's own reply is never copied into it.
492
499
  */
493
500
  export async function postWebhookCore(opts: {
494
501
  url: string;
@@ -501,11 +508,13 @@ export async function postWebhookCore(opts: {
501
508
  createdAt: string;
502
509
  payload: Record<string, string>;
503
510
  timeoutMs?: number;
511
+ /** Test seams: a fake resolver and transport instead of the network. */
512
+ network?: { resolver?: Resolver; transport?: Transport };
504
513
  }): Promise<DeliveryResult & { status?: number }> {
505
514
  const url = (opts.url ?? "").trim();
506
515
  if (!url) return { ok: false, error: "No webhook URL configured." };
507
- if (!/^https:\/\//i.test(url))
508
- return { ok: false, error: "Webhook URL must start with https://" };
516
+ const checked = checkWebhookUrl(url);
517
+ if (!checked.ok) return { ok: false, error: checked.message };
509
518
 
510
519
  const body = JSON.stringify({
511
520
  event: "form.submission",
@@ -530,28 +539,26 @@ export async function postWebhookCore(opts: {
530
539
  const { createHmac } = await import("crypto");
531
540
  headers["X-Signature"] = createHmac("sha256", secret).update(body).digest("hex");
532
541
  } catch {
533
- /* signing unavailable — send unsigned rather than dropping the delivery */
542
+ // A receiver that was promised a signature must never get an unsigned
543
+ // request it cannot tell from a forgery.
544
+ return { ok: false, error: "Webhook not sent: it could not be signed." };
534
545
  }
535
546
  }
536
547
 
537
- const controller = new AbortController();
538
- const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 10_000);
539
- try {
540
- const res = await fetch(url, {
541
- method: "POST",
542
- headers,
543
- body,
544
- signal: controller.signal,
545
- });
546
- if (!res.ok) {
547
- const text = (await res.text().catch(() => "")).slice(0, 300);
548
- return { ok: false, status: res.status, error: `Webhook [${res.status}]: ${text}` };
549
- }
550
- return { ok: true, status: res.status };
551
- } catch (e) {
552
- const msg = e instanceof Error ? e.message : String(e);
553
- return { ok: false, error: msg === "The operation was aborted." ? "Webhook timed out." : msg };
554
- } finally {
555
- clearTimeout(timer);
548
+ const res = await safeRequest({
549
+ url: checked.url.toString(),
550
+ method: "POST",
551
+ headers,
552
+ body,
553
+ policy: { ...WEBHOOK_POLICY, timeoutMs: opts.timeoutMs ?? WEBHOOK_POLICY.timeoutMs },
554
+ resolver: opts.network?.resolver,
555
+ transport: opts.network?.transport,
556
+ });
557
+ if (!res.ok) {
558
+ return { ok: false, error: res.failure === "timeout" ? "Webhook timed out." : `Webhook not delivered: ${res.message}` };
559
+ }
560
+ if (res.status < 200 || res.status >= 300) {
561
+ return { ok: false, status: res.status, error: `Webhook answered ${res.status}.` };
556
562
  }
563
+ return { ok: true, status: res.status };
557
564
  }
@@ -34,6 +34,7 @@ import {
34
34
  type FormsDb,
35
35
  } from "./forms-core";
36
36
  import { auditAs } from "./audit-core";
37
+ import { redactUrl } from "./outbound-http";
37
38
  import {
38
39
  evaluateRule,
39
40
  isFieldVisible,
@@ -358,7 +359,7 @@ export async function submitFormCore(admin: FormsDb, data: SubmitInput, meta: Re
358
359
  formKey: form.key,
359
360
  channel: "webhook",
360
361
  ok: res.ok,
361
- target: eff.webhookUrl,
362
+ target: redactUrl(eff.webhookUrl),
362
363
  error: res.error,
363
364
  durationMs: Date.now() - startedAt,
364
365
  meta: { status: res.status ?? null },
@@ -1170,10 +1171,12 @@ export async function deliverChannelCore(
1170
1171
  })
1171
1172
  .eq("id", sub.id);
1172
1173
  } else if (channel === "webhook") {
1173
- target = toOverride?.trim() || eff.webhookUrl;
1174
- if (!target) throw new Error(`No webhook URL configured for "${form.key}".`);
1174
+ const webhookUrl = toOverride?.trim() || eff.webhookUrl;
1175
+ if (!webhookUrl) throw new Error(`No webhook URL configured for "${form.key}".`);
1176
+ // Recorded without its query string, which may carry the receiver's key.
1177
+ target = redactUrl(webhookUrl);
1175
1178
  result = await postWebhookCore({
1176
- url: target,
1179
+ url: webhookUrl,
1177
1180
  secret: eff.webhookSecret,
1178
1181
  formKey: form.key,
1179
1182
  formName: form.name,
@@ -1385,7 +1388,7 @@ export async function testWebhookCore(
1385
1388
  formKey: form.key,
1386
1389
  channel: "webhook",
1387
1390
  ok: res.ok,
1388
- target: url,
1391
+ target: redactUrl(url),
1389
1392
  error: res.error,
1390
1393
  durationMs: Date.now() - startedAt,
1391
1394
  meta: { test: true, status: res.status ?? null },