@bygghammar/cms-platform 0.3.1 → 0.5.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.
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Pure helpers for building a self-contained migration package.
3
+ *
4
+ * A migration package is a superset of a backup archive: same portable table
5
+ * data, plus a versioned manifest with per-file checksums, expected row counts
6
+ * and provenance. Secrets never enter it.
7
+ */
8
+ import { createHash } from "node:crypto";
9
+
10
+ import { ARCHIVE_FORMAT_VERSION, SCHEMA_VERSION, type ArchiveManifest } from "./datasets";
11
+ import {
12
+ verifyMediaFile,
13
+ isSafeArchivePath,
14
+ sanitizeSecrets,
15
+ isPlainObject,
16
+ toJsonSafe,
17
+ } from "./safety";
18
+
19
+ /** Bump when the package layout changes in a way older tools cannot read. */
20
+ export const MIGRATION_PACKAGE_VERSION = 2;
21
+
22
+ export type MediaChecksum = { path: string; bytes: number; sha256: string };
23
+
24
+ export type MigrationManifest = {
25
+ packageVersion: number;
26
+ archiveFormat: number;
27
+ sourceSchemaVersion: number;
28
+ exportedAt: string;
29
+ source: string | null;
30
+ groups: string[];
31
+ /** Expected row count per table, asserted by `verify`. */
32
+ counts: Record<string, number>;
33
+ users: number;
34
+ media: { files: number; bytes: number };
35
+ checksums: MediaChecksum[];
36
+ /** Names only — values are never exported. */
37
+ secretRefs: string[];
38
+ /** Fields whose credential identifiers were redacted and must be reconfigured. */
39
+ redacted?: string[];
40
+ rejected: string[];
41
+ };
42
+
43
+ export function sha256(bytes: Uint8Array): string {
44
+ return createHash("sha256").update(Buffer.from(bytes)).digest("hex");
45
+ }
46
+
47
+ /**
48
+ * Keys whose values must never leave the source system. Matching values are
49
+ * replaced by a `secret:NAME` reference so the target knows what to configure.
50
+ */
51
+ export const SECRET_KEY_RE =
52
+ /(password|passwd|secret|token|api[_-]?key|apikey|private[_-]?key|credential|service[_-]?role|smtp_pass|authorization|bearer|cookie|hash)/i;
53
+
54
+ /**
55
+ * Keys that hold only the *name* of a secret (an environment-variable name),
56
+ * never its value. These must survive the export unchanged, otherwise the
57
+ * self-hosted mail system cannot find its credentials.
58
+ */
59
+ export const SECRET_NAME_KEY_RE =
60
+ /(secret_name|secret_ref_name|_secret_ref|ref_name|key_id|_name)$/i;
61
+
62
+ /** Translation metadata (`svHash`, `enHash`, …) is content, not a credential. */
63
+ export const TRANSLATION_HASH_KEY_RE = /^[a-z]{2}(-[A-Za-z]{2,4})?hash$/i;
64
+
65
+ export function isSecretNameKey(key: string): boolean {
66
+ return SECRET_NAME_KEY_RE.test(key) || TRANSLATION_HASH_KEY_RE.test(key);
67
+ }
68
+
69
+ export type SecretScanResult<T> = { value: T; refs: string[] };
70
+
71
+ /** Recursively replace secret-like values with reference names. */
72
+ export function stripSecrets<T>(value: T, path: string[] = []): SecretScanResult<T> {
73
+ const refs: string[] = [];
74
+
75
+ const walk = (v: unknown, keys: string[]): unknown => {
76
+ if (v instanceof Date) return v.toISOString();
77
+ if (Array.isArray(v)) return v.map((item, i) => walk(item, [...keys, String(i)]));
78
+ if (v && typeof v === "object") {
79
+ if (!isPlainObject(v)) return toJsonSafe(v);
80
+ const out: Record<string, unknown> = {};
81
+ for (const [k, item] of Object.entries(v)) {
82
+ if (
83
+ SECRET_KEY_RE.test(k) &&
84
+ !isSecretNameKey(k) &&
85
+ item != null &&
86
+ !isPlainObject(item) &&
87
+ !Array.isArray(item)
88
+ ) {
89
+ const ref = [...keys, k].join(".");
90
+ refs.push(ref);
91
+ out[k] = `secret:${k.toUpperCase()}`;
92
+ continue;
93
+ }
94
+ out[k] = walk(item, [...keys, k]);
95
+ }
96
+ return out;
97
+ }
98
+ return toJsonSafe(v);
99
+ };
100
+
101
+ return { value: walk(sanitizeSecrets(value), path) as T, refs };
102
+ }
103
+
104
+ /* ------------------------------------------------- credential surrogates */
105
+
106
+ /** Prefix of every non-live client-token surrogate written by the exporter. */
107
+ export const CLIENT_TOKEN_SURROGATE_PREFIX = "migrated:";
108
+
109
+ /** Deterministic, unique, non-live replacement for a live client token. */
110
+ export function surrogateClientToken(rowId: unknown, fallback: number): string {
111
+ const id = typeof rowId === "string" && rowId.trim() ? rowId.trim() : `row-${fallback}`;
112
+ return `${CLIENT_TOKEN_SURROGATE_PREFIX}${id}`;
113
+ }
114
+
115
+ /**
116
+ * Replace live partial-submission client tokens with surrogates. Every row is
117
+ * preserved; the values stay NOT NULL and unique per (form_key, client_token).
118
+ */
119
+ export function sanitizePartialTokens(rows: Record<string, unknown>[]): Record<string, unknown>[] {
120
+ return rows.map((row, i) => ({ ...row, client_token: surrogateClientToken(row["id"], i) }));
121
+ }
122
+
123
+ /** Placeholder written where a credential identifier used to be. */
124
+ export const REDACTED = "redacted:configure-on-server";
125
+
126
+ /** AWS access-key-ID shaped identifiers (SES SMTP usernames included). */
127
+ export const ACCESS_KEY_ID_RE =
128
+ /^(?:AKIA|ASIA|AIDA|AROA|AGPA|AIPA|ANPA|ANVA|ABIA|ACCA)[A-Z0-9]{12,}$/;
129
+
130
+ export function looksLikeCredentialIdentifier(value: unknown): boolean {
131
+ if (typeof value !== "string") return false;
132
+ const v = value.trim();
133
+ if (!v) return false;
134
+ if (ACCESS_KEY_ID_RE.test(v)) return true;
135
+ // Opaque machine credential: long, no spaces, no @ (so real usernames and
136
+ // e-mail addresses are kept).
137
+ return v.length >= 20 && !/[\s@]/.test(v) && /[A-Z0-9]/.test(v) && !/^[a-z._-]+$/.test(v);
138
+ }
139
+
140
+ /**
141
+ * Redact credential identifiers (e.g. SES SMTP usernames) from email provider
142
+ * configuration. Values are never logged — only the field path is reported.
143
+ */
144
+ export function redactProviderCredentials(rows: Record<string, unknown>[]): {
145
+ rows: Record<string, unknown>[];
146
+ redacted: string[];
147
+ } {
148
+ const redacted: string[] = [];
149
+ const out = rows.map((row) => {
150
+ const config = row["config"];
151
+ if (!isPlainObject(config)) return row;
152
+ const next: Record<string, unknown> = { ...config };
153
+ for (const key of ["username", "user", "smtp_username", "access_key_id", "accessKeyId"]) {
154
+ if (key in next && looksLikeCredentialIdentifier(next[key])) {
155
+ next[key] = REDACTED;
156
+ redacted.push(`email_providers.config.${key}`);
157
+ }
158
+ }
159
+ return { ...row, config: next };
160
+ });
161
+ return { rows: out, redacted: [...new Set(redacted)] };
162
+ }
163
+
164
+ /* ------------------------------------------------------ credential audit */
165
+
166
+ const PRIVATE_KEY_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
167
+ const BCRYPT_RE = /^\$2[aby]?\$\d{2}\$[./A-Za-z0-9]{53}$/;
168
+ const ARGON_RE = /^\$argon2[a-z]{0,2}\$/;
169
+ const JWT_RE = /^ey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}$/;
170
+ const CREDENTIALED_URL_RE = /\b[a-z+]{2,12}:\/\/[^\s/:@]+:[^\s/@]+@/i;
171
+ const API_KEY_RE =
172
+ /\b(sk_live_[A-Za-z0-9]{10,}|rk_live_[A-Za-z0-9]{10,}|gh[pousr]_[A-Za-z0-9]{20,}|sb_secret_[A-Za-z0-9_-]{10,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/;
173
+
174
+ /**
175
+ * Scan the data about to be written (or just read back) for values that must
176
+ * never appear in a migration package. Returns human-readable problems; the
177
+ * offending values themselves are never included.
178
+ */
179
+ export function auditExportedData(data: Map<string, Record<string, unknown>[]>): string[] {
180
+ const problems: string[] = [];
181
+
182
+ const partials = data.get("form_partials") ?? [];
183
+ partials.forEach((row, i) => {
184
+ const token = row["client_token"];
185
+ if (typeof token !== "string" || !token.startsWith(CLIENT_TOKEN_SURROGATE_PREFIX)) {
186
+ problems.push(
187
+ `form_partials[${i}].client_token is a live token — it must be replaced by a surrogate.`,
188
+ );
189
+ }
190
+ });
191
+
192
+ for (const [table, rows] of data) {
193
+ rows.forEach((row, i) => {
194
+ const walk = (value: unknown, path: string): void => {
195
+ if (Array.isArray(value)) {
196
+ value.forEach((item, j) => walk(item, `${path}[${j}]`));
197
+ return;
198
+ }
199
+ if (value && typeof value === "object") {
200
+ if (!isPlainObject(value)) return;
201
+ for (const [k, item] of Object.entries(value)) {
202
+ const child = `${path}.${k}`;
203
+ if (isSecretNameKey(k)) continue;
204
+ if (
205
+ typeof item === "string" &&
206
+ item &&
207
+ !item.startsWith("secret:") &&
208
+ !item.startsWith(REDACTED) &&
209
+ !item.startsWith(CLIENT_TOKEN_SURROGATE_PREFIX) &&
210
+ SECRET_KEY_RE.test(k)
211
+ ) {
212
+ problems.push(`${child} contains a credential value.`);
213
+ continue;
214
+ }
215
+ if (
216
+ table === "email_providers" &&
217
+ /(^|\.)config\./.test(child) &&
218
+ /^(username|user|smtp_username|access_key_id|accessKeyId)$/.test(k) &&
219
+ looksLikeCredentialIdentifier(item)
220
+ ) {
221
+ problems.push(`${child} looks like a credential identifier.`);
222
+ continue;
223
+ }
224
+ walk(item, child);
225
+ }
226
+ return;
227
+ }
228
+ if (typeof value !== "string" || !value) return;
229
+ if (PRIVATE_KEY_RE.test(value)) problems.push(`${path} contains a private key.`);
230
+ else if (BCRYPT_RE.test(value) || ARGON_RE.test(value))
231
+ problems.push(`${path} contains a password hash.`);
232
+ else if (JWT_RE.test(value)) problems.push(`${path} contains a JSON Web Token.`);
233
+ else if (API_KEY_RE.test(value)) problems.push(`${path} contains an API key.`);
234
+ else if (CREDENTIALED_URL_RE.test(value))
235
+ problems.push(`${path} contains a credentialed URL.`);
236
+ };
237
+ walk(row, `${table}[${i}]`);
238
+ });
239
+ }
240
+
241
+ return [...new Set(problems)];
242
+ }
243
+
244
+ /** Tables that must never be exported, whatever a caller asks for. */
245
+ export const EXCLUDED_TABLE_RE =
246
+ /^(auth|storage|realtime|vault|supabase_functions|pgsodium|net)[._]/i;
247
+
248
+ export function isExportableTable(table: string): boolean {
249
+ return !EXCLUDED_TABLE_RE.test(table);
250
+ }
251
+
252
+ export type MediaCandidate = { path: string; bytes: Uint8Array };
253
+
254
+ export type MediaScan = {
255
+ accepted: { path: string; bytes: Uint8Array; sha256: string }[];
256
+ rejected: string[];
257
+ totalBytes: number;
258
+ };
259
+
260
+ /**
261
+ * Validate media before it enters the package: safe path, allowed extension,
262
+ * real file signature (no HTML disguised as an image), no duplicate paths.
263
+ */
264
+ export function scanMedia(files: MediaCandidate[]): MediaScan {
265
+ const accepted: MediaScan["accepted"] = [];
266
+ const rejected: string[] = [];
267
+ const seen = new Set<string>();
268
+ let totalBytes = 0;
269
+
270
+ for (const file of files) {
271
+ if (!isSafeArchivePath(file.path)) {
272
+ rejected.push(`${file.path} (unsafe path)`);
273
+ continue;
274
+ }
275
+ if (seen.has(file.path)) {
276
+ rejected.push(`${file.path} (duplicate path)`);
277
+ continue;
278
+ }
279
+ const verdict = verifyMediaFile(file.path, file.bytes);
280
+ if (!verdict.ok) {
281
+ rejected.push(`${file.path} (${verdict.reason})`);
282
+ continue;
283
+ }
284
+ seen.add(file.path);
285
+ accepted.push({ path: file.path, bytes: file.bytes, sha256: sha256(file.bytes) });
286
+ totalBytes += file.bytes.byteLength;
287
+ }
288
+
289
+ return { accepted, rejected, totalBytes };
290
+ }
291
+
292
+ export type BuildManifestInput = {
293
+ source: string | null;
294
+ groups: string[];
295
+ counts: Record<string, number>;
296
+ users: number;
297
+ media: MediaScan;
298
+ secretRefs: string[];
299
+ redacted?: string[];
300
+ archiveManifest?: Pick<ArchiveManifest, "schemaVersion"> | null;
301
+ now?: () => Date;
302
+ };
303
+
304
+ export function buildMigrationManifest(input: BuildManifestInput): MigrationManifest {
305
+ const now = (input.now ?? (() => new Date()))().toISOString();
306
+ return {
307
+ packageVersion: MIGRATION_PACKAGE_VERSION,
308
+ archiveFormat: ARCHIVE_FORMAT_VERSION,
309
+ sourceSchemaVersion: input.archiveManifest?.schemaVersion ?? SCHEMA_VERSION,
310
+ exportedAt: now,
311
+ source: input.source,
312
+ groups: [...input.groups],
313
+ counts: { ...input.counts },
314
+ users: input.users,
315
+ media: { files: input.media.accepted.length, bytes: input.media.totalBytes },
316
+ checksums: input.media.accepted.map((f) => ({
317
+ path: f.path,
318
+ bytes: f.bytes.byteLength,
319
+ sha256: f.sha256,
320
+ })),
321
+ secretRefs: [...new Set(input.secretRefs)].sort(),
322
+ redacted: [...new Set(input.redacted ?? [])].sort(),
323
+ rejected: [...input.media.rejected],
324
+ };
325
+ }
326
+
327
+ /** Verify the checksums in a manifest against the files actually present. */
328
+ export function checkChecksums(
329
+ manifest: MigrationManifest,
330
+ files: Map<string, Uint8Array>,
331
+ ): string[] {
332
+ const problems: string[] = [];
333
+ for (const entry of manifest.checksums) {
334
+ const bytes = files.get(entry.path);
335
+ if (!bytes) {
336
+ problems.push(`missing media file: ${entry.path}`);
337
+ continue;
338
+ }
339
+ if (bytes.byteLength !== entry.bytes) {
340
+ problems.push(`size mismatch: ${entry.path}`);
341
+ continue;
342
+ }
343
+ if (sha256(bytes) !== entry.sha256) problems.push(`checksum mismatch: ${entry.path}`);
344
+ }
345
+ for (const path of files.keys()) {
346
+ if (!manifest.checksums.some((c) => c.path === path))
347
+ problems.push(`unlisted media file: ${path}`);
348
+ }
349
+ return problems;
350
+ }
@@ -0,0 +1,24 @@
1
+ import {
2
+ buildBrowserMediaArchive as buildSharedBrowserMediaArchive,
3
+ type MediaExportProgress,
4
+ } from "./browser-media";
5
+
6
+ import { ARCHIVE_FORMAT_VERSION, SCHEMA_VERSION, type ArchiveManifest } from "./datasets";
7
+ import type { MediaStorage } from "../media/storage";
8
+
9
+ export type { MediaExportProgress };
10
+
11
+ type MediaExportOptions = {
12
+ baseArchive: Uint8Array;
13
+ groups: string[];
14
+ storage: Pick<MediaStorage, "list" | "download">;
15
+ onProgress?: (progress: MediaExportProgress) => void;
16
+ };
17
+
18
+ export function buildBrowserMediaArchive(options: MediaExportOptions) {
19
+ return buildSharedBrowserMediaArchive<ArchiveManifest>({
20
+ ...options,
21
+ expectedFormat: ARCHIVE_FORMAT_VERSION,
22
+ expectedSchemaVersion: SCHEMA_VERSION,
23
+ });
24
+ }