@morphika/andami 0.2.9 → 0.2.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.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Sanity bulk operations for backup restore.
3
+ *
4
+ * Provides wipe (delete all) and restore (createOrReplace) for the
5
+ * 5 Andami document types. Uses the writeClient for mutations and
6
+ * adminClient for reads. Operations are batched to stay within
7
+ * Sanity's transaction limits (~200 mutations per transaction).
8
+ */
9
+
10
+ import { writeClient } from "../sanity/writeClient";
11
+ import { adminClient } from "../sanity/client";
12
+ import { logger } from "../logger";
13
+
14
+ // ============================================
15
+ // Constants
16
+ // ============================================
17
+
18
+ /** All Sanity document types managed by Andami */
19
+ export const ANDAMI_DOC_TYPES = [
20
+ "page",
21
+ "siteSettings",
22
+ "siteStyles",
23
+ "assetRegistry",
24
+ "customSection",
25
+ ] as const;
26
+
27
+ export type AndamiDocType = (typeof ANDAMI_DOC_TYPES)[number];
28
+
29
+ /** Max mutations per Sanity transaction (conservative limit) */
30
+ const BATCH_SIZE = 200;
31
+
32
+ // ============================================
33
+ // Wipe operations
34
+ // ============================================
35
+
36
+ /**
37
+ * Delete ALL Andami documents from Sanity.
38
+ * Fetches document IDs per type, then batch-deletes them.
39
+ *
40
+ * Returns a summary of how many documents were deleted per type.
41
+ */
42
+ export async function wipeAllDocuments(): Promise<Record<string, number>> {
43
+ const summary: Record<string, number> = {};
44
+
45
+ for (const docType of ANDAMI_DOC_TYPES) {
46
+ try {
47
+ // Fetch all _id values for this type
48
+ const ids: string[] = await adminClient.fetch(
49
+ `*[_type == $type]._id`,
50
+ { type: docType }
51
+ );
52
+
53
+ if (!ids || ids.length === 0) {
54
+ summary[docType] = 0;
55
+ continue;
56
+ }
57
+
58
+ // Delete in batches
59
+ let deleted = 0;
60
+ for (let i = 0; i < ids.length; i += BATCH_SIZE) {
61
+ const batch = ids.slice(i, i + BATCH_SIZE);
62
+ const tx = writeClient.transaction();
63
+
64
+ for (const id of batch) {
65
+ // Delete both the published and draft versions
66
+ tx.delete(id);
67
+ tx.delete(`drafts.${id}`);
68
+ }
69
+
70
+ await tx.commit({ visibility: "async" });
71
+ deleted += batch.length;
72
+ }
73
+
74
+ summary[docType] = deleted;
75
+ logger.info(
76
+ "[Backup:Restore]",
77
+ `Wiped ${deleted} ${docType} document(s)`
78
+ );
79
+ } catch (err) {
80
+ logger.error(
81
+ "[Backup:Restore]",
82
+ `Failed to wipe ${docType} documents`,
83
+ err
84
+ );
85
+ throw new Error(
86
+ `Wipe failed for ${docType}: ${err instanceof Error ? err.message : "Unknown error"}`
87
+ );
88
+ }
89
+ }
90
+
91
+ return summary;
92
+ }
93
+
94
+ // ============================================
95
+ // Restore operations
96
+ // ============================================
97
+
98
+ /**
99
+ * Restore documents of a given type into Sanity.
100
+ * Uses createOrReplace to preserve original _id values.
101
+ *
102
+ * - Array types (page, customSection): expects an array of docs
103
+ * - Singleton types (siteSettings, siteStyles, assetRegistry): expects a single doc
104
+ *
105
+ * Returns the number of documents restored.
106
+ */
107
+ export async function restoreDocuments(
108
+ docType: AndamiDocType,
109
+ docs: unknown
110
+ ): Promise<number> {
111
+ // Normalize to array
112
+ const docArray = Array.isArray(docs) ? docs : docs ? [docs] : [];
113
+ if (docArray.length === 0) return 0;
114
+
115
+ let restored = 0;
116
+ const failed: string[] = [];
117
+
118
+ for (let i = 0; i < docArray.length; i += BATCH_SIZE) {
119
+ const batch = docArray.slice(i, i + BATCH_SIZE);
120
+ const tx = writeClient.transaction();
121
+
122
+ for (const doc of batch) {
123
+ if (!doc || typeof doc !== "object") continue;
124
+
125
+ const d = doc as Record<string, unknown>;
126
+
127
+ // Safety net: verify the document's _type matches the expected type
128
+ // for this batch. If it doesn't, skip the document rather than
129
+ // silently overwriting _type (which would make Sanity store it under
130
+ // the wrong type and later surface as a broken document). This guards
131
+ // against future bugs where a wrong key/type mapping is passed in.
132
+ if (d._type && d._type !== docType) {
133
+ logger.warn(
134
+ "[Backup:Restore]",
135
+ `Skipping document ${String(d._id)} — type mismatch (${String(d._type)} ≠ ${docType})`
136
+ );
137
+ continue;
138
+ }
139
+ d._type = docType;
140
+
141
+ // Remove _rev — Sanity generates this on write.
142
+ // Keeping it would cause "document already exists with different revision" errors.
143
+ delete d._rev;
144
+
145
+ // L-11: drop `_updatedAt` so Sanity stamps a fresh value on write.
146
+ // `_updatedAt` is an audit field that semantically should reflect the
147
+ // time of the *restore*, not the time of the original save. Leaving
148
+ // it on the payload would back-date the document and confuse any
149
+ // "recently edited" UI in the Studio.
150
+ //
151
+ // `_createdAt` however is intentionally preserved: it's a provenance
152
+ // field that should survive a backup round-trip (otherwise every
153
+ // restore looks like the entire dataset was created today). Sanity
154
+ // accepts `_createdAt` on createOrReplace and honours it, unlike
155
+ // `_updatedAt` which it always regenerates.
156
+ delete d._updatedAt;
157
+
158
+ // NOTE: the export stores raw references as { _ref, _type: "reference" }.
159
+ // No post-processing needed here — Sanity accepts them directly.
160
+
161
+ tx.createOrReplace(d as { _id: string; _type: string });
162
+ }
163
+
164
+ try {
165
+ await tx.commit({ visibility: "async" });
166
+ restored += batch.length;
167
+ } catch (err) {
168
+ // Log which batch failed but continue with remaining batches
169
+ const batchIds = batch
170
+ .map((d: Record<string, unknown>) => d._id || "?")
171
+ .join(", ");
172
+ logger.error(
173
+ "[Backup:Restore]",
174
+ `Batch restore failed for ${docType} [${batchIds}]`,
175
+ err
176
+ );
177
+ failed.push(...batch.map((d: Record<string, unknown>) => String(d._id || "?")));
178
+ }
179
+ }
180
+
181
+ if (failed.length > 0) {
182
+ logger.warn(
183
+ "[Backup:Restore]",
184
+ `${failed.length} ${docType} document(s) failed to restore: ${failed.join(", ")}`
185
+ );
186
+ }
187
+
188
+ logger.info(
189
+ "[Backup:Restore]",
190
+ `Restored ${restored}/${docArray.length} ${docType} document(s)`
191
+ );
192
+
193
+ return restored;
194
+ }
@@ -213,6 +213,7 @@ export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSa
213
213
  draftMode: doc.draft_mode ?? true,
214
214
  rows,
215
215
  _customSectionCache: {},
216
+ _customSectionRefetchTick: 0,
216
217
  // BUG-014 fix: flag whether colors came from the document
217
218
  _hasDocumentPageSettings: hasDocumentPageSettings,
218
219
  pageSettings: {
@@ -93,6 +93,10 @@ export function createCanvasActions(set: StoreSet, get: StoreGet) {
93
93
  selectedProjectCardKey: null,
94
94
  // m1 fix: only mark page dirty if the section was actually saved (Session 110)
95
95
  isDirty: wasSaved ? true : state.isDirty,
96
+ // Bump refetch tick so CustomSectionInstanceCard re-fetches fresh data
97
+ _customSectionRefetchTick: wasSaved
98
+ ? state._customSectionRefetchTick + 1
99
+ : state._customSectionRefetchTick,
96
100
  // Reset history back to page context
97
101
  _history: [],
98
102
  _future: [],
@@ -88,6 +88,7 @@ const initialState: BuilderState = {
88
88
 
89
89
  rows: [],
90
90
  _customSectionCache: {},
91
+ _customSectionRefetchTick: 0,
91
92
 
92
93
  selectedRowKey: null,
93
94
  selectedColumnKey: null,
@@ -239,6 +239,10 @@ export interface BuilderState {
239
239
  * Used by SortableRow to merge base settings with per-instance overrides. */
240
240
  _customSectionCache: Record<string, import("../../lib/sanity/types").SectionV2Settings>;
241
241
 
242
+ /** Counter incremented after a custom section is saved via the section editor.
243
+ * Used as a useEffect dependency in CustomSectionInstanceCard to trigger refetch. */
244
+ _customSectionRefetchTick: number;
245
+
242
246
  /** Live preview overlay from color picker — shown on canvas without persisting to Sanity.
243
247
  * Set while user is dragging in the color picker; cleared on close. */
244
248
  colorPickerPreview: {
package/lib/security.ts CHANGED
@@ -359,6 +359,32 @@ export async function decryptToken(stored: string): Promise<string> {
359
359
  }
360
360
 
361
361
  // ─── Rate Limiting (#32) ──────────────────────────────────────────────────
362
+ //
363
+ // IMPORTANT — this is a best-effort, PER-INSTANCE in-memory limiter. It is
364
+ // intentionally simple and runs entirely inside the Node.js process that
365
+ // handles the request. That design has known blind spots in a serverless
366
+ // environment like Vercel:
367
+ //
368
+ // - Every cold start wipes `rateLimitBuckets`, so a caller that hits a
369
+ // newly-warm instance resets their counter even if they exceeded the
370
+ // limit on a sibling instance moments earlier.
371
+ // - Vercel routes requests across many concurrent invocations. Two
372
+ // requests from the same IP can hit two different instances and both
373
+ // see an empty bucket.
374
+ // - Horizontal scale makes the effective limit ~ maxRequests * N per
375
+ // window, where N is the live instance count.
376
+ //
377
+ // For the backups flow specifically (L-1 in docs/audits/BACKUPS-V2-AUDIT.md),
378
+ // the "1 restore per 5 minutes" guarantee is therefore soft. It still
379
+ // prevents accidental double-submits from the admin UI (same browser, same
380
+ // instance within the keep-alive window) but it is NOT a hard rate limit
381
+ // and MUST NOT be relied on for adversarial scenarios. Auth + CSRF are the
382
+ // real access gates.
383
+ //
384
+ // A stronger implementation would back this with Redis / Upstash / Vercel KV
385
+ // so all invocations share the same counters. That is tracked as a
386
+ // follow-up ("pending infra decision") in the audit doc — not implemented
387
+ // here because it requires an infra choice by the host instance.
362
388
 
363
389
  interface RateLimitBucket {
364
390
  count: number;
@@ -383,6 +409,10 @@ function cleanupRateLimitBuckets(windowMs: number) {
383
409
  * #32: Simple in-memory rate limiter for mutation endpoints.
384
410
  * Returns true if the request should be allowed, false if rate limited.
385
411
  *
412
+ * Best-effort semantics only (see block comment above). Do not treat this
413
+ * as a hard security boundary — use it to protect against honest double-
414
+ * submits and accidental loops, not against a determined attacker.
415
+ *
386
416
  * @param key - Unique key for the rate limit bucket (e.g. "r2-delete:IP")
387
417
  * @param maxRequests - Max requests per window
388
418
  * @param windowMs - Window size in milliseconds
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared security utilities for input validation and sanitization.
3
+ * Used across API routes and components.
4
+ */
5
+
6
+ // ─── Request Body Size Limits ─────────────────────────────────────────────
7
+
8
+ /** Default max body size for JSON API routes: 1MB */
9
+ export const MAX_JSON_BODY_SIZE = 1 * 1024 * 1024;
10
+
11
+ /** Max body size for page builder saves (larger due to block content): 5MB */
12
+ export const MAX_PAGE_BODY_SIZE = 5 * 1024 * 1024;
13
+
14
+ /**
15
+ * Check if a request's Content-Length exceeds the specified limit.
16
+ * Returns true if the body is too large.
17
+ * #11: Also rejects requests without Content-Length header (chunked encoding bypass).
18
+ */
19
+ export function isBodyTooLarge(request: Request, maxBytes: number): boolean {
20
+ const contentLength = request.headers.get("content-length");
21
+ if (!contentLength) {
22
+ return true;
23
+ }
24
+ const size = parseInt(contentLength, 10);
25
+ if (isNaN(size) || size > maxBytes) return true;
26
+ return false;
27
+ }
@@ -91,24 +91,46 @@ export interface StorageAdapter {
91
91
  // ============================================
92
92
 
93
93
  const MIME_TYPES: Record<string, string> = {
94
+ // Raster / vector images
94
95
  jpg: "image/jpeg",
95
96
  jpeg: "image/jpeg",
96
97
  png: "image/png",
97
98
  webp: "image/webp",
99
+ avif: "image/avif",
98
100
  gif: "image/gif",
99
101
  svg: "image/svg+xml",
102
+ // Video
100
103
  mp4: "video/mp4",
101
104
  webm: "video/webm",
102
105
  mov: "video/quicktime",
106
+ // Documents
103
107
  pdf: "application/pdf",
108
+ // Fonts — served from R2 when instances use custom typography via
109
+ // the /api/admin/styles/fonts uploader. Correct MIME matters for
110
+ // browser caching, CORS font fetches, and presigned-URL signatures.
111
+ woff: "font/woff",
112
+ woff2: "font/woff2",
113
+ ttf: "font/ttf",
114
+ otf: "font/otf",
104
115
  };
105
116
 
106
- /** Supported media file extensions (for filtering during scans) */
117
+ /** Supported media file extensions (for filtering during asset-browser scans) */
107
118
  export const MEDIA_EXTENSIONS = new Set([
108
119
  "jpg", "jpeg", "png", "webp", "gif", "svg",
109
120
  "mp4", "webm", "mov",
110
121
  ]);
111
122
 
123
+ /**
124
+ * Extensions allowed in backup restore uploads.
125
+ * Superset of MEDIA_EXTENSIONS — includes fonts and documents so a full
126
+ * site backup can round-trip without losing typography or PDF assets.
127
+ */
128
+ export const BACKUP_ASSET_EXTENSIONS = new Set<string>([
129
+ ...MEDIA_EXTENSIONS,
130
+ "pdf",
131
+ "woff", "woff2", "ttf", "otf",
132
+ ]);
133
+
112
134
  /** Get MIME type from a filename or extension */
113
135
  export function getMimeType(filenameOrExt: string): string {
114
136
  const ext = filenameOrExt.includes(".")
@@ -123,3 +145,13 @@ export function isMediaFile(key: string | undefined): boolean {
123
145
  const ext = key.split(".").pop()?.toLowerCase() || "";
124
146
  return MEDIA_EXTENSIONS.has(ext);
125
147
  }
148
+
149
+ /**
150
+ * Check if a filename/key is allowed as a backup asset
151
+ * (media files + fonts + PDFs).
152
+ */
153
+ export function isBackupAssetFile(key: string | undefined): boolean {
154
+ if (!key) return false;
155
+ const ext = key.split(".").pop()?.toLowerCase() || "";
156
+ return BACKUP_ASSET_EXTENSIONS.has(ext);
157
+ }
package/lib/version.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
- * Framework version — auto-read from package.json at build time.
3
- * Kept as a simple constant so it can be imported without reading
4
- * the full package.json at runtime.
2
+ * Framework version — kept in sync with package.json at publish time by
3
+ * `scripts/prepack.mjs`. Do not edit manually; any change here will be
4
+ * overwritten on the next `npm publish`.
5
+ *
6
+ * Exposed as a plain constant so it can be imported without reading
7
+ * package.json at runtime.
5
8
  */
6
- export const ANDAMI_VERSION = "0.1.5";
9
+ export const ANDAMI_VERSION = "0.2.10";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphika/andami",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Visual Page Builder — core library. A reusable website builder with visual editing, CMS integration, and asset management.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -83,6 +83,9 @@
83
83
  "./lib/editor/*": "./lib/editor/*.ts",
84
84
  "./lib/animation/*": "./lib/animation/*.ts",
85
85
  "./lib/shader/glsl": "./lib/shader/glsl/index.ts",
86
+ "./lib/backup/manifest": "./lib/backup/manifest.ts",
87
+ "./lib/backup/export": "./lib/backup/export.ts",
88
+ "./lib/backup/r2-helpers": "./lib/backup/r2-helpers.ts",
86
89
  "./lib/storage": "./lib/storage/index.ts",
87
90
  "./lib/storage/*": "./lib/storage/*.ts",
88
91
  "./lib/contexts/*": "./lib/contexts/*.tsx",
@@ -135,6 +138,7 @@
135
138
  "./admin/assets": "./admin/assets.ts",
136
139
  "./admin/database": "./admin/database.ts",
137
140
  "./admin/settings": "./admin/settings.ts",
141
+ "./admin/backups": "./admin/backups.ts",
138
142
  "./admin/setup": "./admin/setup.ts",
139
143
  "./components/admin/setup-wizard": "./components/admin/setup-wizard/index.ts",
140
144
  "./components/admin/setup-wizard/*": "./components/admin/setup-wizard/*.tsx",
@@ -173,6 +177,13 @@
173
177
  "./api/projects": "./app/api/projects/route.ts",
174
178
  "./api/assets": "./app/api/assets/[...path]/route.ts",
175
179
  "./api/custom-sections": "./app/api/custom-sections/[id]/route.ts",
180
+ "./api/admin/backups/export": "./app/api/admin/backups/export/route.ts",
181
+ "./api/admin/backups/status": "./app/api/admin/backups/status/route.ts",
182
+ "./api/admin/backups/restore": "./app/api/admin/backups/restore/route.ts",
183
+ "./api/admin/backups/prepare-export": "./app/api/admin/backups/prepare-export/route.ts",
184
+ "./api/admin/backups/restore-data": "./app/api/admin/backups/restore-data/route.ts",
185
+ "./lib/backup/restore": "./lib/backup/restore.ts",
186
+ "./lib/backup/sanity-ops": "./lib/backup/sanity-ops.ts",
176
187
  "./api/draft-mode/enable": "./app/api/draft-mode/enable/route.ts",
177
188
  "./api/draft-mode/disable": "./app/api/draft-mode/disable/route.ts"
178
189
  },
@@ -182,6 +193,7 @@
182
193
  "react-dom": ">=19.0.0"
183
194
  },
184
195
  "dependencies": {
196
+ "archiver": "^7.0.1",
185
197
  "@aws-sdk/client-s3": "^3.1021.0",
186
198
  "@aws-sdk/s3-request-presigner": "^3.1021.0",
187
199
  "@dnd-kit/core": "^6.3.1",
@@ -196,12 +208,16 @@
196
208
  "@tiptap/pm": "^2.12.0",
197
209
  "@tiptap/react": "^2.12.0",
198
210
  "@tiptap/starter-kit": "^2.12.0",
211
+ "jszip": "^3.10.1",
199
212
  "next-sanity": "^12.1.5",
200
213
  "ogl": "^1.0.8",
201
214
  "sanity": "^5.17.1",
215
+ "unzipper": "^0.12.3",
202
216
  "zustand": "^5.0.12"
203
217
  },
204
218
  "devDependencies": {
219
+ "@types/archiver": "^6.0.3",
220
+ "@types/unzipper": "^0.10.10",
205
221
  "@tailwindcss/postcss": "^4",
206
222
  "@testing-library/dom": "^10.4.1",
207
223
  "@testing-library/jest-dom": "^6.6.3",