@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
@@ -37,6 +37,13 @@ import {
37
37
  type PublicationWindow,
38
38
  } from "../cms/page-publication";
39
39
  import { resolveGlobalsDeep } from "./resolve-globals";
40
+ import { normalizePageListing, type PageListing } from "../cms/page-visibility";
41
+ import {
42
+ assertNotProtected,
43
+ PAGE_LISTING_PREFIX,
44
+ readVisibilitySettingsCore,
45
+ type VersionToken,
46
+ } from "./page-visibility-core";
40
47
  import { CONTRACT_V2_MIGRATIONS, CONTRACT_V3_MIGRATIONS } from "../platform/contract";
41
48
  import { digestJson, type DigestValue } from "../platform/digest";
42
49
 
@@ -50,8 +57,48 @@ export type PagesDeps = {
50
57
  context: PagesContext;
51
58
  /** Privileged client; only used to resolve legacy version authors. */
52
59
  admin?: () => Promise<any>;
60
+ /**
61
+ * Keys of pages that must always stay published and listed (for Voyara the
62
+ * start page and the contact page). The cores refuse to unpublish, end-date,
63
+ * trash or unlist them. Supplied by the host's server code, never the browser.
64
+ */
65
+ protectedPageKeys?: readonly string[];
66
+ };
67
+
68
+ /** Listing fields the list and the builder show next to publication. */
69
+ export type PageListingFields = {
70
+ listing: PageListing;
71
+ /** Version token to send back with a listing change (null: never changed). */
72
+ listing_version: VersionToken;
73
+ /** Protected pages offer neither Unpublish nor Make link-only. */
74
+ protected: boolean;
53
75
  };
54
76
 
77
+ function listingFieldsFor(
78
+ pageId: string,
79
+ key: string,
80
+ settings: Awaited<ReturnType<typeof readVisibilitySettingsCore>>,
81
+ protectedPageKeys: readonly string[] | undefined,
82
+ ): PageListingFields {
83
+ const row = settings.listings.get(pageId.toLowerCase());
84
+ return {
85
+ listing: normalizePageListing(row?.listing),
86
+ listing_version: row?.version ?? null,
87
+ protected: (protectedPageKeys ?? []).includes(key),
88
+ };
89
+ }
90
+
91
+ /** Name and key of one page, for the protected-page rule. */
92
+ async function pageIdentity(client: any, pageId: string): Promise<{ key: string; name: string } | null> {
93
+ const { data, error } = await client
94
+ .from("pages")
95
+ .select("key,name")
96
+ .eq("id", pageId)
97
+ .maybeSingle();
98
+ if (error) throw new Error(error.message);
99
+ return (data as { key: string; name: string } | null) ?? null;
100
+ }
101
+
55
102
  export type ResolvedPage = {
56
103
  key: string;
57
104
  name: string;
@@ -304,9 +351,10 @@ export type PageListRow = {
304
351
  status: string;
305
352
  updated_at: string;
306
353
  current_version_id: string | null;
307
- } & PublicationSummaryFields;
354
+ } & PublicationSummaryFields &
355
+ PageListingFields;
308
356
 
309
- export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow[]> {
357
+ export async function listPagesCore({ context, protectedPageKeys }: PagesDeps): Promise<PageListRow[]> {
310
358
  await assertStaff(context);
311
359
  const { data, error } = await context.supabase
312
360
  .from("pages")
@@ -315,7 +363,9 @@ export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow
315
363
  .order("is_system", { ascending: false })
316
364
  .order("name");
317
365
  if (error) throw new Error(error.message);
318
- const rows = (data ?? []) as Array<Omit<PageListRow, keyof PublicationSummaryFields>>;
366
+ const rows = (data ?? []) as Array<
367
+ Omit<PageListRow, keyof PublicationSummaryFields | keyof PageListingFields>
368
+ >;
319
369
  if (rows.length === 0) return [];
320
370
 
321
371
  /*
@@ -341,11 +391,17 @@ export async function listPagesCore({ context }: PagesDeps): Promise<PageListRow
341
391
  byPage.set(pageId, [...(byPage.get(pageId) ?? []), ...toPublicationWindows([row])]);
342
392
  }
343
393
 
394
+ // Listing is its own decision, stored apart from publication; read once.
395
+ const settings = await readVisibilitySettingsCore(context.supabase);
344
396
  const now = Date.now();
345
- return rows.map((row) => ({ ...row, ...summarize(byPage.get(row.id) ?? [], now) }));
397
+ return rows.map((row) => ({
398
+ ...row,
399
+ ...summarize(byPage.get(row.id) ?? [], now),
400
+ ...listingFieldsFor(row.id, row.key, settings, protectedPageKeys),
401
+ }));
346
402
  }
347
403
 
348
- export async function getPageForEditCore({ context }: PagesDeps, key: string) {
404
+ export async function getPageForEditCore({ context, protectedPageKeys }: PagesDeps, key: string) {
349
405
  await assertStaff(context);
350
406
  const { data: page, error } = await context.supabase
351
407
  .from("pages")
@@ -373,7 +429,9 @@ export async function getPageForEditCore({ context }: PagesDeps, key: string) {
373
429
  * the public routes use.
374
430
  */
375
431
  const publication = await publicationOf(context.supabase, page.id);
376
- return { page: { ...page, ...publication }, blocks };
432
+ const settings = await readVisibilitySettingsCore(context.supabase);
433
+ const listing = listingFieldsFor(page.id, page.key, settings, protectedPageKeys);
434
+ return { page: { ...page, ...publication, ...listing }, blocks };
377
435
  }
378
436
 
379
437
  /**
@@ -649,7 +707,7 @@ export async function createPageCore({ context }: PagesDeps, input: { key: strin
649
707
  * or a schedule somebody forgot about.
650
708
  */
651
709
  export async function setPageStatusCore(
652
- { context }: PagesDeps,
710
+ { context, protectedPageKeys }: PagesDeps,
653
711
  input: { pageId: string; status: PageStatus; editSessionId?: string },
654
712
  ) {
655
713
  await assertStaff(context);
@@ -670,6 +728,7 @@ export async function setPageStatusCore(
670
728
  if (status === "published" && !page.current_version_id) {
671
729
  throw new Error("This page has no saved content yet, so there is nothing to publish.");
672
730
  }
731
+ if (status === "draft") assertNotProtected(protectedPageKeys, page, "unpublished");
673
732
 
674
733
  invalidatePublishedCache();
675
734
 
@@ -774,6 +833,11 @@ export async function duplicatePageCore(
774
833
  blocks = duplicateBlockTree(blocksOf(version.blocks));
775
834
  }
776
835
 
836
+ // Before the copy exists: what this duplication means for its listing, fixed
837
+ // now and kept with the idempotency key, so a retry completes this decision
838
+ // rather than taking a new one from the source as it is by then.
839
+ const intent = await recordDuplicateIntent(context, idempotencyKey, input.pageId);
840
+
777
841
  invalidatePublishedCache();
778
842
  const copy = await publicationRpc(
779
843
  context.supabase,
@@ -797,6 +861,7 @@ export async function duplicatePageCore(
797
861
 
798
862
  const id = typeof copy?.pageId === "string" ? copy.pageId : "";
799
863
  if (!id) throw new Error("The database made no copy of this page.");
864
+ await completeDuplicateListing(context, intent, id);
800
865
  return {
801
866
  id,
802
867
  key: String(copy.key ?? key),
@@ -846,7 +911,7 @@ export async function renamePageCore(
846
911
  * publishing action nobody confirmed.
847
912
  */
848
913
  export async function deletePageCore(
849
- { context }: PagesDeps,
914
+ { context, protectedPageKeys }: PagesDeps,
850
915
  pageId: string,
851
916
  editSessionId?: string,
852
917
  ) {
@@ -856,6 +921,8 @@ export async function deletePageCore(
856
921
  resourceId: pageId,
857
922
  sessionId: editSessionId,
858
923
  });
924
+ const identity = await pageIdentity(context.supabase, pageId);
925
+ if (identity) assertNotProtected(protectedPageKeys, identity, "moved to the trash");
859
926
  invalidatePublishedCache();
860
927
  await publicationRpc(
861
928
  context.supabase,
@@ -956,6 +1023,163 @@ export async function purgePageCore({ context }: PagesDeps, pageId: string) {
956
1023
  return { ok: true as const };
957
1024
  }
958
1025
 
1026
+ /** Where a duplication's listing decision is kept until it has been applied. */
1027
+ const DUPLICATE_INTENT_PREFIX = "duplicate.intent.";
1028
+
1029
+ type DuplicateIntent = {
1030
+ key: string;
1031
+ /** What the copy's listing is to be: the source's, as it was when the row was written. */
1032
+ listing: PageListing;
1033
+ /** When the database wrote the row (`site_settings.created_at`, its own clock). */
1034
+ recordedAt: string;
1035
+ };
1036
+
1037
+ /**
1038
+ * A copy of a link-only page starts link-only too.
1039
+ *
1040
+ * The copy is a draft, so nothing is public yet; but the moment somebody
1041
+ * publishes it, a listed copy would put the address the original kept out of
1042
+ * menus and the sitemap straight into them. Booking exceptions are never
1043
+ * copied: the safe default for a new page is booking hidden while booking is
1044
+ * off.
1045
+ *
1046
+ * The copy is made in one transaction and listed in a second write, so the
1047
+ * listing can fail after the copy exists, and a retry with the same
1048
+ * idempotency key is how a caller finishes it. A retry must finish *that*
1049
+ * decision and never take a new one: if the source has been listed or
1050
+ * unlisted since, a call that read the source again would change a copy
1051
+ * nobody asked to change. So the decision is written down before the copy is
1052
+ * made — a `site_settings` row keyed by the idempotency key, durable across
1053
+ * processes, no migration — and applied after it.
1054
+ *
1055
+ * Which row is that decision is settled by the database's own record, not by
1056
+ * which call wrote it, whether a cleanup succeeded, or how many calls are in
1057
+ * flight: the decision is the row written **before the copy existed**. The
1058
+ * row's `created_at` and the copy's `pages.created_at` are both the
1059
+ * database's `now()`, and the row is always committed before the call that
1060
+ * makes the copy starts. A row written by a call that arrived after the copy
1061
+ * was made — a replay of a finished duplication, alone or overlapping
1062
+ * another, or one whose cleanup failed — is only an observation of the source
1063
+ * as it is today, and is never applied.
1064
+ *
1065
+ * The row is inserted only if absent (`ON CONFLICT DO NOTHING`), so the first
1066
+ * attempt's decision is the one that stands, and its `created_at` never moves.
1067
+ * The table is publicly readable, so the row holds the decision only, as the
1068
+ * page's own listing row would.
1069
+ */
1070
+ async function recordDuplicateIntent(
1071
+ context: PagesContext,
1072
+ idempotencyKey: string,
1073
+ sourceId: string,
1074
+ ): Promise<DuplicateIntent | null> {
1075
+ const key = `${DUPLICATE_INTENT_PREFIX}${idempotencyKey.toLowerCase()}`;
1076
+ const settings = await readVisibilitySettingsCore(context.supabase);
1077
+ const listing = normalizePageListing(settings.listings.get(sourceId.toLowerCase())?.listing);
1078
+ const { error } = await context.supabase
1079
+ .from("site_settings")
1080
+ .upsert(
1081
+ { key, value: { listing }, updated_by: context.userId },
1082
+ { onConflict: "key", ignoreDuplicates: true },
1083
+ )
1084
+ .select("key");
1085
+ if (error) throw new Error(`Could not prepare the copy: ${error.message}. Nothing was copied.`);
1086
+ // Whoever wrote it, the row now in the database is the one that counts.
1087
+ const { data: row, error: readError } = await context.supabase
1088
+ .from("site_settings")
1089
+ .select("value, created_at")
1090
+ .eq("key", key)
1091
+ .maybeSingle();
1092
+ if (readError) throw new Error(`Could not prepare the copy: ${readError.message}. Nothing was copied.`);
1093
+ // Gone again: a call that finished this duplication has applied and removed it.
1094
+ if (!row) return null;
1095
+ const value = (row.value ?? {}) as { listing?: unknown };
1096
+ return { key, listing: normalizePageListing(value.listing), recordedAt: String(row.created_at) };
1097
+ }
1098
+
1099
+ /**
1100
+ * Apply the duplication's recorded listing decision to the copy, if it is the
1101
+ * original decision (see {@link recordDuplicateIntent}).
1102
+ *
1103
+ * Only ever inserts a missing listing row, so a listing chosen for the copy
1104
+ * since (a row already present, listed or link-only) is never overwritten,
1105
+ * and applying the same decision twice is harmless.
1106
+ */
1107
+ async function completeDuplicateListing(
1108
+ context: PagesContext,
1109
+ intent: DuplicateIntent | null,
1110
+ copyId: string,
1111
+ ): Promise<void> {
1112
+ if (!intent) return;
1113
+ const { data: copy, error } = await context.supabase
1114
+ .from("pages")
1115
+ .select("created_at")
1116
+ .eq("id", copyId)
1117
+ .maybeSingle();
1118
+ const recorded = timestampMicros(intent.recordedAt);
1119
+ const created = timestampMicros(copy?.created_at);
1120
+ if (error || recorded === null || created === null) {
1121
+ // Undecidable now. The decision stays where it is, for a retry to settle.
1122
+ throw new Error(
1123
+ "The copy was made as a draft, but whether it is listed like the original could not be settled" +
1124
+ (error ? `: ${error.message}` : "") +
1125
+ ". Retrying the same duplication (same idempotency key) completes it without a second copy; " +
1126
+ "otherwise check the copy's listing before publishing it.",
1127
+ );
1128
+ }
1129
+ if (recorded < created && intent.listing === "link-only") {
1130
+ const { error: writeError } = await context.supabase
1131
+ .from("site_settings")
1132
+ .upsert(
1133
+ {
1134
+ key: `${PAGE_LISTING_PREFIX}${copyId.toLowerCase()}`,
1135
+ value: { listing: "link-only" },
1136
+ updated_by: context.userId,
1137
+ },
1138
+ { onConflict: "key", ignoreDuplicates: true },
1139
+ )
1140
+ .select("updated_at");
1141
+ if (writeError) {
1142
+ throw new Error(
1143
+ `The copy was made as a draft, but it could not be marked link-only like the original: ${writeError.message}. ` +
1144
+ "Retrying the same duplication (same idempotency key) completes it without a second copy; " +
1145
+ "otherwise set the copy to link-only before publishing it.",
1146
+ );
1147
+ }
1148
+ invalidatePublishedCache();
1149
+ }
1150
+ await forgetDuplicateIntent(context, intent);
1151
+ }
1152
+
1153
+ /**
1154
+ * Remove the decision row once it has been dealt with. Nothing depends on this
1155
+ * succeeding, so a failure is not an error: an original decision left behind
1156
+ * only ever inserts what is already there, and a row written after the copy
1157
+ * existed is never applied. Whatever row holds the key by now was written
1158
+ * after the copy existed, so removing it loses no pending decision.
1159
+ */
1160
+ async function forgetDuplicateIntent(context: PagesContext, intent: DuplicateIntent): Promise<void> {
1161
+ await context.supabase.from("site_settings").delete().eq("key", intent.key);
1162
+ }
1163
+
1164
+ /**
1165
+ * A `timestamptz` as PostgREST returns it (`2026-10-16T11:03:36.123456+00:00`),
1166
+ * in whole microseconds since the epoch, so two instants in the same
1167
+ * millisecond still compare correctly. Null when it is not one.
1168
+ */
1169
+ function timestampMicros(value: unknown): number | null {
1170
+ if (typeof value !== "string") return null;
1171
+ const match = /^(\d{4}-\d\d-\d\d)[T ](\d\d:\d\d:\d\d)(?:\.(\d{1,6})\d*)?(Z|[+-]\d\d(?::?\d\d)?)$/.exec(
1172
+ value.trim(),
1173
+ );
1174
+ if (!match) return null;
1175
+ const offset = match[4]!;
1176
+ const zone =
1177
+ offset === "Z" ? "Z" : offset.length === 3 ? `${offset}:00` : offset.replace(/^([+-]\d\d)(\d\d)$/, "$1:$2");
1178
+ const seconds = Date.parse(`${match[1]}T${match[2]}${zone}`);
1179
+ if (!Number.isFinite(seconds)) return null;
1180
+ return seconds * 1000 + Number((match[3] ?? "").padEnd(6, "0"));
1181
+ }
1182
+
959
1183
  /* ---------- Global blocks ---------- */
960
1184
 
961
1185
  /** A global block holds exactly one block and may not reference other globals. */
@@ -1236,7 +1460,7 @@ function scheduleInstant(value: unknown, field: string): number {
1236
1460
  * and the page it promised to reveal stayed hidden for good.
1237
1461
  */
1238
1462
  export async function schedulePageVersionCore(
1239
- { context }: PagesDeps,
1463
+ { context, protectedPageKeys }: PagesDeps,
1240
1464
  input: {
1241
1465
  versionId: string;
1242
1466
  publishAt: string;
@@ -1271,6 +1495,11 @@ export async function schedulePageVersionCore(
1271
1495
  resourceId: v.page_id,
1272
1496
  sessionId: input.editSessionId,
1273
1497
  });
1498
+ // An end date is an unpublish on a timer, which a protected page refuses too.
1499
+ if (unpublishAt !== null) {
1500
+ const identity = await pageIdentity(context.supabase, v.page_id);
1501
+ if (identity) assertNotProtected(protectedPageKeys, identity, "given an unpublish time");
1502
+ }
1274
1503
  invalidatePublishedCache();
1275
1504
  const scheduled = await publicationRpc(
1276
1505
  context.supabase,
@@ -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
  // ---------------------------------------------------------------------------
@@ -13,7 +13,7 @@
13
13
  * tests. A cold process simply has an empty cache.
14
14
  */
15
15
 
16
- export type PublishedCacheScope = "page" | "global" | "custom";
16
+ export type PublishedCacheScope = "page" | "global" | "policy" | "custom";
17
17
 
18
18
  type Entry = { value: unknown; expiresAt: number };
19
19
 
@@ -22,6 +22,16 @@ const store = new Map<string, Entry>();
22
22
  /** How long an entry may be served before it is re-read. */
23
23
  let ttlMs = 20_000;
24
24
 
25
+ /**
26
+ * Bumped by every invalidation. A load records the generation it started in
27
+ * and may only store its answer if no invalidation happened meanwhile: a read
28
+ * that began before a write and finished after it holds the old state, and
29
+ * putting that back into the cache would undo the invalidation the write just
30
+ * made — every request after the write would be served what the write
31
+ * replaced.
32
+ */
33
+ let generation = 0;
34
+
25
35
  /** Cheap counters so the backoffice can show whether the cache is working. */
26
36
  let hits = 0;
27
37
  let misses = 0;
@@ -83,6 +93,10 @@ export function publishedCacheSet(
83
93
  */
84
94
  export function invalidatePublishedCache(scope?: PublishedCacheScope, key?: string): void {
85
95
  invalidations += 1;
96
+ // Any invalidation, however narrow, retires every load already in flight:
97
+ // it may only cost a fill that was going to be correct, never let a stale
98
+ // one through.
99
+ generation += 1;
86
100
  if (!scope) {
87
101
  store.clear();
88
102
  return;
@@ -116,9 +130,14 @@ export async function withPublishedCache<T>(
116
130
  return cached;
117
131
  }
118
132
  misses += 1;
133
+ const startedIn = generation;
119
134
  const value = await load();
120
- // `undefined` is indistinguishable from "absent", so it is never stored.
121
- if (value !== undefined) publishedCacheSet(scope, key, value, boundaryOf?.(value));
135
+ // `undefined` is indistinguishable from "absent", so it is never stored. A
136
+ // value whose read began before an invalidation is returned to its caller —
137
+ // it is what that caller asked for — but never stored for anyone else.
138
+ if (value !== undefined && startedIn === generation) {
139
+ publishedCacheSet(scope, key, value, boundaryOf?.(value));
140
+ }
122
141
  return value;
123
142
  }
124
143
 
@@ -136,6 +155,7 @@ export function publishedCacheStats(): {
136
155
  /** Resets counters and contents. Tests use this between cases. */
137
156
  export function resetPublishedCache(): void {
138
157
  store.clear();
158
+ generation += 1;
139
159
  hits = 0;
140
160
  misses = 0;
141
161
  invalidations = 0;