@bygghammar/cms-platform 0.95.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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The page builder's channel to its preview canvas.
3
+ *
4
+ * The builder renders the page being edited in an iframe (the preview route)
5
+ * and streams unsaved drafts into it; the canvas answers with a ready signal
6
+ * and block selections. The preview route is public, so its page can be
7
+ * opened or framed by anyone — a message that only *says* it comes from the
8
+ * builder proves nothing. Both ends therefore check who is talking:
9
+ *
10
+ * - the canvas accepts the builder's messages only from its own parent frame
11
+ * (`event.source`), only from an expected origin (its own, plus any origin a
12
+ * site explicitly names), and only in one builder session, in order;
13
+ * - the builder accepts the canvas's messages only from its own iframe's
14
+ * window and the iframe's origin;
15
+ * - nothing is ever posted to `"*"`: every message names the origin that may
16
+ * receive it, so unsaved drafts never reach a page that navigated elsewhere.
17
+ *
18
+ * Malformed, oversized, out-of-order and repeated messages are ignored.
19
+ */
20
+
21
+ export const EDITOR_SOURCE = "lovable-editor";
22
+ export const PREVIEW_SOURCE = "lovable-preview";
23
+
24
+ /** Largest draft accepted, measured as JSON. A whole page with its globals is far smaller. */
25
+ export const PREVIEW_MESSAGE_MAX_BYTES = 4 * 1024 * 1024;
26
+
27
+ const MAX_ID = 200;
28
+ const KEYS_THAT_ARE_NOT_DATA = new Set(["__proto__", "constructor", "prototype"]);
29
+
30
+ export type EditorDraftMessage = {
31
+ source: typeof EDITOR_SOURCE;
32
+ session: string;
33
+ seq: number;
34
+ type: "draft";
35
+ blocks: unknown[];
36
+ globals: Record<string, unknown[]>;
37
+ selectedId: string | null;
38
+ locale?: string;
39
+ sharedContent?: Record<string, unknown>;
40
+ };
41
+
42
+ export type EditorScrollMessage = {
43
+ source: typeof EDITOR_SOURCE;
44
+ session: string;
45
+ seq: number;
46
+ type: "scrollTo";
47
+ blockId: string;
48
+ };
49
+
50
+ export type EditorMessage = EditorDraftMessage | EditorScrollMessage;
51
+
52
+ export type PreviewMessage =
53
+ | { source: typeof PREVIEW_SOURCE; type: "ready" }
54
+ | { source: typeof PREVIEW_SOURCE; type: "select"; blockId: string };
55
+
56
+ /** The parts of a `MessageEvent` the guards read. */
57
+ export type IncomingMessage = { data: unknown; origin: string; source: unknown };
58
+
59
+ /** A serialized origin (`https://host[:port]`), or null for anything else. */
60
+ export function normalizeOrigin(value: string | null | undefined): string | null {
61
+ if (!value) return null;
62
+ try {
63
+ const url = new URL(value);
64
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
65
+ if (url.username || url.password) return null;
66
+ return url.origin;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /** The origins the canvas trusts: its own first, then any the site names explicitly. */
73
+ export function trustedEditorOrigins(own: string, extra: readonly string[] = []): string[] {
74
+ const out: string[] = [];
75
+ for (const value of [own, ...extra]) {
76
+ const origin = normalizeOrigin(value);
77
+ if (origin && !out.includes(origin)) out.push(origin);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** The origin of the page the builder frames, resolved against the builder's address. */
83
+ export function previewOriginOf(previewUrl: string, base: string): string | null {
84
+ try {
85
+ return normalizeOrigin(new URL(previewUrl, base).href);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /** A fresh, unguessable builder session id. */
92
+ export function newPreviewSession(): string {
93
+ const bytes = new Uint8Array(16);
94
+ globalThis.crypto.getRandomValues(bytes);
95
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
96
+ }
97
+
98
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
99
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
100
+ const proto = Object.getPrototypeOf(value) as unknown;
101
+ return proto === Object.prototype || proto === null;
102
+ }
103
+
104
+ function hasOnlyDataKeys(value: Record<string, unknown>): boolean {
105
+ return Object.keys(value).every((key) => !KEYS_THAT_ARE_NOT_DATA.has(key));
106
+ }
107
+
108
+ const isId = (value: unknown): value is string =>
109
+ typeof value === "string" && value.length > 0 && value.length <= MAX_ID;
110
+
111
+ function sizeWithin(value: unknown, maxBytes: number): boolean {
112
+ try {
113
+ const json = JSON.stringify(value);
114
+ return typeof json === "string" && json.length <= maxBytes;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /** The builder's message, checked for shape; null when it is not one. */
121
+ export function parseEditorMessage(
122
+ data: unknown,
123
+ maxBytes = PREVIEW_MESSAGE_MAX_BYTES,
124
+ ): EditorMessage | null {
125
+ if (!isPlainObject(data) || data["source"] !== EDITOR_SOURCE) return null;
126
+ const session = data["session"];
127
+ const seq = data["seq"];
128
+ if (typeof session !== "string" || session.length < 16 || session.length > 128) return null;
129
+ if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) return null;
130
+ if (!sizeWithin(data, maxBytes)) return null;
131
+
132
+ if (data["type"] === "scrollTo") {
133
+ if (!isId(data["blockId"])) return null;
134
+ return { source: EDITOR_SOURCE, session, seq, type: "scrollTo", blockId: data["blockId"] };
135
+ }
136
+ if (data["type"] !== "draft") return null;
137
+
138
+ const blocks = data["blocks"];
139
+ const globals = data["globals"];
140
+ const selectedId = data["selectedId"] ?? null;
141
+ const locale = data["locale"];
142
+ const sharedContent = data["sharedContent"];
143
+ if (!Array.isArray(blocks)) return null;
144
+ if (!isPlainObject(globals) || !hasOnlyDataKeys(globals)) return null;
145
+ if (!Object.values(globals).every((value) => Array.isArray(value))) return null;
146
+ if (selectedId !== null && !isId(selectedId)) return null;
147
+ if (locale !== undefined && (typeof locale !== "string" || locale.length > 16)) return null;
148
+ if (sharedContent !== undefined && (!isPlainObject(sharedContent) || !hasOnlyDataKeys(sharedContent))) {
149
+ return null;
150
+ }
151
+ return {
152
+ source: EDITOR_SOURCE,
153
+ session,
154
+ seq,
155
+ type: "draft",
156
+ blocks,
157
+ globals: globals as Record<string, unknown[]>,
158
+ selectedId,
159
+ ...(locale !== undefined ? { locale: locale as string } : {}),
160
+ ...(sharedContent !== undefined ? { sharedContent } : {}),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * The canvas's side: accepts the builder's messages from its parent frame and
166
+ * a trusted origin, in one session and in order.
167
+ */
168
+ export function createEditorMessageGuard(options: {
169
+ trustedOrigins: readonly string[];
170
+ /** The window that framed the canvas (`window.parent`). */
171
+ parent: () => unknown;
172
+ /** The canvas's own window, to tell "not framed" apart. */
173
+ self: () => unknown;
174
+ maxBytes?: number;
175
+ }) {
176
+ const trusted = new Set(options.trustedOrigins);
177
+ let session: string | null = null;
178
+ let lastSeq = -1;
179
+ let origin: string | null = null;
180
+
181
+ return {
182
+ accept(event: IncomingMessage): EditorMessage | null {
183
+ const parent = options.parent();
184
+ if (!parent || parent === options.self()) return null;
185
+ if (event.source !== parent) return null;
186
+ if (!trusted.has(event.origin)) return null;
187
+ if (origin !== null && event.origin !== origin) return null;
188
+ const message = parseEditorMessage(event.data, options.maxBytes);
189
+ if (!message) return null;
190
+ if (session !== null && message.session !== session) return null;
191
+ if (message.seq <= lastSeq) return null;
192
+ session = message.session;
193
+ origin = event.origin;
194
+ lastSeq = message.seq;
195
+ return message;
196
+ },
197
+ /** Where replies may go: the builder's verified origin, or every trusted one before it spoke. */
198
+ replyOrigins(): string[] {
199
+ return origin !== null ? [origin] : [...trusted];
200
+ },
201
+ };
202
+ }
203
+
204
+ /** The canvas's message, checked for shape; null when it is not one. */
205
+ export function parsePreviewMessage(data: unknown): PreviewMessage | null {
206
+ if (!isPlainObject(data) || data["source"] !== PREVIEW_SOURCE) return null;
207
+ if (data["type"] === "ready") return { source: PREVIEW_SOURCE, type: "ready" };
208
+ if (data["type"] === "select" && isId(data["blockId"])) {
209
+ return { source: PREVIEW_SOURCE, type: "select", blockId: data["blockId"] };
210
+ }
211
+ return null;
212
+ }
213
+
214
+ /** The builder's side: accepts only its own iframe's messages, from the iframe's origin. */
215
+ export function acceptPreviewMessage(
216
+ event: IncomingMessage,
217
+ options: { frame: unknown; previewOrigin: string | null },
218
+ ): PreviewMessage | null {
219
+ if (!options.frame || event.source !== options.frame) return null;
220
+ if (!options.previewOrigin || event.origin !== options.previewOrigin) return null;
221
+ return parsePreviewMessage(event.data);
222
+ }
223
+
224
+ /** Posts to each origin in turn; a window at any other origin receives nothing. */
225
+ export function postToOrigins(
226
+ target: { postMessage: (message: unknown, targetOrigin: string) => void } | null | undefined,
227
+ message: unknown,
228
+ origins: readonly string[],
229
+ ): void {
230
+ if (!target) return;
231
+ for (const origin of origins) target.postMessage(message, origin);
232
+ }
@@ -8,10 +8,16 @@
8
8
  * and draft store only.
9
9
  */
10
10
  import { useQueryClient } from "@tanstack/react-query";
11
- import { useEffect, useState, type ComponentType } from "react";
11
+ import { useEffect, useRef, useState, type ComponentType } from "react";
12
12
 
13
13
  import { LocaleProvider, isLocale, DEFAULT_LOCALE, type Locale } from "../../i18n";
14
14
  import type { BlockInstance } from "../../blocks/types";
15
+ import {
16
+ createEditorMessageGuard,
17
+ postToOrigins,
18
+ PREVIEW_SOURCE,
19
+ trustedEditorOrigins,
20
+ } from "../preview-messaging";
15
21
 
16
22
  export type PreviewDraftSnapshot = {
17
23
  blocks: BlockInstance[];
@@ -43,6 +49,12 @@ export type PreviewPageDeps = {
43
49
  /** Outline colour used for block hover/selection. */
44
50
  outlineColorVar?: string;
45
51
  copy?: { draft?: string; published?: string; loading?: string };
52
+ /**
53
+ * Origins other than the preview's own that may drive the canvas — only
54
+ * when the builder is served from a different origin than the site. The
55
+ * canvas otherwise listens only to a builder on its own origin.
56
+ */
57
+ trustedEditorOrigins?: readonly string[];
46
58
  /**
47
59
  * What a visitor would get, for the standalone Preview: a hook returning a
48
60
  * transform the site applies to the blocks it shows (booking links removed
@@ -79,17 +91,6 @@ const asIs = (
79
91
  ) => snapshot;
80
92
  const useAsIs = () => asIs;
81
93
 
82
- type DraftMsg = {
83
- source: "lovable-editor";
84
- type: "draft" | "scrollTo";
85
- blocks: BlockInstance[];
86
- globals: Record<string, BlockInstance[]>;
87
- selectedId?: string | null;
88
- blockId?: string;
89
- locale?: string;
90
- sharedContent?: Record<string, unknown>;
91
- };
92
-
93
94
  export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKey: string }> {
94
95
  const copy = {
95
96
  draft: deps.copy?.draft ?? "Draft preview — unsaved changes",
@@ -168,14 +169,23 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
168
169
  if (q && isLocale(q)) setLocale(q);
169
170
  }, []);
170
171
 
171
- // Ready handshake plus draft/scroll messages from the editor.
172
+ // Ready handshake plus draft/scroll messages from the editor: only from
173
+ // the frame that embeds this canvas, on a trusted origin, in order (see
174
+ // preview-messaging). Replies name that origin; nothing goes to "*".
175
+ const replyOrigins = useRef<() => string[]>(() => []);
172
176
  useEffect(() => {
173
- const send = (msg: unknown) =>
174
- window.parent && window.parent !== window && window.parent.postMessage(msg, "*");
175
- send({ source: "lovable-preview", type: "ready" });
177
+ const guard = createEditorMessageGuard({
178
+ trustedOrigins: trustedEditorOrigins(window.location.origin, deps.trustedEditorOrigins),
179
+ parent: () => window.parent,
180
+ self: () => window,
181
+ });
182
+ replyOrigins.current = () => guard.replyOrigins();
183
+ if (window.parent !== window) {
184
+ postToOrigins(window.parent, { source: PREVIEW_SOURCE, type: "ready" }, guard.replyOrigins());
185
+ }
176
186
  const onMsg = (ev: MessageEvent) => {
177
- const data = ev.data as DraftMsg | undefined;
178
- if (!data || data.source !== "lovable-editor") return;
187
+ const data = guard.accept(ev);
188
+ if (!data) return;
179
189
  if (data.type === "scrollTo") {
180
190
  const id = data.blockId;
181
191
  if (!id) return;
@@ -186,7 +196,6 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
186
196
  });
187
197
  return;
188
198
  }
189
- if (data.type !== "draft") return;
190
199
  if (data.sharedContent && Object.keys(data.sharedContent).length) {
191
200
  queryClient.setQueryData(
192
201
  deps.sharedContentQueryKey,
@@ -196,9 +205,9 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
196
205
  }),
197
206
  );
198
207
  }
199
- setBlocks(data.blocks);
200
- setGlobals(data.globals);
201
- setSelectedId(data.selectedId ?? null);
208
+ setBlocks(data.blocks as BlockInstance[]);
209
+ setGlobals(data.globals as Record<string, BlockInstance[]>);
210
+ setSelectedId(data.selectedId);
202
211
  if (data.locale && isLocale(data.locale)) setLocale(data.locale);
203
212
  setIsDraft(true);
204
213
  setReady(true);
@@ -221,13 +230,12 @@ export function createPreviewPage(deps: PreviewPageDeps): ComponentType<{ pageKe
221
230
  }
222
231
  e.preventDefault();
223
232
  e.stopPropagation();
224
- window.parent.postMessage(
225
- {
226
- source: "lovable-preview",
227
- type: "select",
228
- blockId: el.getAttribute("data-block-id"),
229
- },
230
- "*",
233
+ const blockId = el.getAttribute("data-block-id");
234
+ if (!blockId) return;
235
+ postToOrigins(
236
+ window.parent,
237
+ { source: PREVIEW_SOURCE, type: "select", blockId },
238
+ replyOrigins.current(),
231
239
  );
232
240
  };
233
241
  document.addEventListener("click", onClick, true);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * An address as it may be stored or shown: its origin, and never its
3
+ * credentials, path, query string or fragment.
4
+ *
5
+ * Receivers put keys in paths (`/services/T…/B…/<key>`) as readily as in query
6
+ * strings, so whatever follows the origin is replaced by a fixed marker rather
7
+ * than trimmed. Pure, so audit code in the browser can use it too.
8
+ */
9
+ export function redactUrl(raw: string | URL): string {
10
+ try {
11
+ const url = typeof raw === "string" ? new URL(raw) : raw;
12
+ if (url.origin === "null") return "(invalid address)";
13
+ const rest = url.pathname !== "/" || url.search !== "" || url.hash !== "";
14
+ return rest ? `${url.origin}/[redacted]` : url.origin;
15
+ } catch {
16
+ return "(invalid address)";
17
+ }
18
+ }