@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
package/CHANGELOG.md CHANGED
@@ -8,6 +8,127 @@ matches `package.json` exactly. See
8
8
  Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
9
9
  from the published npm tarballs.
10
10
 
11
+ ## 0.97.0
12
+
13
+ Booking-off search landing pages. No migration: the settings live in the
14
+ existing `site_settings` table (the migration manifest is unchanged at 60). Two
15
+ exports were added — `./cms/booking-landing` and `./site/BookingOffNotice` —
16
+ and none removed. With nothing stored, every site behaves exactly as on 0.96.0.
17
+
18
+ - **Pages hidden from the website while booking is hidden** (Site settings →
19
+ Booking, after the pages that keep their booking links). While booking is
20
+ hidden, a page on this list is left out of every structured link the website
21
+ offers, and returns when booking is shown again. It stays live (200),
22
+ self-canonical, indexable and in the sitemap, so a visitor arriving from a
23
+ search result still gets the page: `pageIsIndexable` and `unlistedPages` are
24
+ unchanged, link-only stays `noindex`, offline stays a 404. Being indexable is
25
+ not a ranking promise, and the screen says so.
26
+ - **One rule for structured links** (`useDiscoverableLink`,
27
+ `isHiddenFromDiscovery`, `hiddenPaths` reason `booking-off`): header menu,
28
+ mobile menu and footer; link tiles (dropped); hero, banner, media-split,
29
+ price-table, countdown and trip buttons (dropped); media-split slide links,
30
+ icon-grid card links and Instagram links (link removed, picture or card
31
+ kept). Applied in render from the link policy the server rendered with, so
32
+ the server's HTML and later client navigations agree. Links inside rich text
33
+ are the one documented exception. `restrictToLinks` keeps `booking-off`
34
+ entries, which name public pages.
35
+ - **Popups** that lead to a hidden page are retired in the runtime the way
36
+ booking popups are; `withoutPopupsLeadingTo` lets a host withhold them on the
37
+ server.
38
+ - **The booking-off notice** (`BookingOffNotice`, placed by the page renderer's
39
+ new `notice` prop after a leading hero): a plain-text heading, message and an
40
+ optional link, from a site-wide default with optional per-page replacements,
41
+ field by field, Swedish and English. The link must be an internal path or an
42
+ `https://` address and never leads to booking or to a hidden page: hosts name
43
+ their booking page (`LinkVisibilityPolicy.bookingPagePaths`, checked when the
44
+ notice renders) and check links on save (`VisibilityDeps.noticeTargetProblem`,
45
+ with `noticeTargetProblem(href, routes)` doing the work from the host's
46
+ routes). The platform names no route.
47
+ - **Storage and staff writes**: rows `booking.landing.<page uuid>` and
48
+ `booking.landing.default`, written by `setBookingLandingCore` and
49
+ `setBookingLandingDefaultCore` — staff only, validated, compare-and-swap on
50
+ `updated_at`, audited, cache-invalidating. Protected pages and booking
51
+ exceptions are refused, and adding an exception for a landing page is refused
52
+ too (`BookingConflictError`); rows that say both are treated as an exception
53
+ only, and the Booking card warns. After a refused save the page-message editor
54
+ shows the stored version and says why, so a stale draft is never resubmitted
55
+ unseen.
56
+ - **Host wiring (optional)**: `booking.fns.setBookingLanding` and
57
+ `setBookingLandingDefault` show the new subsection; without them the Booking
58
+ card is unchanged.
59
+
60
+ ## 0.96.0
61
+
62
+ Security release. No migration, and no export was added or removed.
63
+
64
+ - **Rich text is sanitized by a parser, not by patterns** (`./html/sanitize-html`).
65
+ The old sanitizer copied the text between the tags it recognised through as
66
+ written, so an unterminated tag at the end of a fragment (for example an image
67
+ tag with an event handler and no closing `>`) survived, and in server
68
+ rendering the markup after the fragment completed it into a live element.
69
+ Markup is now read the way a browser's HTML tokenizer reads it (every quoting
70
+ form, comments, DOCTYPE, CDATA, raw-text elements, a tag cut off by the end of
71
+ the input) and the output is written from scratch: allowlisted elements and
72
+ attributes only, attributes decoded once and escaped once, and every `<` and
73
+ `>` in text escaped. The editor's allowlist, URL rules and booking-link
74
+ `&amp;` handling are unchanged.
75
+ - Tightened: a `data:` image is accepted only in an image's `src`, never as a
76
+ link target. `svg`, `math`, `template`, `textarea`, `title`, `xmp`,
77
+ `noembed`, `noframes` and `plaintext` are dropped together with their
78
+ content.
79
+ - `htmlToText` now returns plain text, with entities decoded.
80
+ - **The builder and its preview only talk to each other**
81
+ (`./site/routes/preview-page`, the page builder). The public preview route
82
+ used to render blocks posted by any window that put a marker string in its
83
+ message, and both ends posted to `"*"`. Now:
84
+ - the preview accepts the builder's messages only from its parent frame,
85
+ from its own origin or an origin the host lists, in one builder session
86
+ with increasing sequence numbers, shape-checked and at most 4 MB;
87
+ - the builder accepts the preview's messages only from its own iframe, and
88
+ every message names its target origin.
89
+ - **Host change, only for a builder on another origin:** list that origin in
90
+ the preview page deps as `trustedEditorOrigins`. A builder on the site's own
91
+ origin (Voyara's) needs no configuration.
92
+ - **One outbound request policy** for every address staff or configuration
93
+ chooses: media import, form webhooks and popup opt-in webhooks. Each of these
94
+ was a server-side request forgery primitive. Every request now:
95
+ - allows only the schemes its policy names (webhooks: https only), with no
96
+ credentials in the URL, no local or single-label host names and no
97
+ privileged ports other than 80 and 443;
98
+ - looks the host up and goes ahead only if every address it resolves to is
99
+ public (IPv4 and IPv6 special-purpose ranges, including mapped, NAT64 and
100
+ 6to4 forms), then connects to the address it checked (pinned, so a DNS
101
+ answer that changes in between cannot redirect it), with TLS still verified
102
+ for the host name;
103
+ - follows redirects itself, checking and pinning every hop, up to a cap;
104
+ - runs under one deadline for the whole request, which also holds when a
105
+ lookup, connection or response body ignores cancellation (Node's DNS
106
+ lookup cannot be cancelled), and reads at most a fixed number of bytes,
107
+ streaming, whatever `Content-Length` says;
108
+ - reports failures by category, with messages that carry no address, query
109
+ string or response body.
110
+ - **What each path does with that policy:**
111
+ - **Media import:** a downloaded file is stored as what its first bytes say
112
+ it is (JPEG, PNG, GIF, WebP, AVIF, MP4, MOV, M4V, WebM, MP3, WAV, Ogg or
113
+ PDF), with that extension, never as the server's content type or the URL's
114
+ extension. SVG and HTML are refused. Public redirects to other sites are
115
+ still followed, carrying only plain request headers.
116
+ - **Form and popup webhooks never follow a redirect to another site.** A
117
+ signed body, its signature, timestamp, event id and idempotency key reach
118
+ only the configured origin (a redirect within that origin is followed;
119
+ popup webhooks report the refusal as `blocked-endpoint`). When a redirect
120
+ turns a request into a GET, entity and signature headers are dropped.
121
+ **Receivers that redirect to another host must be configured with their
122
+ final address.**
123
+ - **Form webhooks** are checked when the form is saved, report a failure by
124
+ status only (the receiver's reply is never copied into it), and are never
125
+ sent unsigned when signing fails.
126
+ - **Addresses in records keep the origin only.** Webhook delivery records,
127
+ errors and the form audit trail show a webhook as its origin plus a fixed
128
+ `/[redacted]` marker, never its path, query string or credentials, because
129
+ receivers put keys in paths as well as in query strings. Settings whose name
130
+ says they are a secret are recorded as changed, never with a value.
131
+
11
132
  ## 0.95.0
12
133
 
13
134
  - **Pages can be link-only, and unpublished from the page itself.** Listing is
package/docs/PAGES.md CHANGED
@@ -541,11 +541,50 @@ Listing is a second property beside publication, and the two never touch:
541
541
  booking links in their own content. Removal (`stripBookingDeep`,
542
542
  `withoutBookingPopups`) is for the host to apply to data before it leaves the
543
543
  server; stored content is never changed.
544
+ - **Booking-off landing pages** (`cms/booking-landing`). While booking is off, a
545
+ page in this list leaves every structured link the website offers (see the
546
+ inventory below; `hiddenPaths` reason `booking-off`, `isHiddenFromDiscovery`,
547
+ `useDiscoverableLink`), but stays live, self-canonical, indexable and in the
548
+ sitemap: `pageIsIndexable` and `unlistedPages` do not change. Indexable is
549
+ not a ranking promise: removing a page's internal links can change how search
550
+ engines value it. Its booking links go as on any page, and it shows a
551
+ plain-text notice (`BookingOffNotice`, placed by the page renderer's `notice`
552
+ prop after a leading hero) with an optional link that never leads to booking
553
+ or to another hidden page. Staff write a site-wide default and optional
554
+ per-page replacements, field by field (`resolveNotice`). A page cannot be both
555
+ a booking exception and a landing page (`BookingConflictError`); stored rows
556
+ that say both are treated as an exception only, and the Booking card warns.
557
+ Offline still wins (404); link-only stays `noindex`. Protected pages are
558
+ refused. With booking on, nothing changes.
559
+
560
+ ### Booking-off landing pages: structured links (reviewed 2026-10-17)
561
+
562
+ Enforced in render from the link policy the server rendered with (and every
563
+ client navigation refreshes), so server HTML and the browser agree. Stored
564
+ content is never changed. `tests/booking-landing-discovery-inventory.test.tsx`
565
+ fails when a block type is added without being classified here.
566
+
567
+ | Surface | While booking is hidden, a link to a landing page… |
568
+ | --- | --- |
569
+ | Header menu, mobile menu, footer, header button | not rendered (`hiddenPaths`); an emptied footer column goes |
570
+ | `linkTiles` | tile dropped |
571
+ | `heroFull`, `ctaBanner`, `mediaSplit` (both buttons), `priceTable`, `countdown` (expired button), `tripList`/`courseList` button | button dropped |
572
+ | `mediaSplit` slide link, `iconGrid` card link, `instagram` profile and tiles | link removed, picture or card kept |
573
+ | Popups (link block, button or answer `openUrl`, form success `openUrl`) | popup withheld on the server (`withoutPopupsLeadingTo`) and retired in the runtime, like a booking popup |
574
+ | Booking-off notice link | dropped when it leads to booking (a booking site or the host's `bookingPagePaths`) or to a hidden page; refused at save by the host's `noticeTargetProblem` |
575
+ | `richText` | **exception**: links in editor-written text stay as written |
576
+ | `form` thank-you redirect | exempt: the result of submitting, not a link offered |
577
+ | `layout`, `faq`, `prose`, `checklist`, `imageMarquee`, `cabinTabs` | no links of their own |
578
+
579
+ Hosts add their own sections to the same rule with `useDiscoverableLink()`
580
+ (Voyara: its legacy hero buttons and destination cards).
544
581
 
545
582
  ### Storage
546
583
 
547
584
  No migration: one `site_settings` row per decision, keyed by page id —
548
- `page.listing.<uuid>`, `booking`, `booking.exception.<uuid>`. Every write is a
585
+ `page.listing.<uuid>`, `booking`, `booking.exception.<uuid>`,
586
+ `booking.landing.<uuid>` (`{ landing, notice? }`) and `booking.landing.default`
587
+ (`{ notice }`). Every write is a
549
588
  compare-and-swap on `updated_at` (`VisibilityConflictError` when someone else
550
589
  changed the row first). None is an `OVERRIDABLE_SETTING_KEYS` key, so the generic
551
590
  settings save and reset cannot reach them.
@@ -570,7 +609,11 @@ shown. The public page route takes a per-page `pagePolicy` query and adds
570
609
 
571
610
  ```ts
572
611
  pages: { fns: { setPageStatus?, setPageListing?, ... } }
573
- booking?: { fns: { getBookingSettings, setBookingEnabled, setBookingException } }
612
+ booking?: { fns: { getBookingSettings, setBookingEnabled, setBookingException,
613
+ setBookingLanding?, setBookingLandingDefault? } }
614
+ // server: VisibilityDeps.noticeTargetProblem?(href) — the host's route check
615
+ // (noticeTargetProblem(href, routes) does the work from the host's routes)
616
+ // browser policy: LinkVisibilityPolicy.bookingPagePaths — the host's booking page, every alias
574
617
  useLinkVisibility?: () => LinkVisibilityPolicy | undefined
575
618
  ```
576
619
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.95.0",
3
+ "version": "0.97.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -210,6 +210,7 @@
210
210
  "./cms/page-publication": "./src/cms/page-publication.ts",
211
211
  "./cms/page-visibility": "./src/cms/page-visibility.ts",
212
212
  "./cms/booking-links": "./src/cms/booking-links.ts",
213
+ "./cms/booking-landing": "./src/cms/booking-landing.ts",
213
214
  "./site/routes": "./src/site/routes.ts",
214
215
  "./site/PageRenderer": "./src/site/PageRenderer.tsx",
215
216
  "./server/env": "./src/server/env.ts",
@@ -239,6 +240,7 @@
239
240
  "./site/route-index": "./src/site/route-index.ts",
240
241
  "./site/route-aliases": "./src/site/route-aliases.ts",
241
242
  "./site/link-visibility": "./src/site/link-visibility.tsx",
243
+ "./site/BookingOffNotice": "./src/site/BookingOffNotice.tsx",
242
244
  "./site/SiteHeader": "./src/site/SiteHeader.tsx",
243
245
  "./site/SiteFooter": "./src/site/SiteFooter.tsx",
244
246
  "./server/schemas": "./src/server/schemas.ts",
@@ -37,6 +37,8 @@ export const ACTION_LABELS: Record<string, string> = {
37
37
  "page.listing": "Changed whether a page is listed",
38
38
  "booking.settings": "Switched booking on or off",
39
39
  "booking.exception": "Changed which pages keep booking links",
40
+ "booking.landing": "Changed which pages are hidden from the website while booking is hidden",
41
+ "booking.landing.default": "Changed the booking-off landing page message",
40
42
  "page.version.create": "Saved page version",
41
43
  "page.version.restore": "Restored page version",
42
44
  "template.create": "Created block template",