@bygghammar/cms-platform 0.34.1 → 0.36.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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.0
4
+
5
+ - Added `tracking/injections` — the pure "Code & tracking" helpers (profile
6
+ merging, scope selection, GTM/GA4/Meta/TikTok snippet builders).
7
+ - Added `server/injections-core` — staff/admin CRUD for `site_injections` with a
8
+ host-supplied audit writer, plus cached request-time bundle resolution,
9
+ `isInjectablePath` and `applyInjections`.
10
+ - Added `server/domains-core` — `locale_domains` reads, request-locale and
11
+ admin-domain resolution (with self-host `SITE_URL` precedence) and staff-only
12
+ upsert/delete.
13
+
3
14
  ## 0.34.1
4
15
 
5
16
  - `saveContentSectionCore` accepts an optional `data` payload, matching hosts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.34.1",
3
+ "version": "0.36.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -145,7 +145,13 @@
145
145
  "./server/pages-core": "./src/server/pages-core.ts",
146
146
  "./server/resolve-globals": "./src/server/resolve-globals.ts",
147
147
  "./server/content-sections-core": "./src/server/content-sections-core.ts",
148
- "./server/translate-db-core": "./src/server/translate-db-core.ts"
148
+ "./server/translate-db-core": "./src/server/translate-db-core.ts",
149
+ "./tracking/injections": "./src/tracking/injections.ts",
150
+ "./server/injections-core": "./src/server/injections-core.ts",
151
+ "./server/domains-core": "./src/server/domains-core.ts",
152
+ "./server/provider-secrets": "./src/server/provider-secrets.ts",
153
+ "./server/smtp": "./src/server/smtp.ts",
154
+ "./server/email-send-core": "./src/server/email-send-core.ts"
149
155
  },
150
156
  "dependencies": {
151
157
  "fflate": "^0.8.3"
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Host-agnostic logic for locale → domain mapping (`locale_domains`).
3
+ *
4
+ * Reads use a privileged client because the table reveals which hostname serves
5
+ * the backoffice; writes run as the signed-in staff user. The host owns the
6
+ * server functions, its locale union and the request headers.
7
+ */
8
+
9
+ import { assertStaff, type PlatformAuthContext } from "./guards";
10
+
11
+ /* eslint-disable @typescript-eslint/no-explicit-any */
12
+ /** Structural subset of a Supabase-shaped client used by this core. */
13
+ export type DomainsClient = { from: (table: string) => any };
14
+ /* eslint-enable @typescript-eslint/no-explicit-any */
15
+
16
+ export type LocaleDomainRecord = {
17
+ locale: string;
18
+ domain: string;
19
+ isDefault: boolean;
20
+ isAdminDomain: boolean;
21
+ };
22
+
23
+ /** Strip protocol, trailing slashes and casing from a stored domain. */
24
+ export function normalizeDomain(domain: string): string {
25
+ return domain
26
+ .replace(/^https?:\/\//, "")
27
+ .replace(/\/+$/, "")
28
+ .toLowerCase();
29
+ }
30
+
31
+ /** Strip a leading `www.` and casing from a request host. */
32
+ export function normalizeHost(host: string): string {
33
+ return host.replace(/^www\./, "").toLowerCase();
34
+ }
35
+
36
+ function rowToDomain(row: {
37
+ locale: string;
38
+ domain: string;
39
+ is_default: boolean;
40
+ is_admin_domain: boolean;
41
+ }): LocaleDomainRecord {
42
+ return {
43
+ locale: row.locale,
44
+ domain: row.domain,
45
+ isDefault: row.is_default,
46
+ isAdminDomain: row.is_admin_domain,
47
+ };
48
+ }
49
+
50
+ /** Every configured mapping, ordered by locale. Requires a privileged client. */
51
+ export async function listLocaleDomainsCore(client: DomainsClient): Promise<LocaleDomainRecord[]> {
52
+ const { data, error } = await client
53
+ .from("locale_domains")
54
+ .select("locale, domain, is_default, is_admin_domain")
55
+ .order("locale");
56
+ if (error) throw new Error(error.message);
57
+ return (data ?? []).map(rowToDomain);
58
+ }
59
+
60
+ /** The mapping whose domain matches `host`, ignoring protocol and `www.`. */
61
+ export function matchDomain(
62
+ domains: LocaleDomainRecord[],
63
+ host: string,
64
+ ): LocaleDomainRecord | undefined {
65
+ const normalizedHost = normalizeHost(host);
66
+ return domains.find((d) => {
67
+ const normalized = normalizeDomain(d.domain);
68
+ return normalizedHost === normalized || normalizedHost === `www.${normalized}`;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Resolve the active locale for a request host, falling back to the default
74
+ * mapping, then to the first mapping, then to a synthetic host-based record.
75
+ */
76
+ export function resolveRequestLocale(
77
+ domains: LocaleDomainRecord[],
78
+ host: string,
79
+ defaultLocale: string,
80
+ ): { locale: string; domain: LocaleDomainRecord } {
81
+ const match = matchDomain(domains, host);
82
+ if (match) return { locale: match.locale, domain: match };
83
+
84
+ const fallback = domains.find((d) => d.isDefault) ??
85
+ domains[0] ?? {
86
+ locale: defaultLocale,
87
+ domain: host || "localhost",
88
+ isDefault: true,
89
+ isAdminDomain: true,
90
+ };
91
+ return { locale: fallback.locale, domain: fallback };
92
+ }
93
+
94
+ /**
95
+ * The domain that serves the backoffice.
96
+ *
97
+ * `forcedHost` (a self-host `SITE_URL`) is authoritative: an imported row from
98
+ * another deployment must never send the backoffice to that other hostname.
99
+ * Otherwise the flagged database row wins, and the request host is last resort.
100
+ */
101
+ export function resolveAdminDomain(
102
+ domains: LocaleDomainRecord[],
103
+ options: { forcedHost?: string | null; requestHost?: string | null; defaultLocale: string },
104
+ ): LocaleDomainRecord {
105
+ const dbAdmin = domains.find((d) => d.isAdminDomain) ?? domains[0];
106
+
107
+ if (options.forcedHost) {
108
+ return {
109
+ locale: dbAdmin?.locale ?? options.defaultLocale,
110
+ domain: options.forcedHost,
111
+ isDefault: dbAdmin?.isDefault ?? true,
112
+ isAdminDomain: true,
113
+ };
114
+ }
115
+ if (dbAdmin) return dbAdmin;
116
+ return {
117
+ locale: options.defaultLocale,
118
+ domain: options.requestHost || "localhost",
119
+ isDefault: true,
120
+ isAdminDomain: true,
121
+ };
122
+ }
123
+
124
+ /** Absolute origin for a locale, or "" when the locale has no mapping. */
125
+ export function resolveLocaleOrigin(locale: string, domains: LocaleDomainRecord[]): string {
126
+ const match = domains.find((d) => d.locale === locale);
127
+ if (!match) return "";
128
+ return `https://${normalizeDomain(match.domain)}`;
129
+ }
130
+
131
+ /**
132
+ * Staff-only upsert of one mapping. Keeps at most one default and one admin
133
+ * domain by clearing the flags on every other locale first.
134
+ */
135
+ export async function saveLocaleDomainCore(
136
+ context: PlatformAuthContext & { supabase: DomainsClient },
137
+ input: { locale: string; domain: string; isDefault: boolean; isAdminDomain: boolean },
138
+ options: { invalidDomainMessage?: string } = {},
139
+ ): Promise<{ ok: true }> {
140
+ await assertStaff(context);
141
+
142
+ const normalized = normalizeDomain(input.domain);
143
+ if (!normalized) throw new Error(options.invalidDomainMessage ?? "Invalid domain");
144
+
145
+ if (input.isDefault) {
146
+ await context.supabase
147
+ .from("locale_domains")
148
+ .update({ is_default: false })
149
+ .neq("locale", input.locale);
150
+ }
151
+ if (input.isAdminDomain) {
152
+ await context.supabase
153
+ .from("locale_domains")
154
+ .update({ is_admin_domain: false })
155
+ .neq("locale", input.locale);
156
+ }
157
+
158
+ const { error } = await context.supabase.from("locale_domains").upsert(
159
+ {
160
+ locale: input.locale,
161
+ domain: normalized,
162
+ is_default: input.isDefault,
163
+ is_admin_domain: input.isAdminDomain,
164
+ },
165
+ { onConflict: "locale" },
166
+ );
167
+ if (error) throw new Error(error.message);
168
+ return { ok: true };
169
+ }
170
+
171
+ /** Staff-only delete of one mapping. */
172
+ export async function deleteLocaleDomainCore(
173
+ context: PlatformAuthContext & { supabase: DomainsClient },
174
+ locale: string,
175
+ ): Promise<{ ok: true }> {
176
+ await assertStaff(context);
177
+ const { error } = await context.supabase.from("locale_domains").delete().eq("locale", locale);
178
+ if (error) throw new Error(error.message);
179
+ return { ok: true };
180
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Host-agnostic email dispatch for rows in `public.email_providers`.
3
+ *
4
+ * Each provider is dispatched by its `kind` (mailgun, ses, http, smtp). The
5
+ * credential is always resolved through the provider's preserved
6
+ * `secret_ref_name` environment variable — values are never read from the
7
+ * database and never appear in an error message.
8
+ *
9
+ * Server-only. Never import from client code.
10
+ */
11
+
12
+ import { resolveProviderSecret, type Env } from "./provider-secrets";
13
+ import { sendSmtp } from "./smtp";
14
+
15
+ export type SendableProvider = {
16
+ id: string;
17
+ name: string;
18
+ kind: string;
19
+ from_name: string | null;
20
+ from_email: string | null;
21
+ reply_to: string | null;
22
+ config: Record<string, unknown>;
23
+ secret_ref_name: string | null;
24
+ };
25
+
26
+ export type SendResult = { ok: true } | { ok: false; error: string };
27
+
28
+ export type SendPayload = {
29
+ to: string;
30
+ subject: string;
31
+ html: string;
32
+ text: string;
33
+ fromName: string | null;
34
+ fromEmail: string;
35
+ replyTo: string | null;
36
+ };
37
+
38
+ export type SendOptions = {
39
+ /** Environment used for credential lookup. Defaults to `process.env`. */
40
+ env?: Env;
41
+ };
42
+
43
+ function envOf(options?: SendOptions): Env {
44
+ return options?.env ?? (process.env as Env);
45
+ }
46
+
47
+ function secretOf(provider: SendableProvider, env: Env): string | undefined {
48
+ const res = resolveProviderSecret(provider, env);
49
+ return res.ok ? res.value : undefined;
50
+ }
51
+
52
+ /** Value-free explanation of why a provider credential is unavailable. */
53
+ function secretProblem(provider: SendableProvider, env: Env): string {
54
+ const res = resolveProviderSecret(provider, env);
55
+ return res.ok ? "" : res.error;
56
+ }
57
+
58
+ function str(config: Record<string, unknown>, key: string): string | undefined {
59
+ const v = config?.[key];
60
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
61
+ }
62
+
63
+ function formatFrom(payload: SendPayload) {
64
+ return payload.fromName ? `${payload.fromName} <${payload.fromEmail}>` : payload.fromEmail;
65
+ }
66
+
67
+ /* ---------------- Mailgun ---------------- */
68
+ async function sendMailgun(
69
+ p: SendableProvider,
70
+ payload: SendPayload,
71
+ env: Env,
72
+ ): Promise<SendResult> {
73
+ const domain = str(p.config, "domain");
74
+ const key = secretOf(p, env);
75
+ if (!domain) return { ok: false, error: "Mailgun domain is missing in the provider config." };
76
+ if (!key) return { ok: false, error: secretProblem(p, env) };
77
+ const region = (str(p.config, "region") ?? "us").toLowerCase();
78
+ const base =
79
+ str(p.config, "base_url") ??
80
+ (region.startsWith("eu") ? "https://api.eu.mailgun.net/v3" : "https://api.mailgun.net/v3");
81
+ const body = new URLSearchParams({
82
+ from: formatFrom(payload),
83
+ to: payload.to,
84
+ subject: payload.subject,
85
+ text: payload.text,
86
+ html: payload.html,
87
+ });
88
+ if (payload.replyTo) body.set("h:Reply-To", payload.replyTo);
89
+
90
+ const res = await fetch(`${base.replace(/\/$/, "")}/${domain}/messages`, {
91
+ method: "POST",
92
+ headers: {
93
+ Authorization: `Basic ${btoa(`api:${key}`)}`,
94
+ "Content-Type": "application/x-www-form-urlencoded",
95
+ },
96
+ body,
97
+ });
98
+ if (!res.ok) return { ok: false, error: `Mailgun [${res.status}]: ${await res.text()}` };
99
+ return { ok: true };
100
+ }
101
+
102
+ /* ---------------- Amazon SES (v2 HTTPS API, SigV4) ---------------- */
103
+ const enc = new TextEncoder();
104
+
105
+ async function hmac(key: ArrayBuffer | Uint8Array, data: string): Promise<ArrayBuffer> {
106
+ const cryptoKey = await crypto.subtle.importKey(
107
+ "raw",
108
+ key as BufferSource,
109
+ { name: "HMAC", hash: "SHA-256" },
110
+ false,
111
+ ["sign"],
112
+ );
113
+ return crypto.subtle.sign("HMAC", cryptoKey, enc.encode(data));
114
+ }
115
+
116
+ function hex(buf: ArrayBuffer): string {
117
+ return Array.from(new Uint8Array(buf))
118
+ .map((b) => b.toString(16).padStart(2, "0"))
119
+ .join("");
120
+ }
121
+
122
+ async function sha256Hex(data: string): Promise<string> {
123
+ return hex(await crypto.subtle.digest("SHA-256", enc.encode(data)));
124
+ }
125
+
126
+ async function sendSes(p: SendableProvider, payload: SendPayload, env: Env): Promise<SendResult> {
127
+ const region = str(p.config, "region");
128
+ const accessKeyId = str(p.config, "access_key_id");
129
+ const secretAccessKey = secretOf(p, env);
130
+ if (!region) return { ok: false, error: "SES region is missing in the provider config." };
131
+ if (!accessKeyId)
132
+ return { ok: false, error: "SES access key id is missing in the provider config." };
133
+ if (!secretAccessKey) return { ok: false, error: secretProblem(p, env) };
134
+
135
+ const host = `email.${region}.amazonaws.com`;
136
+ const path = "/v2/email/outbound-emails";
137
+ const body = JSON.stringify({
138
+ FromEmailAddress: formatFrom(payload),
139
+ Destination: { ToAddresses: [payload.to] },
140
+ ...(payload.replyTo ? { ReplyToAddresses: [payload.replyTo] } : {}),
141
+ Content: {
142
+ Simple: {
143
+ Subject: { Data: payload.subject, Charset: "UTF-8" },
144
+ Body: {
145
+ Text: { Data: payload.text, Charset: "UTF-8" },
146
+ Html: { Data: payload.html, Charset: "UTF-8" },
147
+ },
148
+ },
149
+ },
150
+ });
151
+
152
+ const now = new Date();
153
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
154
+ const dateStamp = amzDate.slice(0, 8);
155
+ const payloadHash = await sha256Hex(body);
156
+ const canonicalHeaders = `content-type:application/json\nhost:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`;
157
+ const signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
158
+ const canonicalRequest = `POST\n${path}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`;
159
+ const scope = `${dateStamp}/${region}/ses/aws4_request`;
160
+ const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${await sha256Hex(canonicalRequest)}`;
161
+
162
+ let signingKey: ArrayBuffer | Uint8Array = enc.encode(`AWS4${secretAccessKey}`);
163
+ for (const part of [dateStamp, region, "ses", "aws4_request"]) {
164
+ signingKey = await hmac(signingKey, part);
165
+ }
166
+ const signature = hex(await hmac(signingKey, stringToSign));
167
+
168
+ const res = await fetch(`https://${host}${path}`, {
169
+ method: "POST",
170
+ headers: {
171
+ "Content-Type": "application/json",
172
+ "X-Amz-Date": amzDate,
173
+ "x-amz-content-sha256": payloadHash,
174
+ Authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
175
+ },
176
+ body,
177
+ });
178
+ if (!res.ok) return { ok: false, error: `SES [${res.status}]: ${await res.text()}` };
179
+ return { ok: true };
180
+ }
181
+
182
+ /* ---------------- Generic HTTP / webhook ---------------- */
183
+ async function sendHttp(p: SendableProvider, payload: SendPayload, env: Env): Promise<SendResult> {
184
+ const url = str(p.config, "endpoint") ?? str(p.config, "url");
185
+ if (!url) return { ok: false, error: "Endpoint URL is missing in the provider config." };
186
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
187
+ const secret = secretOf(p, env);
188
+ if (secret) {
189
+ const headerName = str(p.config, "auth_header") ?? "Authorization";
190
+ const scheme = str(p.config, "auth_scheme");
191
+ headers[headerName] = scheme ? `${scheme} ${secret}` : secret;
192
+ }
193
+ const res = await fetch(url, {
194
+ method: (str(p.config, "method") ?? "POST").toUpperCase(),
195
+ headers,
196
+ body: JSON.stringify({
197
+ from: formatFrom(payload),
198
+ from_email: payload.fromEmail,
199
+ from_name: payload.fromName,
200
+ reply_to: payload.replyTo,
201
+ to: payload.to,
202
+ subject: payload.subject,
203
+ html: payload.html,
204
+ text: payload.text,
205
+ }),
206
+ });
207
+ if (!res.ok) return { ok: false, error: `Endpoint [${res.status}]: ${await res.text()}` };
208
+ return { ok: true };
209
+ }
210
+
211
+ /* ---------------- SMTP (raw socket, STARTTLS) ---------------- */
212
+ async function sendSmtpProvider(
213
+ p: SendableProvider,
214
+ payload: SendPayload,
215
+ env: Env,
216
+ ): Promise<SendResult> {
217
+ const host = str(p.config, "host");
218
+ if (!host) return { ok: false, error: "SMTP host is missing in the provider config." };
219
+ const secret = resolveProviderSecret(p, env);
220
+ if (!secret.ok) return { ok: false, error: secret.error };
221
+ const rawPort = p.config?.port;
222
+ const port = Number(typeof rawPort === "string" ? rawPort.trim() : rawPort) || 587;
223
+ return sendSmtp(
224
+ { host, port, username: str(p.config, "username"), password: secret.value },
225
+ {
226
+ to: payload.to,
227
+ fromEmail: payload.fromEmail,
228
+ fromName: payload.fromName,
229
+ replyTo: payload.replyTo,
230
+ subject: payload.subject,
231
+ text: payload.text,
232
+ html: payload.html,
233
+ },
234
+ );
235
+ }
236
+
237
+ /** Dispatch one message through the provider's own transport. Never throws. */
238
+ export async function sendViaProvider(
239
+ provider: SendableProvider,
240
+ payload: SendPayload,
241
+ options?: SendOptions,
242
+ ): Promise<SendResult> {
243
+ const env = envOf(options);
244
+ try {
245
+ switch (provider.kind) {
246
+ case "mailgun":
247
+ return await sendMailgun(provider, payload, env);
248
+ case "ses":
249
+ return await sendSes(provider, payload, env);
250
+ case "http":
251
+ return await sendHttp(provider, payload, env);
252
+ case "smtp":
253
+ return await sendSmtpProvider(provider, payload, env);
254
+ case "lovable":
255
+ return {
256
+ ok: false,
257
+ error:
258
+ "Lovable Emails sending is not wired up yet — set up the email domain first, or test another provider.",
259
+ };
260
+ default:
261
+ return { ok: false, error: `Unknown provider kind "${provider.kind}".` };
262
+ }
263
+ } catch (e) {
264
+ return { ok: false, error: e instanceof Error ? e.message : "Unknown send error" };
265
+ }
266
+ }