@bygghammar/cms-platform 0.3.1 → 0.4.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/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.3.1",
3
+ "version": "0.4.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,
7
7
  "license": "UNLICENSED",
8
8
  "publishConfig": {
9
- "access": "public",
10
- "registry": "https://registry.npmjs.org/"
9
+ "access": "public"
11
10
  },
12
11
  "files": [
13
12
  "src",
@@ -18,9 +17,6 @@
18
17
  "url": "git+https://github.com/byggis79/devsba.git",
19
18
  "directory": "packages/cms-platform"
20
19
  },
21
- "publishConfig": {
22
- "access": "public"
23
- },
24
20
  "exports": {
25
21
  ".": "./src/index.ts",
26
22
  "./i18n": "./src/i18n/index.ts",
@@ -44,7 +40,34 @@
44
40
  "./blocks/registry": "./src/blocks/registry.tsx",
45
41
  "./trips": "./src/trips/index.ts",
46
42
  "./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
47
- "./navigation": "./src/navigation/index.ts"
43
+ "./navigation": "./src/navigation/index.ts",
44
+ "./forms/conditions": "./src/forms/conditions.ts",
45
+ "./forms/field-sets": "./src/forms/field-sets.ts",
46
+ "./forms/form-audit": "./src/forms/form-audit.ts",
47
+ "./forms/rich-text": "./src/forms/rich-text.tsx",
48
+ "./media/urls": "./src/media/urls.ts",
49
+ "./media/filename": "./src/media/filename.ts",
50
+ "./media/focus": "./src/media/focus.ts",
51
+ "./media/internal-paths": "./src/media/internal-paths.ts",
52
+ "./media/optimize": "./src/media/optimize.ts",
53
+ "./media/srcset": "./src/media/srcset.ts",
54
+ "./media/media-meta": "./src/media/media-meta.ts",
55
+ "./media/review-image": "./src/media/review-image.ts",
56
+ "./media/storage": "./src/media/storage.ts",
57
+ "./media/usage-types": "./src/media/usage-types.ts",
58
+ "./cms/version-diff": "./src/cms/version-diff.ts",
59
+ "./cms/shared-draft-store": "./src/cms/shared-draft-store.ts",
60
+ "./http/cache-policy": "./src/http/cache-policy.ts",
61
+ "./http/compression": "./src/http/compression.ts",
62
+ "./http/response-headers": "./src/http/response-headers.ts",
63
+ "./backup/datasets": "./src/backup/datasets.ts",
64
+ "./backup/export-core": "./src/backup/export-core.ts",
65
+ "./backup/restore-core": "./src/backup/restore-core.ts",
66
+ "./backup/media-export": "./src/backup/media-export.ts",
67
+ "./i18n/json": "./src/i18n/json.ts",
68
+ "./i18n/resolve-locale": "./src/i18n/resolve-locale.ts",
69
+ "./i18n/translation-meta": "./src/i18n/translation-meta.ts",
70
+ "./platform": "./src/platform/index.ts"
48
71
  },
49
72
  "dependencies": {
50
73
  "fflate": "^0.8.3"
@@ -54,6 +77,20 @@
54
77
  "lucide-react": ">=0.5.0",
55
78
  "react": ">=19",
56
79
  "react-dom": ">=19",
57
- "sonner": ">=2"
80
+ "sonner": ">=2",
81
+ "@supabase/supabase-js": ">=2",
82
+ "@tanstack/react-start": ">=1",
83
+ "@tanstack/react-router": ">=1"
84
+ },
85
+ "peerDependenciesMeta": {
86
+ "@supabase/supabase-js": {
87
+ "optional": true
88
+ },
89
+ "@tanstack/react-start": {
90
+ "optional": true
91
+ },
92
+ "@tanstack/react-router": {
93
+ "optional": true
94
+ }
58
95
  }
59
- }
96
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Client-safe description of what a backup archive can contain.
3
+ *
4
+ * Used by both the backoffice UI and the server-side export/import engine so
5
+ * the two can never drift apart.
6
+ */
7
+
8
+ /**
9
+ * Archive layout version. Format 2 adds accounts, email configuration,
10
+ * verified media and secret sanitising. Format 1 archives are rejected.
11
+ */
12
+ export const ARCHIVE_FORMAT_VERSION = 2;
13
+ /** Bump when the database schema changes in a way that breaks old archives. */
14
+ export const SCHEMA_VERSION = 2;
15
+
16
+ export {
17
+ ALLOWED_MEDIA_EXTENSIONS,
18
+ isAllowedMediaFile,
19
+ hasAllowedMediaExtension,
20
+ verifyMediaFile,
21
+ isSafeArchivePath,
22
+ sanitizeSecrets,
23
+ rewriteMediaUrls,
24
+ neutralizeInjection,
25
+ } from "./safety";
26
+
27
+ export type TableSpec = {
28
+ table: string;
29
+ /** Primary key column(s) used for upserts. */
30
+ pk: string;
31
+ /**
32
+ * Conflict target for upserts. Defaults to `pk`, but tables that carry a
33
+ * business key (pages.key, forms.key, …) must conflict on that instead, or
34
+ * archives with different UUIDs collide on the unique index.
35
+ */
36
+ conflict?: string;
37
+ /**
38
+ * Columns that reference rows created later in the import order.
39
+ * They are stripped on the first pass and written in a second pass.
40
+ */
41
+ deferred?: string[];
42
+ /** Tables that must have imported cleanly before this one is attempted. */
43
+ requires?: string[];
44
+ /** Credential-like values in these JSON columns are stripped on export. */
45
+ sanitize?: string[];
46
+ /**
47
+ * Nullable foreign keys checked against the archive itself. A non-null value
48
+ * whose parent row is absent fails analysis before anything is restored.
49
+ */
50
+ references?: { column: string; table: string; pk?: string }[];
51
+ };
52
+
53
+ /** Business keys used to detect that an archive comes from another database. */
54
+ export const LINEAGE_KEYS: Record<string, string> = {
55
+ pages: "key",
56
+ global_sections: "key",
57
+ forms: "key",
58
+ content_sections: "key",
59
+ page_seo: "page_key",
60
+ redirects: "from_path",
61
+ locale_domains: "locale",
62
+ };
63
+
64
+ export type DatasetGroup = {
65
+ id: string;
66
+ label: string;
67
+ description: string;
68
+ tables: TableSpec[];
69
+ /** Rows are only ever inserted — never updated or deleted. */
70
+ insertOnly?: boolean;
71
+ /** Special payloads that are not plain tables. */
72
+ special?: "mediaFiles" | "authUsers";
73
+ /** Not selected when the user picks "recommended". */
74
+ heavy?: boolean;
75
+ };
76
+
77
+ export const DATASET_GROUPS: DatasetGroup[] = [
78
+ {
79
+ id: "content",
80
+ label: "Content & pages",
81
+ description:
82
+ "Pages, page versions, publications, sections, global sections and block templates.",
83
+ tables: [
84
+ { table: "pages", pk: "id", conflict: "key", deferred: ["current_version_id"] },
85
+ { table: "page_versions", pk: "id", conflict: "page_id,version_no", requires: ["pages"] },
86
+ { table: "page_publications", pk: "id", requires: ["pages", "page_versions"] },
87
+ { table: "content_sections", pk: "key", conflict: "key" },
88
+ { table: "global_sections", pk: "id", conflict: "key", deferred: ["current_version_id"] },
89
+ {
90
+ table: "global_section_versions",
91
+ pk: "id",
92
+ conflict: "section_id,version_no",
93
+ requires: ["global_sections"],
94
+ },
95
+ { table: "block_templates", pk: "id" },
96
+ ],
97
+ },
98
+ {
99
+ id: "seo",
100
+ label: "SEO & routing",
101
+ description: "Page SEO, redirects, language domains and tracking/code injections.",
102
+ tables: [
103
+ { table: "page_seo", pk: "page_key", conflict: "page_key" },
104
+ { table: "redirects", pk: "id", conflict: "from_path" },
105
+ { table: "locale_domains", pk: "locale", conflict: "locale" },
106
+ { table: "site_injections", pk: "id" },
107
+ ],
108
+ },
109
+ {
110
+ id: "forms",
111
+ label: "Forms",
112
+ description: "Form definitions, their version history and global form settings.",
113
+ tables: [
114
+ { table: "forms", pk: "id", conflict: "key" },
115
+ { table: "form_versions", pk: "id", conflict: "form_key,version_no", requires: ["forms"] },
116
+ { table: "form_settings", pk: "id" },
117
+ ],
118
+ },
119
+
120
+ {
121
+ id: "customer",
122
+ label: "Customer data",
123
+ description:
124
+ "Form submissions, partial submissions and delivery attempts. Always insert-only — never overwritten or deleted.",
125
+ insertOnly: true,
126
+ tables: [
127
+ { table: "form_submissions", pk: "id", requires: ["forms"] },
128
+ { table: "form_partials", pk: "id", conflict: "form_key,client_token", requires: ["forms"] },
129
+ {
130
+ table: "form_delivery_attempts",
131
+ pk: "id",
132
+ requires: ["forms", "form_submissions"],
133
+ },
134
+ ],
135
+ },
136
+
137
+ {
138
+ id: "mediaMeta",
139
+ label: "Media metadata",
140
+ description: "Alt texts, titles, descriptions, focus points and the media bin index.",
141
+ tables: [
142
+ { table: "media_meta", pk: "path" },
143
+ { table: "media_focus", pk: "path" },
144
+ { table: "media_trash", pk: "id" },
145
+ ],
146
+ },
147
+ {
148
+ id: "mediaFiles",
149
+ label: "Media files",
150
+ description: "The actual images and videos. This is by far the largest part of an archive.",
151
+ special: "mediaFiles",
152
+ heavy: true,
153
+ tables: [],
154
+ },
155
+ {
156
+ id: "users",
157
+ label: "Users & roles",
158
+ description:
159
+ "Accounts and their role assignments. Passwords can never be exported — restored users must reset theirs.",
160
+ special: "authUsers",
161
+ tables: [
162
+ { table: "app_users", pk: "id" },
163
+ { table: "user_roles", pk: "id", conflict: "user_id,role", requires: ["app_users"] },
164
+ ],
165
+ },
166
+ {
167
+ id: "email",
168
+ label: "Email configuration",
169
+ description:
170
+ "Sending providers and global email settings. Only the names of secrets are stored — never their values.",
171
+ tables: [
172
+ { table: "email_providers", pk: "id", sanitize: ["config", "notes"] },
173
+ {
174
+ table: "email_settings",
175
+ pk: "id",
176
+ requires: ["email_providers"],
177
+ sanitize: ["*"],
178
+ references: [
179
+ { column: "default_provider_id", table: "email_providers" },
180
+ { column: "fallback_provider_id", table: "email_providers" },
181
+ ],
182
+ },
183
+ ],
184
+ },
185
+ {
186
+ id: "logs",
187
+ label: "Logs",
188
+ description: "Audit log and outgoing email history.",
189
+ insertOnly: true,
190
+ heavy: true,
191
+ tables: [
192
+ { table: "audit_logs", pk: "id" },
193
+ { table: "email_logs", pk: "id" },
194
+ ],
195
+ },
196
+ ];
197
+
198
+ export const ALL_GROUP_IDS = DATASET_GROUPS.map((g) => g.id);
199
+ export const DEFAULT_GROUP_IDS = DATASET_GROUPS.filter((g) => !g.heavy).map((g) => g.id);
200
+
201
+ export function groupById(id: string): DatasetGroup | undefined {
202
+ return DATASET_GROUPS.find((g) => g.id === id);
203
+ }
204
+
205
+ /** Import order across all groups, respecting foreign keys. */
206
+ export const IMPORT_ORDER: string[] = [
207
+ "app_users",
208
+ "user_roles",
209
+ "pages",
210
+ "page_versions",
211
+ "page_publications",
212
+ "global_sections",
213
+ "global_section_versions",
214
+ "content_sections",
215
+ "block_templates",
216
+ "page_seo",
217
+ "redirects",
218
+ "locale_domains",
219
+ "site_injections",
220
+ "email_providers",
221
+ "email_settings",
222
+ "forms",
223
+ "form_versions",
224
+ "form_settings",
225
+ "form_submissions",
226
+ "form_partials",
227
+ "form_delivery_attempts",
228
+ "media_meta",
229
+ "media_focus",
230
+ "media_trash",
231
+ "audit_logs",
232
+ "email_logs",
233
+ ];
234
+
235
+ export type ArchiveManifest = {
236
+ format: number;
237
+ schemaVersion: number;
238
+ createdAt: string;
239
+ createdBy: string | null;
240
+ site: string | null;
241
+ groups: string[];
242
+ counts: Record<string, number>;
243
+ mediaFiles: number;
244
+ mediaBytes: number;
245
+ /** Files found in the library but excluded because they are not media. */
246
+ quarantinedMedia?: string[];
247
+ };
248
+
249
+ export type ImportMode = "merge" | "replace";
@@ -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
+ }