@bygghammar/cms-platform 0.95.0 → 0.97.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 (42) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/docs/PAGES.md +45 -2
  3. package/package.json +3 -1
  4. package/src/admin/describe-log.ts +2 -0
  5. package/src/backoffice/BookingLandingSection.tsx +672 -0
  6. package/src/backoffice/BookingSettingsCard.tsx +34 -4
  7. package/src/backoffice/PageEditorView.tsx +47 -24
  8. package/src/backoffice/routes/host.ts +7 -0
  9. package/src/backoffice/routes/index.tsx +22 -3
  10. package/src/blocks/components/CountdownBlock.tsx +3 -1
  11. package/src/blocks/components/CtaBannerBlock.tsx +8 -2
  12. package/src/blocks/components/HeroFullBlock.tsx +7 -1
  13. package/src/blocks/components/IconGridBlock.tsx +5 -1
  14. package/src/blocks/components/InstagramBlock.tsx +5 -1
  15. package/src/blocks/components/InstagramTiles.tsx +3 -1
  16. package/src/blocks/components/LinkTilesBlock.tsx +8 -1
  17. package/src/blocks/components/MediaSplitBlock.tsx +8 -3
  18. package/src/blocks/components/PriceTableBlock.tsx +6 -5
  19. package/src/blocks/components/TripListBlock.tsx +5 -1
  20. package/src/cms/booking-landing.ts +359 -0
  21. package/src/cms/booking-links.ts +24 -0
  22. package/src/cms/page-visibility.ts +67 -1
  23. package/src/forms/form-audit.ts +22 -2
  24. package/src/html/html-tokenizer.ts +254 -0
  25. package/src/html/sanitize-html.ts +71 -36
  26. package/src/navigation/visibility.ts +65 -3
  27. package/src/popups/runtime.tsx +75 -32
  28. package/src/server/forms-core.ts +9 -0
  29. package/src/server/forms-delivery-core.ts +29 -22
  30. package/src/server/forms-submissions-core.ts +8 -5
  31. package/src/server/media-usage-core.ts +53 -13
  32. package/src/server/outbound-address.ts +131 -0
  33. package/src/server/outbound-http.ts +565 -0
  34. package/src/server/page-visibility-core.ts +231 -5
  35. package/src/server/popup-optin-webhook-core.ts +79 -140
  36. package/src/server/schemas.ts +45 -0
  37. package/src/site/BookingOffNotice.tsx +73 -0
  38. package/src/site/PageRenderer.tsx +59 -3
  39. package/src/site/link-visibility.tsx +21 -2
  40. package/src/site/preview-messaging.ts +232 -0
  41. package/src/site/routes/preview-page.tsx +37 -29
  42. package/src/util/redact-url.ts +18 -0
@@ -11,6 +11,11 @@
11
11
  * | `page.listing.<page uuid>` | `{ "listing": "link-only" }` | listed |
12
12
  * | `booking` | `{ "enabled": false }` | booking on |
13
13
  * | `booking.exception.<uuid>` | `{ "exception": true }` | no exception |
14
+ * | `booking.landing.<uuid>` | `{ "landing": true, "notice"?: … }` | not a landing page |
15
+ * | `booking.landing.default` | `{ "notice": … }` | built-in copy |
16
+ *
17
+ * A landing page leaves the site's menus and page lists while booking is off
18
+ * and stays live and indexable (see `cms/booking-landing`).
14
19
  *
15
20
  * Keys use the page's id, never its key or address, so every alias and locale
16
21
  * of a page shares one decision, a restored page keeps its own, and a page
@@ -25,6 +30,14 @@
25
30
  * None of these rows is an `OVERRIDABLE_SETTING_KEYS` key, so the generic
26
31
  * settings save and reset cannot read, overwrite or erase them.
27
32
  */
33
+ import {
34
+ NoticeValidationError,
35
+ noticeLinkHrefs,
36
+ normalizeNoticeContent,
37
+ normalizePageNoticeOverride,
38
+ type NoticeContent,
39
+ type PageNoticeOverride,
40
+ } from "../cms/booking-landing";
28
41
  import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
29
42
  import {
30
43
  nextPublicationBoundary,
@@ -49,6 +62,8 @@ export const SITE_SETTINGS = "site_settings";
49
62
  export const PAGE_LISTING_PREFIX = "page.listing.";
50
63
  export const BOOKING_SETTING_KEY = "booking";
51
64
  export const BOOKING_EXCEPTION_PREFIX = "booking.exception.";
65
+ export const BOOKING_LANDING_PREFIX = "booking.landing.";
66
+ export const BOOKING_LANDING_DEFAULT_KEY = "booking.landing.default";
52
67
 
53
68
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
54
69
 
@@ -63,8 +78,28 @@ export type VisibilityDeps = {
63
78
  * by the browser.
64
79
  */
65
80
  protectedPageKeys: readonly string[];
81
+ /**
82
+ * The host's check of a booking-off notice link against its own routes:
83
+ * why the link may not be used (it leads to the host's booking page, or to a
84
+ * page hidden while booking is hidden), or null. Supplied by the host's
85
+ * server code. Without it only the platform's own rules apply (no booking
86
+ * site, safe address forms).
87
+ */
88
+ noticeTargetProblem?: (href: string) => string | null | Promise<string | null>;
66
89
  };
67
90
 
91
+ /** Refuse a notice whose link the host says leads to booking or to a hidden page. */
92
+ async function assertNoticeTargets(
93
+ deps: Pick<VisibilityDeps, "noticeTargetProblem">,
94
+ notice: NoticeContent | PageNoticeOverride,
95
+ ): Promise<void> {
96
+ if (!deps.noticeTargetProblem) return;
97
+ for (const href of noticeLinkHrefs(notice)) {
98
+ const problem = await deps.noticeTargetProblem(href);
99
+ if (problem) throw new NoticeValidationError(problem);
100
+ }
101
+ }
102
+
68
103
  /** Someone else changed the same decision since the editor last looked. */
69
104
  export class VisibilityConflictError extends Error {
70
105
  readonly code = "visibility_conflict";
@@ -96,10 +131,31 @@ export async function readVisibilitySettingsCore(client: any): Promise<Visibilit
96
131
  booking: { enabled: true, version: null },
97
132
  listings: new Map(),
98
133
  exceptions: new Map(),
134
+ landings: new Map(),
135
+ landingDefault: { notice: null, version: null },
99
136
  };
100
137
  for (const row of (data ?? []) as SettingRow[]) {
101
138
  const version = row.updated_at ?? null;
102
- if (row.key === BOOKING_SETTING_KEY) {
139
+ if (row.key === BOOKING_LANDING_DEFAULT_KEY) {
140
+ // Stored copy is read leniently: a field that no longer validates is
141
+ // left out rather than failing every public page.
142
+ const notice = record(row.value)["notice"];
143
+ settings.landingDefault = {
144
+ notice: notice == null ? null : normalizeNoticeContent(notice, false),
145
+ version,
146
+ };
147
+ } else if (row.key.startsWith(BOOKING_LANDING_PREFIX)) {
148
+ const pageId = row.key.slice(BOOKING_LANDING_PREFIX.length);
149
+ if (UUID.test(pageId)) {
150
+ const value = record(row.value);
151
+ settings.landings.set(pageId.toLowerCase(), {
152
+ // Only an explicit `true` makes a landing page.
153
+ landing: value["landing"] === true,
154
+ notice: normalizePageNoticeOverride(value["notice"], false),
155
+ version,
156
+ });
157
+ }
158
+ } else if (row.key === BOOKING_SETTING_KEY) {
103
159
  // Only an explicit `false` turns booking off; anything else is today's behaviour.
104
160
  settings.booking = { enabled: record(row.value)["enabled"] !== false, version };
105
161
  } else if (row.key.startsWith(PAGE_LISTING_PREFIX)) {
@@ -127,6 +183,8 @@ export type VisibilitySettings = {
127
183
  booking: { enabled: boolean; version: VersionToken };
128
184
  listings: Map<string, { listing: PageListing; version: VersionToken }>;
129
185
  exceptions: Map<string, { exception: boolean; version: VersionToken }>;
186
+ landings: Map<string, { landing: boolean; notice: PageNoticeOverride; version: VersionToken }>;
187
+ landingDefault: { notice: NoticeContent | null; version: VersionToken };
130
188
  };
131
189
 
132
190
  /**
@@ -304,7 +362,14 @@ export async function setBookingExceptionCore(
304
362
  if (!UUID.test(input.pageId)) throw new Error("Page not found");
305
363
  // Adding needs a page that exists; removing works even for one in the trash.
306
364
  let name = "this page";
307
- if (input.exception) name = (await readLivePage(context.supabase, input.pageId)).name;
365
+ if (input.exception) {
366
+ name = (await readLivePage(context.supabase, input.pageId)).name;
367
+ // A page cannot both keep its booking links and be a booking-off landing page.
368
+ const settings = await readVisibilitySettingsCore(context.supabase);
369
+ if (settings.landings.get(input.pageId.toLowerCase())?.landing === true) {
370
+ throw new BookingConflictError(name, "landing");
371
+ }
372
+ }
308
373
  invalidatePublishedCache();
309
374
  const version = await compareAndSwap(
310
375
  context.supabase,
@@ -329,6 +394,114 @@ export async function setBookingExceptionCore(
329
394
  return { exception: input.exception, version };
330
395
  }
331
396
 
397
+ /** Thrown when one page would both keep its booking links and be a booking-off landing page. */
398
+ export class BookingConflictError extends Error {
399
+ readonly code = "booking_conflict";
400
+ constructor(name: string, already: "exception" | "landing") {
401
+ super(
402
+ already === "exception"
403
+ ? `“${name}” keeps its booking links while booking is hidden, so it cannot also be hidden ` +
404
+ "from the website. Remove it from “Pages that keep their booking links” first."
405
+ : `“${name}” is hidden from the website while booking is hidden, so it cannot also keep ` +
406
+ "its booking links. Remove it from “Pages hidden from the website while booking is hidden” first.",
407
+ );
408
+ this.name = "BookingConflictError";
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Add a page to, or remove it from, the booking-off landing pages, or change
414
+ * its own notice.
415
+ *
416
+ * Adding needs a page that exists, is not protected (the start page and the
417
+ * contact page always stay in the menus) and does not keep its booking links.
418
+ * Removing works even for a page in the trash, and drops its own notice.
419
+ * Listing, publication, schedules and indexing are not touched: the page stays
420
+ * exactly as reachable and indexable as it was.
421
+ */
422
+ export async function setBookingLandingCore(
423
+ deps: VisibilityDeps,
424
+ input: {
425
+ pageId: string;
426
+ landing: boolean;
427
+ notice?: unknown;
428
+ expectedVersion: VersionToken;
429
+ },
430
+ ): Promise<{ landing: boolean; version: string }> {
431
+ const { context } = deps;
432
+ await assertStaff(context);
433
+ if (!UUID.test(input.pageId)) throw new Error("Page not found");
434
+ const pageId = input.pageId.toLowerCase();
435
+ let name = "this page";
436
+ let value: Record<string, unknown> = { landing: false };
437
+ if (input.landing) {
438
+ const page = await readLivePage(context.supabase, pageId);
439
+ name = page.name;
440
+ assertNotProtected(deps.protectedPageKeys, page, "hidden from the website while booking is hidden");
441
+ // Validated here as well as in the request schema: the core is the authority.
442
+ const notice = normalizePageNoticeOverride(input.notice ?? null, true);
443
+ await assertNoticeTargets(deps, notice);
444
+ const settings = await readVisibilitySettingsCore(context.supabase);
445
+ if (settings.exceptions.get(pageId)?.exception === true) {
446
+ throw new BookingConflictError(name, "exception");
447
+ }
448
+ value = Object.keys(notice).length > 0 ? { landing: true, notice } : { landing: true };
449
+ }
450
+ invalidatePublishedCache();
451
+ const version = await compareAndSwap(
452
+ context.supabase,
453
+ `${BOOKING_LANDING_PREFIX}${pageId}`,
454
+ value,
455
+ input.expectedVersion,
456
+ context.userId,
457
+ `whether ${input.landing ? `“${name}”` : name} is hidden while booking is hidden`,
458
+ );
459
+ invalidatePublishedCache();
460
+ await auditAs(context, {
461
+ action: "booking.landing",
462
+ entity: "pages",
463
+ entityId: pageId,
464
+ meta: {
465
+ landing: input.landing,
466
+ ownNotice: input.landing && "notice" in value,
467
+ description: input.landing
468
+ ? `Set "${name}" to leave the website's menus and page lists while booking is hidden; it stays reachable and indexable.`
469
+ : "Removed a page from the pages hidden from the website while booking is hidden.",
470
+ },
471
+ });
472
+ return { landing: input.landing, version };
473
+ }
474
+
475
+ /** Save the site-wide booking-off notice. */
476
+ export async function setBookingLandingDefaultCore(
477
+ deps: Pick<VisibilityDeps, "context" | "noticeTargetProblem">,
478
+ input: { notice: unknown; expectedVersion: VersionToken },
479
+ ): Promise<{ version: string }> {
480
+ const { context } = deps;
481
+ await assertStaff(context);
482
+ const notice = normalizeNoticeContent(input.notice, true);
483
+ await assertNoticeTargets(deps, notice);
484
+ invalidatePublishedCache();
485
+ const version = await compareAndSwap(
486
+ context.supabase,
487
+ BOOKING_LANDING_DEFAULT_KEY,
488
+ { notice },
489
+ input.expectedVersion,
490
+ context.userId,
491
+ "the booking-off notice",
492
+ );
493
+ invalidatePublishedCache();
494
+ await auditAs(context, {
495
+ action: "booking.landing.default",
496
+ entity: SITE_SETTINGS,
497
+ entityId: BOOKING_LANDING_DEFAULT_KEY,
498
+ meta: {
499
+ description: "Changed the notice booking-off landing pages show while booking is hidden.",
500
+ },
501
+ });
502
+ return { version };
503
+ }
504
+
332
505
  /** Booking setting and its exception pages, for the settings screen. */
333
506
  export type BookingSettingsView = {
334
507
  enabled: boolean;
@@ -346,6 +519,28 @@ export type BookingSettingsView = {
346
519
  * ones: adding a page back has to send the version of the row it left.
347
520
  */
348
521
  rowVersions: Record<string, VersionToken>;
522
+ /** Booking-off landing pages and their notice. */
523
+ landing: {
524
+ /** The stored default notice (empty fields use the built-in copy), and its version. */
525
+ default: { notice: NoticeContent | null; version: VersionToken };
526
+ pages: Array<{
527
+ pageId: string;
528
+ key: string | null;
529
+ name: string | null;
530
+ /** The page is in the trash or gone: the setting does nothing until restored. */
531
+ missing: boolean;
532
+ notice: PageNoticeOverride;
533
+ version: VersionToken;
534
+ /**
535
+ * Stored as a booking exception too. It is treated as an exception only
536
+ * — it keeps its booking links and stays in the menus — until somebody
537
+ * removes it from one of the lists.
538
+ */
539
+ conflict: boolean;
540
+ }>;
541
+ /** Versions of every stored landing row by page id, removed ones included. */
542
+ rowVersions: Record<string, VersionToken>;
543
+ };
349
544
  };
350
545
 
351
546
  export async function readBookingSettingsCore(
@@ -355,17 +550,40 @@ export async function readBookingSettingsCore(
355
550
  await assertStaff(context);
356
551
  const settings = await readVisibilitySettingsCore(context.supabase);
357
552
  const ids = [...settings.exceptions].filter(([, row]) => row.exception).map(([id]) => id);
553
+ const landingIds = [...settings.landings].filter(([, row]) => row.landing).map(([id]) => id);
358
554
  const pages = new Map<string, PageRow>();
359
- if (ids.length > 0) {
555
+ const wanted = [...new Set([...ids, ...landingIds])];
556
+ if (wanted.length > 0) {
360
557
  const { data, error } = await context.supabase
361
558
  .from("pages")
362
559
  .select("id,key,name")
363
- .in("id", ids)
560
+ .in("id", wanted)
364
561
  .is("deleted_at", null);
365
562
  if (error) throw new Error(error.message);
366
563
  for (const row of (data ?? []) as PageRow[]) pages.set(row.id.toLowerCase(), row);
367
564
  }
565
+ const byName = (a: { name: string | null }, b: { name: string | null }) =>
566
+ (a.name ?? "~").localeCompare(b.name ?? "~");
368
567
  return {
568
+ landing: {
569
+ default: settings.landingDefault,
570
+ pages: landingIds
571
+ .map((pageId) => {
572
+ const page = pages.get(pageId);
573
+ const row = settings.landings.get(pageId)!;
574
+ return {
575
+ pageId,
576
+ key: page?.key ?? null,
577
+ name: page?.name ?? null,
578
+ missing: !page,
579
+ notice: row.notice,
580
+ version: row.version,
581
+ conflict: settings.exceptions.get(pageId)?.exception === true,
582
+ };
583
+ })
584
+ .sort(byName),
585
+ rowVersions: Object.fromEntries([...settings.landings].map(([id, row]) => [id, row.version])),
586
+ },
369
587
  enabled: settings.booking.enabled,
370
588
  version: settings.booking.version,
371
589
  exceptions: ids
@@ -443,8 +661,16 @@ async function readPublicPolicy(client: any): Promise<PublicPolicySnapshot> {
443
661
  live: publicVersionId(byPage.get(row.id) ?? [], now) !== null,
444
662
  listing: settings.listings.get(id)?.listing ?? "listed",
445
663
  bookingException: settings.exceptions.get(id)?.exception === true,
664
+ bookingLanding: settings.landings.get(id)?.landing === true,
665
+ landingNotice: settings.landings.get(id)?.landing ? settings.landings.get(id)!.notice : null,
446
666
  };
447
667
  });
448
668
 
449
- return { known: true, bookingEnabled: settings.booking.enabled, pages, revalidateAt };
669
+ return {
670
+ known: true,
671
+ bookingEnabled: settings.booking.enabled,
672
+ pages,
673
+ revalidateAt,
674
+ landingDefault: settings.landingDefault.notice,
675
+ };
450
676
  }
@@ -25,6 +25,14 @@
25
25
  * Server-only. Never import from client code.
26
26
  */
27
27
 
28
+ import {
29
+ fetchTransport,
30
+ injectedFetchResolver,
31
+ safeRequest,
32
+ WEBHOOK_POLICY,
33
+ type Resolver,
34
+ type Transport,
35
+ } from "./outbound-http";
28
36
  import { checkWebhookUrl } from "./webhook-url-guard";
29
37
 
30
38
  export const POPUP_WEBHOOK_SCHEMA_VERSION = 1;
@@ -56,8 +64,15 @@ export interface OptInWebhookInput {
56
64
  readonly timeoutMs?: number;
57
65
  readonly maxResponseBytes?: number;
58
66
  readonly maxRedirects?: number;
59
- /** Injected in tests. */
67
+ /**
68
+ * Test seam: requests go through this fetch instead of the network. Every
69
+ * hop is still checked and followed by the outbound policy, but names are
70
+ * not looked up and the connection is not pinned. Production passes none.
71
+ */
60
72
  readonly fetchImpl?: typeof fetch;
73
+ /** Test seams for the pinned path: a fake resolver and transport. */
74
+ readonly resolver?: Resolver;
75
+ readonly transport?: Transport;
61
76
  readonly now?: () => number;
62
77
  }
63
78
 
@@ -147,71 +162,6 @@ export async function signBody(
147
162
  .digest("hex");
148
163
  }
149
164
 
150
- /**
151
- * Read at most `limit` bytes of a response, then stop.
152
- *
153
- * A receiver that answers with a gigabyte would otherwise hold this server's
154
- * memory hostage. Nothing is done with the body but the read has to be bounded.
155
- */
156
- async function drainBounded(
157
- response: Response,
158
- limit: number,
159
- ): Promise<boolean> {
160
- const body = response.body;
161
- if (body === null) return true;
162
- const reader = body.getReader();
163
- let total = 0;
164
- try {
165
- for (;;) {
166
- const { done, value } = await reader.read();
167
- if (done) return true;
168
- total += value?.byteLength ?? 0;
169
- if (total > limit) return false;
170
- }
171
- } finally {
172
- await reader.cancel().catch(() => undefined);
173
- }
174
- }
175
-
176
- /**
177
- * POST with every redirect hop re-validated.
178
- *
179
- * `redirect: "manual"` is what makes the guard meaningful: with the default
180
- * `follow`, the runtime chases a 302 into the private network before any of
181
- * this code sees it.
182
- */
183
- async function postWithGuardedRedirects(
184
- url: string,
185
- init: RequestInit,
186
- options: { fetchImpl: typeof fetch; maxRedirects: number; maxBytes: number },
187
- ): Promise<
188
- | { response: Response; oversize: boolean }
189
- | { blocked: DeliveryOutcome["errorCategory"] }
190
- > {
191
- let target = url;
192
- for (let hop = 0; hop <= options.maxRedirects; hop += 1) {
193
- const check = checkWebhookUrl(target);
194
- if (!check.ok) return { blocked: "blocked-endpoint" };
195
-
196
- const response = await options.fetchImpl(target, {
197
- ...init,
198
- redirect: "manual",
199
- });
200
-
201
- if (response.status >= 300 && response.status < 400) {
202
- const location = response.headers.get("location");
203
- if (location === null) return { response, oversize: false };
204
- // Resolve relative redirects against the hop we actually made.
205
- target = new URL(location, target).toString();
206
- continue;
207
- }
208
-
209
- const withinLimit = await drainBounded(response, options.maxBytes);
210
- return { response, oversize: !withinLimit };
211
- }
212
- return { blocked: "too-many-redirects" };
213
- }
214
-
215
165
  /**
216
166
  * Deliver one opt-in. Never throws.
217
167
  *
@@ -224,9 +174,6 @@ export async function deliverOptInWebhook(
224
174
  ): Promise<DeliveryOutcome> {
225
175
  const now = input.now ?? Date.now;
226
176
  const startedAt = now();
227
- const fetchImpl = input.fetchImpl ?? fetch;
228
- const maxBytes = input.maxResponseBytes ?? DEFAULT_MAX_BYTES;
229
- const maxRedirects = input.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
230
177
 
231
178
  const check = checkWebhookUrl(input.endpoint);
232
179
  if (!check.ok) {
@@ -242,84 +189,76 @@ export async function deliverOptInWebhook(
242
189
  const timestamp = String(Math.floor(now() / 1000));
243
190
  const signature = await signBody(input.signingSecret, timestamp, body);
244
191
 
245
- const controller = new AbortController();
246
- const timer = setTimeout(
247
- () => controller.abort(),
248
- input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
249
- );
250
-
251
- try {
252
- const result = await postWithGuardedRedirects(
253
- check.url.toString(),
254
- {
255
- method: "POST",
256
- headers: {
257
- "content-type": "application/json",
258
- "user-agent": "cms-platform-webhooks/1",
259
- "x-webhook-event":
260
- input.test === true ? "popup.opt_in.test" : "popup.opt_in",
261
- "x-webhook-event-id": input.eventId,
262
- // The receiver deduplicates on this. Retries of the same opt-in
263
- // reuse the same value, which is what makes a retry safe.
264
- "x-webhook-idempotency-key": input.eventId,
265
- "x-webhook-timestamp": timestamp,
266
- "x-webhook-signature": `v1=${signature}`,
267
- },
268
- body,
269
- signal: controller.signal,
270
- },
271
- { fetchImpl, maxRedirects, maxBytes },
272
- );
192
+ // Every hop is checked, looked up, pinned and capped by the shared outbound
193
+ // policy; redirects are followed there, never by the runtime.
194
+ const network = input.fetchImpl
195
+ ? { transport: fetchTransport(input.fetchImpl), resolver: injectedFetchResolver }
196
+ : { transport: input.transport, resolver: input.resolver };
197
+ const result = await safeRequest({
198
+ url: check.url.toString(),
199
+ method: "POST",
200
+ headers: {
201
+ "content-type": "application/json",
202
+ "user-agent": "cms-platform-webhooks/1",
203
+ "x-webhook-event": input.test === true ? "popup.opt_in.test" : "popup.opt_in",
204
+ "x-webhook-event-id": input.eventId,
205
+ // The receiver deduplicates on this. Retries of the same opt-in
206
+ // reuse the same value, which is what makes a retry safe.
207
+ "x-webhook-idempotency-key": input.eventId,
208
+ "x-webhook-timestamp": timestamp,
209
+ "x-webhook-signature": `v1=${signature}`,
210
+ },
211
+ body,
212
+ policy: {
213
+ ...WEBHOOK_POLICY,
214
+ timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
215
+ maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_BYTES,
216
+ maxRedirects: input.maxRedirects ?? DEFAULT_MAX_REDIRECTS,
217
+ },
218
+ ...network,
219
+ });
220
+ const durationMs = now() - startedAt;
273
221
 
274
- if ("blocked" in result) {
275
- return {
276
- ok: false,
277
- errorCategory: result.blocked,
278
- error:
279
- result.blocked === "too-many-redirects"
280
- ? "The endpoint redirected too many times."
281
- : "The endpoint redirected to an address inside this server's network.",
282
- durationMs: now() - startedAt,
283
- };
222
+ if (!result.ok) {
223
+ switch (result.failure) {
224
+ case "blocked-url":
225
+ case "blocked-address":
226
+ return {
227
+ ok: false,
228
+ errorCategory: "blocked-endpoint",
229
+ error: "The endpoint, or an address it redirected to, is not a public https address.",
230
+ durationMs,
231
+ };
232
+ case "cross-origin-redirect":
233
+ return {
234
+ ok: false,
235
+ errorCategory: "blocked-endpoint",
236
+ error: "The endpoint redirected to another site; an opt-in is only sent to the configured address.",
237
+ durationMs,
238
+ };
239
+ case "too-many-redirects":
240
+ return { ok: false, errorCategory: "too-many-redirects", error: "The endpoint redirected too many times.", durationMs };
241
+ case "too-large":
242
+ return { ok: false, errorCategory: "too-large", error: "The endpoint's reply was too large.", durationMs };
243
+ case "timeout":
244
+ return { ok: false, errorCategory: "timeout", error: "The endpoint did not answer in time.", durationMs };
245
+ default:
246
+ return { ok: false, errorCategory: "network", error: "The endpoint could not be reached.", durationMs };
284
247
  }
248
+ }
285
249
 
286
- const { response, oversize } = result;
287
- if (oversize) {
288
- return {
289
- ok: false,
290
- status: response.status,
291
- errorCategory: "too-large",
292
- error: "The endpoint's reply was too large.",
293
- durationMs: now() - startedAt,
294
- };
295
- }
296
- if (!response.ok) {
297
- return {
298
- ok: false,
299
- status: response.status,
300
- errorCategory: "http-error",
301
- // Deliberately not the response body: it is attacker-influenced text
302
- // that would end up in an administrator's screen and the logs.
303
- error: `The endpoint answered ${response.status}.`,
304
- durationMs: now() - startedAt,
305
- };
306
- }
307
- return { ok: true, status: response.status, durationMs: now() - startedAt };
308
- } catch (caught) {
309
- const aborted =
310
- caught instanceof Error &&
311
- (caught.name === "AbortError" || caught.message.includes("aborted"));
250
+ if (result.status < 200 || result.status >= 300) {
312
251
  return {
313
252
  ok: false,
314
- errorCategory: aborted ? "timeout" : "network",
315
- error: aborted
316
- ? "The endpoint did not answer in time."
317
- : "The endpoint could not be reached.",
318
- durationMs: now() - startedAt,
253
+ status: result.status,
254
+ errorCategory: "http-error",
255
+ // Deliberately not the response body: it is attacker-influenced text
256
+ // that would end up in an administrator's screen and the logs.
257
+ error: `The endpoint answered ${result.status}.`,
258
+ durationMs,
319
259
  };
320
- } finally {
321
- clearTimeout(timer);
322
260
  }
261
+ return { ok: true, status: result.status, durationMs };
323
262
  }
324
263
 
325
264
  // ---------------------------------------------------------------------------
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import { z } from "zod";
13
13
 
14
+ import { NOTICE_LIMITS, parseNoticeCtaHref } from "../cms/booking-landing";
15
+
14
16
  /* ------------------------------------------------------------------ shared */
15
17
 
16
18
  export const uuid = z.string().uuid();
@@ -33,6 +35,37 @@ export const cmsKey = z
33
35
 
34
36
  export const listLimit = z.object({ limit: z.number().min(1).max(500).default(200) });
35
37
 
38
+ /* ------------------------------------------------------- booking-off notice */
39
+
40
+ /** Text per language, bounded. Content rules (limits, links) are re-checked by the core. */
41
+ const noticeText = (max: number) =>
42
+ z.record(z.string().regex(/^[a-z]{2}(-[a-z0-9]{2,8})?$/i), z.string().max(max)).refine(
43
+ (value) => Object.keys(value).length <= 8,
44
+ "Too many languages",
45
+ );
46
+
47
+ const noticeCtaSchema = z.object({
48
+ label: noticeText(NOTICE_LIMITS.ctaLabel),
49
+ href: z
50
+ .string()
51
+ .max(NOTICE_LIMITS.href)
52
+ .refine((href) => parseNoticeCtaHref(href).ok, {
53
+ message: "Use an address on this site (/…) or an https:// address that does not lead to booking.",
54
+ }),
55
+ });
56
+
57
+ export const noticeSchema = z.object({
58
+ heading: noticeText(NOTICE_LIMITS.heading).optional(),
59
+ body: noticeText(NOTICE_LIMITS.body).optional(),
60
+ cta: noticeCtaSchema.nullable().optional(),
61
+ });
62
+
63
+ export const noticeOverrideSchema = z.object({
64
+ heading: noticeText(NOTICE_LIMITS.heading).optional(),
65
+ body: noticeText(NOTICE_LIMITS.body).optional(),
66
+ cta: z.union([noticeCtaSchema, z.literal("none")]).optional(),
67
+ });
68
+
36
69
  /* ---------------------------------------------------------------- edit locks */
37
70
 
38
71
  /**
@@ -148,6 +181,18 @@ export const pageSchemas = {
148
181
  exception: z.boolean(),
149
182
  expectedVersion: z.string().min(1).max(64).nullable(),
150
183
  }),
184
+ /** A booking-off landing page, added, removed, or given its own notice. */
185
+ setBookingLanding: z.object({
186
+ pageId: uuid,
187
+ landing: z.boolean(),
188
+ notice: noticeOverrideSchema.nullable().optional(),
189
+ expectedVersion: z.string().min(1).max(64).nullable(),
190
+ }),
191
+ /** The site-wide booking-off notice. */
192
+ setBookingLandingDefault: z.object({
193
+ notice: noticeSchema,
194
+ expectedVersion: z.string().min(1).max(64).nullable(),
195
+ }),
151
196
  duplicatePage: z.object({
152
197
  pageId: uuid,
153
198
  name: z.string().min(1).max(120),