@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.10
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 +489 -0
- package/README.md +16 -2
- package/docs/EDIT-LOCKS.md +213 -0
- package/docs/POPUPS.md +102 -0
- package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
- package/package.json +31 -3
- package/scripts/create-cms-site.mjs +5 -4
- package/scripts/sync-migrations.mjs +27 -6
- package/src/backoffice/ContactDetailsView.tsx +19 -5
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/EditLock.tsx +513 -0
- package/src/backoffice/EmailSettingsView.tsx +99 -0
- package/src/backoffice/FormEditorView.tsx +531 -512
- package/src/backoffice/FormSettingsView.tsx +16 -4
- package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
- package/src/backoffice/NavigationView.tsx +35 -11
- package/src/backoffice/PageBuilderView.tsx +51 -16
- package/src/backoffice/RichTextEditor.tsx +50 -14
- package/src/backoffice/SectionEditorView.tsx +18 -6
- package/src/backoffice/SectionFormView.tsx +184 -31
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/forms/VersionHistory.tsx +4 -2
- package/src/backoffice/inspectors/blocks.tsx +22 -1
- package/src/backoffice/inspectors/fields.tsx +21 -0
- package/src/backoffice/routes/host.ts +58 -7
- package/src/backoffice/routes/index.tsx +117 -15
- package/src/backoffice/routes/pages.tsx +10 -6
- package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/components/CtaBannerBlock.tsx +18 -12
- package/src/blocks/components/HeroFullBlock.tsx +25 -12
- package/src/blocks/components/MediaSplitBlock.tsx +91 -41
- package/src/blocks/components/PriceTableBlock.tsx +123 -89
- package/src/blocks/components/RichTextBlock.tsx +26 -4
- package/src/blocks/index.ts +8 -0
- package/src/blocks/optional-content.ts +82 -0
- package/src/blocks/registry.tsx +1 -0
- package/src/blocks/types.ts +6 -0
- package/src/cms/edit-lock.ts +491 -0
- package/src/config/modules.ts +208 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +62 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +27 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/navigation/index.ts +29 -0
- package/src/popups/admin.tsx +123 -0
- package/src/popups/cms-bridges.ts +240 -0
- package/src/popups/destinations.tsx +147 -0
- package/src/popups/host.ts +91 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +128 -0
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +22 -5
- package/src/server/domains-core.ts +124 -107
- package/src/server/edit-lock-adapter.ts +91 -0
- package/src/server/edit-lock-supabase-adapter.ts +135 -0
- package/src/server/edit-locks-core.ts +286 -0
- package/src/server/forms-core.ts +37 -2
- package/src/server/injections-core.ts +230 -49
- package/src/server/page-seo-save-core.ts +27 -0
- package/src/server/pages-core.ts +108 -11
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/provider-secrets.ts +100 -4
- package/src/server/schemas.ts +74 -9
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/site/SiteHeader.tsx +11 -5
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/tracking/injections.ts +52 -9
- package/src/types/css-modules.d.ts +6 -0
- package/src/ui/Reveal.tsx +74 -9
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a popup outcome is a genuine opt-in worth delivering.
|
|
3
|
+
*
|
|
4
|
+
* One predicate, in one file, because getting it wrong is the difference
|
|
5
|
+
* between "we sent the customer's email to their CRM" and "we sent a stranger's
|
|
6
|
+
* empty form to their CRM". Every caller goes through here; nothing decides
|
|
7
|
+
* this inline.
|
|
8
|
+
*
|
|
9
|
+
* The rule: only a stored submission counts. The honeypot path answers
|
|
10
|
+
* `{ ok: true, skipped: true }` — a deliberate lie to a bot, which the form
|
|
11
|
+
* system returns so the bot believes it succeeded — and treating that as an
|
|
12
|
+
* opt-in would deliver junk and inflate every conversion figure. Everything
|
|
13
|
+
* else a popup can produce (a "yes" click on its own, a "no", a dismissal, a
|
|
14
|
+
* "maybe later") is a decision, not a submission.
|
|
15
|
+
*
|
|
16
|
+
* Pure and host-agnostic. Server-only by convention, but safe anywhere.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What a form submit answers with. Deliberately loose: input is untrusted. */
|
|
20
|
+
export interface SubmissionOutcome {
|
|
21
|
+
readonly ok?: unknown;
|
|
22
|
+
readonly skipped?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type OptInRefusal =
|
|
26
|
+
| "not-a-submission"
|
|
27
|
+
| "submission-failed"
|
|
28
|
+
| "honeypot-skipped"
|
|
29
|
+
| "preview"
|
|
30
|
+
| "test-mode";
|
|
31
|
+
|
|
32
|
+
export type OptInDecision =
|
|
33
|
+
| { readonly deliver: true }
|
|
34
|
+
| { readonly deliver: false; readonly reason: OptInRefusal };
|
|
35
|
+
|
|
36
|
+
export interface OptInContext {
|
|
37
|
+
/** The result the form system returned. */
|
|
38
|
+
readonly outcome: SubmissionOutcome | null | undefined;
|
|
39
|
+
/** True inside the builder preview, which performs nothing. */
|
|
40
|
+
readonly preview?: boolean;
|
|
41
|
+
/** A popup marked test mode records nothing and delivers nothing. */
|
|
42
|
+
readonly testMode?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* An administrator pressing "send a test delivery". The only way a test-mode
|
|
45
|
+
* popup ever produces a request, and it is labelled as a test in the payload.
|
|
46
|
+
*/
|
|
47
|
+
readonly explicitTestDelivery?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A genuine, stored submission — the only thing that may be delivered.
|
|
52
|
+
*
|
|
53
|
+
* `ok === true && skipped !== true`, checked structurally rather than by type
|
|
54
|
+
* assertion, because this value comes back across a request boundary and an
|
|
55
|
+
* unknown shape must fail closed.
|
|
56
|
+
*/
|
|
57
|
+
export function isGenuineSubmission(
|
|
58
|
+
outcome: SubmissionOutcome | null | undefined,
|
|
59
|
+
): boolean {
|
|
60
|
+
if (outcome === null || outcome === undefined || typeof outcome !== "object")
|
|
61
|
+
return false;
|
|
62
|
+
return outcome.ok === true && outcome.skipped !== true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function shouldDeliverOptIn(context: OptInContext): OptInDecision {
|
|
66
|
+
// An explicit test delivery is an administrator action, not a visitor's, and
|
|
67
|
+
// carries no submission at all.
|
|
68
|
+
if (context.explicitTestDelivery === true) return { deliver: true };
|
|
69
|
+
|
|
70
|
+
if (context.preview === true) return { deliver: false, reason: "preview" };
|
|
71
|
+
|
|
72
|
+
const outcome = context.outcome;
|
|
73
|
+
if (
|
|
74
|
+
outcome === null ||
|
|
75
|
+
outcome === undefined ||
|
|
76
|
+
typeof outcome !== "object"
|
|
77
|
+
) {
|
|
78
|
+
return { deliver: false, reason: "not-a-submission" };
|
|
79
|
+
}
|
|
80
|
+
if (outcome.skipped === true)
|
|
81
|
+
return { deliver: false, reason: "honeypot-skipped" };
|
|
82
|
+
if (outcome.ok !== true)
|
|
83
|
+
return { deliver: false, reason: "submission-failed" };
|
|
84
|
+
|
|
85
|
+
// Test mode is checked last so the reason reported is the most specific one:
|
|
86
|
+
// an administrator debugging a test-mode popup wants to know the submission
|
|
87
|
+
// itself was fine.
|
|
88
|
+
if (context.testMode === true) return { deliver: false, reason: "test-mode" };
|
|
89
|
+
|
|
90
|
+
return { deliver: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The fields an administrator chose, and nothing else.
|
|
95
|
+
*
|
|
96
|
+
* A submission holds everything the visitor typed. A destination receives only
|
|
97
|
+
* what was explicitly mapped — so adding a "personal number" field to a form
|
|
98
|
+
* never silently starts sending it to a third party. Unmapped keys, absent
|
|
99
|
+
* keys and non-string values are all dropped rather than coerced.
|
|
100
|
+
*/
|
|
101
|
+
export function selectMappedFields(
|
|
102
|
+
submitted: Readonly<Record<string, unknown>>,
|
|
103
|
+
mapping: readonly string[],
|
|
104
|
+
): Record<string, string> {
|
|
105
|
+
const out: Record<string, string> = {};
|
|
106
|
+
for (const key of mapping) {
|
|
107
|
+
const value = submitted[key];
|
|
108
|
+
if (typeof value === "string" && value !== "") out[key] = value;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivering a genuine popup opt-in to somebody else's system.
|
|
3
|
+
*
|
|
4
|
+
* Host-agnostic: the site supplies a privileged database client, the signing
|
|
5
|
+
* secret resolved from its own environment, and the submitted values it
|
|
6
|
+
* already holds. Everything about *how* the delivery is made and secured lives
|
|
7
|
+
* here so every site gets the same guarantees.
|
|
8
|
+
*
|
|
9
|
+
* The rules this file exists to enforce:
|
|
10
|
+
*
|
|
11
|
+
* - It is sent only for a genuine, stored submission. A "yes" click, a
|
|
12
|
+
* dismissal or a honeypot hit is not an opt-in and never reaches here —
|
|
13
|
+
* the caller decides that, and `popup-optin-trigger.ts` is the one place
|
|
14
|
+
* allowed to.
|
|
15
|
+
* - The endpoint is re-validated on every hop, including redirects, so a
|
|
16
|
+
* destination that passes review cannot later redirect into the private
|
|
17
|
+
* network. `fetch` follows redirects invisibly, so redirects are followed
|
|
18
|
+
* manually here — that is the only way to see each hop.
|
|
19
|
+
* - The body is signed with HMAC-SHA256 over a timestamped string, so a
|
|
20
|
+
* receiver can verify origin AND reject a replay.
|
|
21
|
+
* - Only fields an administrator explicitly mapped are sent. Nothing else
|
|
22
|
+
* from the submission, and nothing at all from the browser's headers.
|
|
23
|
+
* - The secret never appears in a return value, an error, or a log line.
|
|
24
|
+
*
|
|
25
|
+
* Server-only. Never import from client code.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { checkWebhookUrl } from "./webhook-url-guard";
|
|
29
|
+
|
|
30
|
+
export const POPUP_WEBHOOK_SCHEMA_VERSION = 1;
|
|
31
|
+
|
|
32
|
+
/** Everything a delivery needs, with no database and no environment in sight. */
|
|
33
|
+
export interface OptInWebhookInput {
|
|
34
|
+
readonly endpoint: string;
|
|
35
|
+
/** Resolved by the host from its own environment. Never read from the database. */
|
|
36
|
+
readonly signingSecret: string;
|
|
37
|
+
/** Stable id for this delivery. The receiver uses it to deduplicate. */
|
|
38
|
+
readonly eventId: string;
|
|
39
|
+
readonly occurredAt: string;
|
|
40
|
+
readonly campaign: {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly key: string;
|
|
43
|
+
readonly name: string;
|
|
44
|
+
};
|
|
45
|
+
readonly form: { readonly id: string; readonly key?: string };
|
|
46
|
+
/** Only the fields the administrator mapped. Already filtered by the caller. */
|
|
47
|
+
readonly fields: Readonly<Record<string, string>>;
|
|
48
|
+
readonly context: {
|
|
49
|
+
readonly pageUrl?: string | null;
|
|
50
|
+
readonly referrer?: string | null;
|
|
51
|
+
readonly utmSource?: string | null;
|
|
52
|
+
readonly device?: string | null;
|
|
53
|
+
};
|
|
54
|
+
/** A test delivery an administrator asked for, clearly marked as such. */
|
|
55
|
+
readonly test?: boolean;
|
|
56
|
+
readonly timeoutMs?: number;
|
|
57
|
+
readonly maxResponseBytes?: number;
|
|
58
|
+
readonly maxRedirects?: number;
|
|
59
|
+
/** Injected in tests. */
|
|
60
|
+
readonly fetchImpl?: typeof fetch;
|
|
61
|
+
readonly now?: () => number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type DeliveryOutcome = {
|
|
65
|
+
readonly ok: boolean;
|
|
66
|
+
readonly status?: number;
|
|
67
|
+
/** A category, never a raw upstream body — that could echo back anything. */
|
|
68
|
+
readonly errorCategory?:
|
|
69
|
+
| "blocked-endpoint"
|
|
70
|
+
| "timeout"
|
|
71
|
+
| "network"
|
|
72
|
+
| "http-error"
|
|
73
|
+
| "too-large"
|
|
74
|
+
| "too-many-redirects";
|
|
75
|
+
/** One sentence for an administrator. Never contains the secret. */
|
|
76
|
+
readonly error?: string;
|
|
77
|
+
readonly durationMs: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
81
|
+
const DEFAULT_MAX_BYTES = 64 * 1024;
|
|
82
|
+
const DEFAULT_MAX_REDIRECTS = 3;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A URL safe to record and show.
|
|
86
|
+
*
|
|
87
|
+
* Query strings routinely carry tokens, so only the origin and path are kept.
|
|
88
|
+
*/
|
|
89
|
+
export function safeUrl(raw: string | null | undefined): string | null {
|
|
90
|
+
if (raw == null || raw.trim() === "") return null;
|
|
91
|
+
try {
|
|
92
|
+
const url = new URL(raw);
|
|
93
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
94
|
+
return `${url.origin}${url.pathname}`;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The versioned body. Changing its shape means bumping the schema version. */
|
|
101
|
+
export function buildOptInPayload(
|
|
102
|
+
input: OptInWebhookInput,
|
|
103
|
+
): Record<string, unknown> {
|
|
104
|
+
return {
|
|
105
|
+
schema_version: POPUP_WEBHOOK_SCHEMA_VERSION,
|
|
106
|
+
event_id: input.eventId,
|
|
107
|
+
event: input.test === true ? "popup.opt_in.test" : "popup.opt_in",
|
|
108
|
+
occurred_at: input.occurredAt,
|
|
109
|
+
campaign: {
|
|
110
|
+
id: input.campaign.id,
|
|
111
|
+
key: input.campaign.key,
|
|
112
|
+
name: input.campaign.name,
|
|
113
|
+
},
|
|
114
|
+
form:
|
|
115
|
+
input.form.key === undefined
|
|
116
|
+
? { id: input.form.id }
|
|
117
|
+
: { id: input.form.id, key: input.form.key },
|
|
118
|
+
fields: input.fields,
|
|
119
|
+
context: {
|
|
120
|
+
page_url: safeUrl(input.context.pageUrl),
|
|
121
|
+
referrer: safeUrl(input.context.referrer),
|
|
122
|
+
utm_source: input.context.utmSource ?? null,
|
|
123
|
+
device: input.context.device ?? null,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The string that gets signed.
|
|
130
|
+
*
|
|
131
|
+
* The timestamp is inside the signed material, not merely a header beside it:
|
|
132
|
+
* signing the body alone would let anyone who captured one delivery replay it
|
|
133
|
+
* forever, because the signature would still verify.
|
|
134
|
+
*/
|
|
135
|
+
export function signingBase(timestamp: string, body: string): string {
|
|
136
|
+
return `${POPUP_WEBHOOK_SCHEMA_VERSION}.${timestamp}.${body}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function signBody(
|
|
140
|
+
secret: string,
|
|
141
|
+
timestamp: string,
|
|
142
|
+
body: string,
|
|
143
|
+
): Promise<string> {
|
|
144
|
+
const { createHmac } = await import("node:crypto");
|
|
145
|
+
return createHmac("sha256", secret)
|
|
146
|
+
.update(signingBase(timestamp, body))
|
|
147
|
+
.digest("hex");
|
|
148
|
+
}
|
|
149
|
+
|
|
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
|
+
/**
|
|
216
|
+
* Deliver one opt-in. Never throws.
|
|
217
|
+
*
|
|
218
|
+
* A delivery failure must not take down the request that produced it: the
|
|
219
|
+
* visitor already submitted the form, and their submission is stored whatever
|
|
220
|
+
* the receiver does.
|
|
221
|
+
*/
|
|
222
|
+
export async function deliverOptInWebhook(
|
|
223
|
+
input: OptInWebhookInput,
|
|
224
|
+
): Promise<DeliveryOutcome> {
|
|
225
|
+
const now = input.now ?? Date.now;
|
|
226
|
+
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
|
+
|
|
231
|
+
const check = checkWebhookUrl(input.endpoint);
|
|
232
|
+
if (!check.ok) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
errorCategory: "blocked-endpoint",
|
|
236
|
+
error: check.message,
|
|
237
|
+
durationMs: now() - startedAt,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const body = JSON.stringify(buildOptInPayload(input));
|
|
242
|
+
const timestamp = String(Math.floor(now() / 1000));
|
|
243
|
+
const signature = await signBody(input.signingSecret, timestamp, body);
|
|
244
|
+
|
|
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
|
+
);
|
|
273
|
+
|
|
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
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
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"));
|
|
312
|
+
return {
|
|
313
|
+
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,
|
|
319
|
+
};
|
|
320
|
+
} finally {
|
|
321
|
+
clearTimeout(timer);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Retry schedule
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
export const MAX_DELIVERY_ATTEMPTS = 5;
|
|
330
|
+
|
|
331
|
+
/** Which failures are worth trying again. */
|
|
332
|
+
export function isRetryable(outcome: DeliveryOutcome): boolean {
|
|
333
|
+
if (outcome.ok) return false;
|
|
334
|
+
switch (outcome.errorCategory) {
|
|
335
|
+
case "timeout":
|
|
336
|
+
case "network":
|
|
337
|
+
return true;
|
|
338
|
+
case "http-error":
|
|
339
|
+
// 4xx means the receiver understood and refused: sending it again changes
|
|
340
|
+
// nothing. 5xx and 429 are the receiver's problem, and may pass.
|
|
341
|
+
return (
|
|
342
|
+
outcome.status === undefined ||
|
|
343
|
+
outcome.status >= 500 ||
|
|
344
|
+
outcome.status === 429
|
|
345
|
+
);
|
|
346
|
+
// A blocked endpoint, an oversized reply or a redirect loop are
|
|
347
|
+
// configuration faults. Retrying is just repeating the same mistake.
|
|
348
|
+
default:
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Exponential backoff with a ceiling: 1m, 5m, 25m, 2h05.
|
|
355
|
+
*
|
|
356
|
+
* Returns `null` when the attempt budget is spent, which is the caller's signal
|
|
357
|
+
* to mark the delivery terminally failed and stop holding its payload.
|
|
358
|
+
*/
|
|
359
|
+
export function nextAttemptDelayMs(attempt: number): number | null {
|
|
360
|
+
if (attempt >= MAX_DELIVERY_ATTEMPTS) return null;
|
|
361
|
+
const minutes = 60_000;
|
|
362
|
+
return Math.min(minutes * 5 ** (attempt - 1), 4 * 60 * minutes);
|
|
363
|
+
}
|
|
@@ -17,6 +17,19 @@ export const SECRET_REF_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
17
17
|
/** Environment variable used by the self-hosted system-mail path. */
|
|
18
18
|
export const SYSTEM_SMTP_SECRET_NAME = "SMTP_PASSWORD";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Where provider credentials are configured, relative to the deployment root.
|
|
22
|
+
*
|
|
23
|
+
* This is the convention the package documents, not a guarantee about a given
|
|
24
|
+
* server: the deployment root differs per installation (a container image, a
|
|
25
|
+
* systemd unit's WorkingDirectory, a platform's own environment panel), so a
|
|
26
|
+
* host that keeps its environment elsewhere passes its own location.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_PROVIDER_SECRETS_FILE = "deploy/provider-secrets.env";
|
|
29
|
+
|
|
30
|
+
/** Where the system-mail credential is configured, relative to the same root. */
|
|
31
|
+
export const DEFAULT_SYSTEM_ENV_FILE = ".env";
|
|
32
|
+
|
|
20
33
|
export type ProviderSecretRow = {
|
|
21
34
|
id?: string | null;
|
|
22
35
|
name?: string | null;
|
|
@@ -147,8 +160,8 @@ export function secretRefProblems(providers: ProviderSecretRow[]): string[] {
|
|
|
147
160
|
}
|
|
148
161
|
|
|
149
162
|
/**
|
|
150
|
-
* Operator-facing, value-free report of what must go into
|
|
151
|
-
*
|
|
163
|
+
* Operator-facing, value-free report of what must go into the provider
|
|
164
|
+
* secrets file.
|
|
152
165
|
*/
|
|
153
166
|
export function formatProviderSecretReport(
|
|
154
167
|
providers: ProviderSecretRow[],
|
|
@@ -157,14 +170,97 @@ export function formatProviderSecretReport(
|
|
|
157
170
|
const reqs = requiredProviderSecrets(providers, env);
|
|
158
171
|
if (reqs.length === 0) return "No active email providers — no provider secrets are required.";
|
|
159
172
|
const lines = [
|
|
160
|
-
|
|
173
|
+
`Provider secrets to configure in ${DEFAULT_PROVIDER_SECRETS_FILE} (names only):`,
|
|
161
174
|
...reqs.map((r) => {
|
|
162
175
|
const state = !r.valid ? "INVALID/MISSING NAME" : r.present ? "set" : "NOT SET";
|
|
163
176
|
return ` ${(r.name ?? "(none)").padEnd(28)} ${state.padEnd(20)} ${r.provider}${r.kind ? ` (${r.kind})` : ""}`;
|
|
164
177
|
}),
|
|
165
178
|
"",
|
|
166
|
-
`System mail (account/password emails) uses ${SYSTEM_SMTP_SECRET_NAME} and is
|
|
179
|
+
`System mail (account/password emails) uses ${SYSTEM_SMTP_SECRET_NAME} and is ` +
|
|
180
|
+
`configured separately in ${DEFAULT_SYSTEM_ENV_FILE}.`,
|
|
167
181
|
"Never paste secret values into chat, logs, PostgreSQL, Git or the migration package.",
|
|
168
182
|
];
|
|
169
183
|
return lines.join("\n");
|
|
170
184
|
}
|
|
185
|
+
|
|
186
|
+
/* ------------------------------------------------------- configuration help */
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Where an installation keeps its email credentials.
|
|
190
|
+
*
|
|
191
|
+
* Every field is optional because the answer is genuinely per-installation.
|
|
192
|
+
* A host that ships the documented layout passes nothing; one that mounts its
|
|
193
|
+
* environment elsewhere passes the paths it uses; one running on a platform
|
|
194
|
+
* that injects environment variables with no file at all passes
|
|
195
|
+
* `usesEnvFiles: false` and a `note` naming where its operators set them.
|
|
196
|
+
*/
|
|
197
|
+
export type SecretsLocation = {
|
|
198
|
+
/** Provider credentials file, relative to the deployment root. */
|
|
199
|
+
providerSecretsFile?: string | null;
|
|
200
|
+
/** System-mail credential file, relative to the deployment root. */
|
|
201
|
+
systemEnvFile?: string | null;
|
|
202
|
+
/** Absolute deployment root, when the installation has a fixed one. */
|
|
203
|
+
deploymentRoot?: string | null;
|
|
204
|
+
/** False when the platform injects environment variables without files. */
|
|
205
|
+
usesEnvFiles?: boolean;
|
|
206
|
+
/** Extra operator-facing sentence, e.g. the name of a hosting panel. */
|
|
207
|
+
note?: string | null;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Value-free description of how credentials are configured on the server. */
|
|
211
|
+
export type SecretsGuidance = {
|
|
212
|
+
/** True when credentials live in files this guidance can point at. */
|
|
213
|
+
usesEnvFiles: boolean;
|
|
214
|
+
/** Provider credentials file, joined to the deployment root when known. */
|
|
215
|
+
providerSecretsPath: string;
|
|
216
|
+
/** System-mail credential file, joined the same way. */
|
|
217
|
+
systemEnvPath: string;
|
|
218
|
+
/** True when the paths above are absolute rather than a relative convention. */
|
|
219
|
+
rootIsKnown: boolean;
|
|
220
|
+
/** Variable names the configured providers require. Names only. */
|
|
221
|
+
requiredNames: string[];
|
|
222
|
+
/** Active providers naming no secret, or naming an unusable one. */
|
|
223
|
+
problems: string[];
|
|
224
|
+
/** Variable holding the system-mail credential. */
|
|
225
|
+
systemSecretName: string;
|
|
226
|
+
/** The shape a secret name must have. */
|
|
227
|
+
namePattern: string;
|
|
228
|
+
note: string | null;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
function joinPath(root: string | null | undefined, file: string): string {
|
|
232
|
+
const raw = typeof root === "string" ? root.trim() : "";
|
|
233
|
+
if (!raw) return file;
|
|
234
|
+
const base = raw.replace(/\/+$/, "");
|
|
235
|
+
// `/` is a valid deployment root, especially inside a container. Removing
|
|
236
|
+
// its only slash must not turn an absolute path back into a relative one.
|
|
237
|
+
return base ? `${base}/${file}` : `/${file.replace(/^\/+/, "")}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Describe where email credentials must be configured, for operators.
|
|
242
|
+
*
|
|
243
|
+
* Returns names and locations only. Secret *values* are never read here — this
|
|
244
|
+
* runs in the browser as well as on the server, and the environment it would
|
|
245
|
+
* read is not the sending server's environment anyway.
|
|
246
|
+
*/
|
|
247
|
+
export function describeSecretConfiguration(
|
|
248
|
+
providers: ProviderSecretRow[] = [],
|
|
249
|
+
location: SecretsLocation = {},
|
|
250
|
+
): SecretsGuidance {
|
|
251
|
+
const usesEnvFiles = location.usesEnvFiles !== false;
|
|
252
|
+
const providerFile = location.providerSecretsFile?.trim() || DEFAULT_PROVIDER_SECRETS_FILE;
|
|
253
|
+
const systemFile = location.systemEnvFile?.trim() || DEFAULT_SYSTEM_ENV_FILE;
|
|
254
|
+
const root = location.deploymentRoot?.trim() || null;
|
|
255
|
+
return {
|
|
256
|
+
usesEnvFiles,
|
|
257
|
+
providerSecretsPath: joinPath(root, providerFile),
|
|
258
|
+
systemEnvPath: joinPath(root, systemFile),
|
|
259
|
+
rootIsKnown: Boolean(root),
|
|
260
|
+
requiredNames: requiredSecretNames(providers),
|
|
261
|
+
problems: secretRefProblems(providers),
|
|
262
|
+
systemSecretName: SYSTEM_SMTP_SECRET_NAME,
|
|
263
|
+
namePattern: SECRET_REF_NAME_RE.source,
|
|
264
|
+
note: location.note?.trim() || null,
|
|
265
|
+
};
|
|
266
|
+
}
|