@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 +11 -0
- package/package.json +8 -2
- package/src/server/domains-core.ts +180 -0
- package/src/server/email-send-core.ts +266 -0
- package/src/server/injections-core.ts +223 -0
- package/src/server/provider-secrets.ts +170 -0
- package/src/server/smtp.ts +416 -0
- package/src/tracking/injections.ts +153 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic logic for the "Code & tracking" admin area (`site_injections`).
|
|
3
|
+
*
|
|
4
|
+
* The host owns the server-function wrappers and the clients; this module owns
|
|
5
|
+
* the row mapping, validation-independent CRUD, the audit trail action names and
|
|
6
|
+
* the request-time resolution of the merged injection bundle, so every site
|
|
7
|
+
* behaves identically.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
EMPTY_BUNDLE,
|
|
12
|
+
mergeBundles,
|
|
13
|
+
normalizeHostname,
|
|
14
|
+
selectProfiles,
|
|
15
|
+
type InjectionBundle,
|
|
16
|
+
type InjectionProfile,
|
|
17
|
+
} from "../tracking/injections";
|
|
18
|
+
import { actorEmail, assertRole, type PlatformAuthContext } from "./guards";
|
|
19
|
+
|
|
20
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
21
|
+
/** Structural subset of a Supabase-shaped client used by this core. */
|
|
22
|
+
export type InjectionsClient = { from: (table: string) => any };
|
|
23
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
24
|
+
|
|
25
|
+
export const INJECTION_SELECT_COLS =
|
|
26
|
+
"id, name, scope_type, scope_value, enabled, head_html, body_start_html, body_end_html, gtm_id, ga4_id, meta_pixel_id, tiktok_pixel_id, extra_meta, sort_order, updated_at";
|
|
27
|
+
|
|
28
|
+
/** Normalize a database row into the profile shape the UI works with. */
|
|
29
|
+
export function rowToInjectionProfile(row: Record<string, unknown>): InjectionProfile {
|
|
30
|
+
return {
|
|
31
|
+
id: String(row.id),
|
|
32
|
+
name: String(row.name ?? ""),
|
|
33
|
+
scope_type: (row.scope_type as InjectionProfile["scope_type"]) ?? "global",
|
|
34
|
+
scope_value: String(row.scope_value ?? ""),
|
|
35
|
+
enabled: Boolean(row.enabled),
|
|
36
|
+
head_html: String(row.head_html ?? ""),
|
|
37
|
+
body_start_html: String(row.body_start_html ?? ""),
|
|
38
|
+
body_end_html: String(row.body_end_html ?? ""),
|
|
39
|
+
gtm_id: String(row.gtm_id ?? ""),
|
|
40
|
+
ga4_id: String(row.ga4_id ?? ""),
|
|
41
|
+
meta_pixel_id: String(row.meta_pixel_id ?? ""),
|
|
42
|
+
tiktok_pixel_id: String(row.tiktok_pixel_id ?? ""),
|
|
43
|
+
extra_meta: Array.isArray(row.extra_meta)
|
|
44
|
+
? (row.extra_meta as InjectionProfile["extra_meta"])
|
|
45
|
+
: [],
|
|
46
|
+
sort_order: Number(row.sort_order ?? 100),
|
|
47
|
+
updated_at: row.updated_at ? String(row.updated_at) : undefined,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Signed-in staff read of every profile, in scope then sort order. */
|
|
52
|
+
export async function listInjectionsCore(client: InjectionsClient): Promise<InjectionProfile[]> {
|
|
53
|
+
const { data, error } = await client
|
|
54
|
+
.from("site_injections")
|
|
55
|
+
.select(INJECTION_SELECT_COLS)
|
|
56
|
+
.order("scope_type")
|
|
57
|
+
.order("sort_order");
|
|
58
|
+
if (error) throw new Error(error.message);
|
|
59
|
+
return ((data ?? []) as Record<string, unknown>[]).map(rowToInjectionProfile);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type InjectionInput = Omit<InjectionProfile, "id" | "updated_at"> & { id?: string };
|
|
63
|
+
|
|
64
|
+
/** Host-provided audit-log writer; keeps privileged clients out of the core. */
|
|
65
|
+
export type AuditWriter = (entry: {
|
|
66
|
+
actorId: string;
|
|
67
|
+
actorEmail: string | null;
|
|
68
|
+
action: string;
|
|
69
|
+
entity: string;
|
|
70
|
+
entityId: string | null;
|
|
71
|
+
meta: Record<string, unknown>;
|
|
72
|
+
}) => Promise<void>;
|
|
73
|
+
|
|
74
|
+
function toRow(input: InjectionInput) {
|
|
75
|
+
return {
|
|
76
|
+
name: input.name,
|
|
77
|
+
scope_type: input.scope_type,
|
|
78
|
+
scope_value: input.scope_type === "global" ? "" : input.scope_value,
|
|
79
|
+
enabled: input.enabled,
|
|
80
|
+
head_html: input.head_html,
|
|
81
|
+
body_start_html: input.body_start_html,
|
|
82
|
+
body_end_html: input.body_end_html,
|
|
83
|
+
gtm_id: input.gtm_id,
|
|
84
|
+
ga4_id: input.ga4_id,
|
|
85
|
+
meta_pixel_id: input.meta_pixel_id,
|
|
86
|
+
tiktok_pixel_id: input.tiktok_pixel_id,
|
|
87
|
+
extra_meta: input.extra_meta,
|
|
88
|
+
sort_order: input.sort_order,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Admin-only create/update of one profile, with an audit entry. */
|
|
93
|
+
export async function saveInjectionCore(
|
|
94
|
+
context: PlatformAuthContext & { supabase: InjectionsClient },
|
|
95
|
+
input: InjectionInput,
|
|
96
|
+
audit: AuditWriter,
|
|
97
|
+
): Promise<{ id: string }> {
|
|
98
|
+
await assertRole(context, "admin");
|
|
99
|
+
const row = toRow(input);
|
|
100
|
+
const scope = `${row.scope_type}:${row.scope_value}`;
|
|
101
|
+
|
|
102
|
+
if (input.id) {
|
|
103
|
+
const { error } = await context.supabase
|
|
104
|
+
.from("site_injections")
|
|
105
|
+
.update(row)
|
|
106
|
+
.eq("id", input.id);
|
|
107
|
+
if (error) throw new Error(error.message);
|
|
108
|
+
await audit({
|
|
109
|
+
actorId: context.userId,
|
|
110
|
+
actorEmail: actorEmail(context),
|
|
111
|
+
action: "injection.update",
|
|
112
|
+
entity: "site_injections",
|
|
113
|
+
entityId: input.id,
|
|
114
|
+
meta: { name: input.name, scope, enabled: input.enabled },
|
|
115
|
+
});
|
|
116
|
+
return { id: input.id };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { data: inserted, error } = await context.supabase
|
|
120
|
+
.from("site_injections")
|
|
121
|
+
.insert(row)
|
|
122
|
+
.select("id")
|
|
123
|
+
.single();
|
|
124
|
+
if (error) throw new Error(error.message);
|
|
125
|
+
const id = String(inserted?.id ?? "");
|
|
126
|
+
await audit({
|
|
127
|
+
actorId: context.userId,
|
|
128
|
+
actorEmail: actorEmail(context),
|
|
129
|
+
action: "injection.create",
|
|
130
|
+
entity: "site_injections",
|
|
131
|
+
entityId: id || null,
|
|
132
|
+
meta: { name: input.name, scope },
|
|
133
|
+
});
|
|
134
|
+
return { id };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Admin-only delete of one profile, with an audit entry. */
|
|
138
|
+
export async function deleteInjectionCore(
|
|
139
|
+
context: PlatformAuthContext & { supabase: InjectionsClient },
|
|
140
|
+
id: string,
|
|
141
|
+
audit: AuditWriter,
|
|
142
|
+
): Promise<{ ok: true }> {
|
|
143
|
+
await assertRole(context, "admin");
|
|
144
|
+
const { error } = await context.supabase.from("site_injections").delete().eq("id", id);
|
|
145
|
+
if (error) throw new Error(error.message);
|
|
146
|
+
await audit({
|
|
147
|
+
actorId: context.userId,
|
|
148
|
+
actorEmail: actorEmail(context),
|
|
149
|
+
action: "injection.delete",
|
|
150
|
+
entity: "site_injections",
|
|
151
|
+
entityId: id,
|
|
152
|
+
meta: {},
|
|
153
|
+
});
|
|
154
|
+
return { ok: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ---------------- Request-time resolution ---------------- */
|
|
158
|
+
|
|
159
|
+
const CACHE_TTL_MS = 30_000;
|
|
160
|
+
const cache = new Map<string, { at: number; bundle: InjectionBundle }>();
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the merged bundle for a request host. Never throws: a backend hiccup
|
|
164
|
+
* degrades to "no injection" so the site still renders.
|
|
165
|
+
*
|
|
166
|
+
* `client` must be a privileged (service-role) client — `site_injections` and
|
|
167
|
+
* `locale_domains` are not readable anonymously.
|
|
168
|
+
*/
|
|
169
|
+
export async function resolveInjectionsCore(
|
|
170
|
+
client: InjectionsClient,
|
|
171
|
+
host: string,
|
|
172
|
+
options: { defaultLocale?: string } = {},
|
|
173
|
+
): Promise<InjectionBundle> {
|
|
174
|
+
const key = normalizeHostname(host);
|
|
175
|
+
const hit = cache.get(key);
|
|
176
|
+
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.bundle;
|
|
177
|
+
|
|
178
|
+
let bundle = EMPTY_BUNDLE;
|
|
179
|
+
try {
|
|
180
|
+
const [{ data: rows }, { data: domains }] = await Promise.all([
|
|
181
|
+
client.from("site_injections").select("*").eq("enabled", true),
|
|
182
|
+
client.from("locale_domains").select("locale, domain, is_default"),
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
if (rows && rows.length > 0) {
|
|
186
|
+
const list = (domains ?? []) as { locale: string; domain: string; is_default: boolean }[];
|
|
187
|
+
const match = list.find((d) => normalizeHostname(d.domain) === key);
|
|
188
|
+
const fallback = list.find((d) => d.is_default);
|
|
189
|
+
const locale = match?.locale ?? fallback?.locale ?? options.defaultLocale ?? "en";
|
|
190
|
+
const profiles = (rows as Record<string, unknown>[]).map(rowToInjectionProfile);
|
|
191
|
+
bundle = mergeBundles(selectProfiles(profiles, locale, host));
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
bundle = EMPTY_BUNDLE;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
cache.set(key, { at: Date.now(), bundle });
|
|
198
|
+
return bundle;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** True for paths that must never receive tracking code. */
|
|
202
|
+
export function isInjectablePath(pathname: string, backofficePath = "/kontoret"): boolean {
|
|
203
|
+
if (pathname.startsWith(backofficePath)) return false;
|
|
204
|
+
if (pathname.startsWith("/preview")) return false;
|
|
205
|
+
if (pathname.startsWith("/api")) return false;
|
|
206
|
+
if (pathname.startsWith("/_serverFn")) return false;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Insert the bundle's three chunks into a rendered HTML document. */
|
|
211
|
+
export function applyInjections(html: string, bundle: InjectionBundle): string {
|
|
212
|
+
let out = html;
|
|
213
|
+
if (bundle.head && out.includes("</head>")) {
|
|
214
|
+
out = out.replace("</head>", `${bundle.head}</head>`);
|
|
215
|
+
}
|
|
216
|
+
if (bundle.bodyStart) {
|
|
217
|
+
out = out.replace(/<body([^>]*)>/i, (m) => `${m}${bundle.bodyStart}`);
|
|
218
|
+
}
|
|
219
|
+
if (bundle.bodyEnd && out.includes("</body>")) {
|
|
220
|
+
out = out.replace("</body>", `${bundle.bodyEnd}</body>`);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-provider secret handling.
|
|
3
|
+
*
|
|
4
|
+
* Every row in `public.email_providers` names the environment variable that
|
|
5
|
+
* holds its credential (`secret_ref_name`). The value itself lives only in the
|
|
6
|
+
* server's environment — never in PostgreSQL, never in a migration package,
|
|
7
|
+
* never in a log line.
|
|
8
|
+
*
|
|
9
|
+
* This is deliberately separate from the system-mail path: `SMTP_PASSWORD`
|
|
10
|
+
* powers self-hosted account/password emails, while provider credentials are
|
|
11
|
+
* resolved per provider through the preserved `secret_ref_name`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Only plain, safe environment-variable names are accepted. */
|
|
15
|
+
export const SECRET_REF_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
|
|
17
|
+
/** Environment variable used by the self-hosted system-mail path. */
|
|
18
|
+
export const SYSTEM_SMTP_SECRET_NAME = "SMTP_PASSWORD";
|
|
19
|
+
|
|
20
|
+
export type ProviderSecretRow = {
|
|
21
|
+
id?: string | null;
|
|
22
|
+
name?: string | null;
|
|
23
|
+
kind?: string | null;
|
|
24
|
+
is_active?: boolean | null;
|
|
25
|
+
secret_ref_name?: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type Env = Record<string, string | undefined>;
|
|
29
|
+
|
|
30
|
+
export function isValidSecretRefName(value: unknown): value is string {
|
|
31
|
+
return typeof value === "string" && SECRET_REF_NAME_RE.test(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function label(provider: ProviderSecretRow): string {
|
|
35
|
+
return provider.name?.trim() || provider.kind?.trim() || provider.id || "provider";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SecretResolution =
|
|
39
|
+
{ ok: true; name: string; value: string } | { ok: false; name: string | null; error: string };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve one provider's credential. Errors mention only the variable name,
|
|
43
|
+
* never a value.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveProviderSecret(
|
|
46
|
+
provider: ProviderSecretRow,
|
|
47
|
+
env: Env = process.env,
|
|
48
|
+
): SecretResolution {
|
|
49
|
+
const raw = provider.secret_ref_name;
|
|
50
|
+
const who = label(provider);
|
|
51
|
+
if (raw == null || String(raw).trim() === "") {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
name: null,
|
|
55
|
+
error: `Provider "${who}" has no secret reference name configured.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const name = String(raw).trim();
|
|
59
|
+
if (!isValidSecretRefName(name)) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
name: null,
|
|
63
|
+
error:
|
|
64
|
+
`Provider "${who}" has an invalid secret reference name — ` +
|
|
65
|
+
`it must match ${SECRET_REF_NAME_RE.source}.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const value = env[name];
|
|
69
|
+
if (value == null || value === "") {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
name,
|
|
73
|
+
error: `Environment variable "${name}" is not set on the server (provider "${who}").`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, name, value };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ProviderSecretRequirement = {
|
|
80
|
+
provider: string;
|
|
81
|
+
kind: string | null;
|
|
82
|
+
name: string | null;
|
|
83
|
+
valid: boolean;
|
|
84
|
+
present: boolean;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Every active provider's required variable name — values are never read out. */
|
|
88
|
+
export function requiredProviderSecrets(
|
|
89
|
+
providers: ProviderSecretRow[],
|
|
90
|
+
env: Env = process.env,
|
|
91
|
+
): ProviderSecretRequirement[] {
|
|
92
|
+
return providers
|
|
93
|
+
.filter((p) => p.is_active !== false)
|
|
94
|
+
.map((p) => {
|
|
95
|
+
const res = resolveProviderSecret(p, env);
|
|
96
|
+
const name = res.ok ? res.name : res.name;
|
|
97
|
+
return {
|
|
98
|
+
provider: label(p),
|
|
99
|
+
kind: p.kind ?? null,
|
|
100
|
+
name,
|
|
101
|
+
valid: name !== null,
|
|
102
|
+
present: res.ok,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Distinct, valid variable names that must be configured. */
|
|
108
|
+
export function requiredSecretNames(providers: ProviderSecretRow[]): string[] {
|
|
109
|
+
const out = new Set<string>();
|
|
110
|
+
for (const p of providers) {
|
|
111
|
+
if (p.is_active === false) continue;
|
|
112
|
+
const raw = typeof p.secret_ref_name === "string" ? p.secret_ref_name.trim() : "";
|
|
113
|
+
if (isValidSecretRefName(raw)) out.add(raw);
|
|
114
|
+
}
|
|
115
|
+
return [...out].sort();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Providers whose secret reference name is present but unsafe. A hard error:
|
|
120
|
+
* such a name can never be resolved to an environment variable.
|
|
121
|
+
*/
|
|
122
|
+
export function invalidSecretProviders(providers: ProviderSecretRow[]): string[] {
|
|
123
|
+
return providers
|
|
124
|
+
.filter((p) => p.is_active !== false)
|
|
125
|
+
.filter((p) => {
|
|
126
|
+
const raw = typeof p.secret_ref_name === "string" ? p.secret_ref_name.trim() : "";
|
|
127
|
+
return raw !== "" && !isValidSecretRefName(raw);
|
|
128
|
+
})
|
|
129
|
+
.map((p) => `${label(p)}: invalid secret reference name`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Active providers that name no secret at all — a hard error. */
|
|
133
|
+
export function providersWithoutSecretRef(providers: ProviderSecretRow[]): string[] {
|
|
134
|
+
return providers
|
|
135
|
+
.filter((p) => p.is_active !== false)
|
|
136
|
+
.filter((p) => (typeof p.secret_ref_name === "string" ? p.secret_ref_name.trim() : "") === "")
|
|
137
|
+
.map((p) => `${label(p)}: no secret reference name configured`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every hard problem with provider secret references: unsafe names and
|
|
142
|
+
* missing/blank names on active providers. Inactive providers are ignored.
|
|
143
|
+
* Values are never inspected or printed.
|
|
144
|
+
*/
|
|
145
|
+
export function secretRefProblems(providers: ProviderSecretRow[]): string[] {
|
|
146
|
+
return [...invalidSecretProviders(providers), ...providersWithoutSecretRef(providers)];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Operator-facing, value-free report of what must go into
|
|
151
|
+
* `deploy/provider-secrets.env`.
|
|
152
|
+
*/
|
|
153
|
+
export function formatProviderSecretReport(
|
|
154
|
+
providers: ProviderSecretRow[],
|
|
155
|
+
env: Env = process.env,
|
|
156
|
+
): string {
|
|
157
|
+
const reqs = requiredProviderSecrets(providers, env);
|
|
158
|
+
if (reqs.length === 0) return "No active email providers — no provider secrets are required.";
|
|
159
|
+
const lines = [
|
|
160
|
+
"Provider secrets to configure in deploy/provider-secrets.env (names only):",
|
|
161
|
+
...reqs.map((r) => {
|
|
162
|
+
const state = !r.valid ? "INVALID/MISSING NAME" : r.present ? "set" : "NOT SET";
|
|
163
|
+
return ` ${(r.name ?? "(none)").padEnd(28)} ${state.padEnd(20)} ${r.provider}${r.kind ? ` (${r.kind})` : ""}`;
|
|
164
|
+
}),
|
|
165
|
+
"",
|
|
166
|
+
`System mail (account/password emails) uses ${SYSTEM_SMTP_SECRET_NAME} and is configured separately in .env.`,
|
|
167
|
+
"Never paste secret values into chat, logs, PostgreSQL, Git or the migration package.",
|
|
168
|
+
];
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|