@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.
- package/CHANGELOG.md +146 -0
- package/docs/PAGES.md +72 -4
- package/package.json +8 -1
- package/src/admin/describe-log.ts +3 -0
- package/src/backoffice/BookingSettingsCard.tsx +252 -0
- package/src/backoffice/NavigationView.tsx +38 -0
- package/src/backoffice/PageBuilderView.tsx +149 -3
- package/src/backoffice/PageEditorView.tsx +47 -24
- package/src/backoffice/PagesListView.tsx +173 -57
- package/src/backoffice/SiteSettingsView.tsx +4 -0
- package/src/backoffice/StatusBadge.tsx +38 -2
- package/src/backoffice/media/ConfirmDialog.tsx +19 -1
- package/src/backoffice/routes/host.ts +24 -0
- package/src/backoffice/routes/index.tsx +54 -0
- package/src/backoffice/routes/pages.tsx +13 -0
- package/src/cms/booking-links.ts +176 -0
- package/src/cms/page-publication.ts +11 -6
- package/src/cms/page-visibility.ts +206 -0
- package/src/forms/form-audit.ts +22 -2
- package/src/html/html-tokenizer.ts +254 -0
- package/src/html/sanitize-html.ts +72 -37
- package/src/navigation/visibility.ts +235 -0
- package/src/popups/runtime.tsx +155 -4
- package/src/server/forms-core.ts +9 -0
- package/src/server/forms-delivery-core.ts +29 -22
- package/src/server/forms-submissions-core.ts +8 -5
- package/src/server/media-usage-core.ts +53 -13
- package/src/server/outbound-address.ts +131 -0
- package/src/server/outbound-http.ts +565 -0
- package/src/server/page-visibility-core.ts +450 -0
- package/src/server/pages-core.ts +238 -9
- package/src/server/popup-optin-webhook-core.ts +79 -140
- package/src/server/published-cache.ts +23 -3
- package/src/server/schemas.ts +17 -0
- package/src/site/PageRenderer.tsx +34 -5
- package/src/site/SiteFooter.tsx +11 -2
- package/src/site/SiteHeader.tsx +41 -21
- package/src/site/link-visibility.tsx +28 -0
- package/src/site/preview-messaging.ts +232 -0
- package/src/site/route-aliases.ts +74 -0
- package/src/site/routes/preview-page.tsx +75 -31
- package/src/site/routes/public-page.tsx +81 -9
- package/src/util/redact-url.ts +18 -0
package/src/server/pages-core.ts
CHANGED
|
@@ -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<
|
|
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) => ({
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
input.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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;
|