@orion-studios/cms 0.5.6 → 0.5.8

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.
@@ -1,6 +1,3 @@
1
- import {
2
- CONTENT_CACHE_TAG
3
- } from "../chunk-AYV6KYDP.js";
4
1
  import {
5
2
  createMemoryRateLimitStore,
6
3
  getAutoReplyEmailFields,
@@ -8,9 +5,12 @@ import {
8
5
  processSubmission,
9
6
  resolveAutoReplyEmailField
10
7
  } from "../chunk-CFZP7674.js";
8
+ import {
9
+ CONTENT_CACHE_TAG
10
+ } from "../chunk-NSAZCP4I.js";
11
11
 
12
12
  // src/server/routes.ts
13
- import { createHash as createHash2, createHmac as createHmac3, timingSafeEqual as timingSafeEqual2 } from "crypto";
13
+ import { createHash as createHash3, createHmac as createHmac3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
14
14
 
15
15
  // src/analytics/aggregate.ts
16
16
  var CONVERSION_NAMES = /* @__PURE__ */ new Set(["call", "email"]);
@@ -40,9 +40,10 @@ function normalizeSource(utmSource, referrer) {
40
40
  if (host.includes("twitter.") || host === "t.co" || host.includes("x.com")) return "X (Twitter)";
41
41
  return host;
42
42
  }
43
- var isConversion = (event) => event.server_verified === true && (event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit"));
43
+ var isFormSubmitEvent = (event) => event.type === "form" && event.name.endsWith(":submit");
44
+ var isConversion = (event) => event.server_verified === true && (event.type === "click" && CONVERSION_NAMES.has(event.name) || isFormSubmitEvent(event));
44
45
  var top = (map, limit, by) => [...map.entries()].sort((a, b) => by(b[1]) - by(a[1])).slice(0, limit);
45
- function computeKpis(events) {
46
+ function computeKpis(events, submissions) {
46
47
  const sessions = /* @__PURE__ */ new Set();
47
48
  const visitors = /* @__PURE__ */ new Set();
48
49
  const visitorSessions = /* @__PURE__ */ new Map();
@@ -65,12 +66,15 @@ function computeKpis(events) {
65
66
  if (event.type === "click" && event.name === "call") calls += 1;
66
67
  if (event.type === "click" && event.name === "email") emails += 1;
67
68
  if (event.type === "click" && event.name === "portal") portalClicks += 1;
68
- if (event.server_verified === true && event.type === "form" && event.name.endsWith(":submit")) {
69
- formSubmits += 1;
70
- }
69
+ if (event.server_verified === true && isFormSubmitEvent(event)) formSubmits += 1;
71
70
  if (isConversion(event) && event.session_key) converting.add(event.session_key);
72
71
  }
73
- const conversions = events.filter(isConversion).length;
72
+ for (const submission of submissions || []) {
73
+ if (submission.session_key && sessions.has(submission.session_key)) {
74
+ converting.add(submission.session_key);
75
+ }
76
+ }
77
+ const conversions = events.filter(isConversion).length + (submissions?.length ?? 0);
74
78
  const returningVisitors = [...visitorSessions.values()].filter((keys) => keys.size > 1).length;
75
79
  return {
76
80
  visitors: visitors.size,
@@ -80,19 +84,21 @@ function computeKpis(events) {
80
84
  pageviews,
81
85
  pagesPerVisitor: visitors.size > 0 ? pageviews / visitors.size : 0,
82
86
  calls,
83
- formSubmits,
87
+ formSubmits: submissions?.length ?? formSubmits,
84
88
  emails,
85
89
  portalClicks,
86
90
  conversions,
87
91
  conversionRate: sessions.size > 0 ? converting.size / sessions.size : 0
88
92
  };
89
93
  }
90
- function aggregateAnalytics(events, previousEvents, range) {
91
- const kpis = computeKpis(events);
92
- const previous = computeKpis(previousEvents);
94
+ function aggregateAnalytics(events, previousEvents, range, submissions, previousSubmissions) {
95
+ const trafficEvents = submissions === void 0 ? events : events.filter((event) => !isFormSubmitEvent(event));
96
+ const previousTrafficEvents = previousSubmissions === void 0 ? previousEvents : previousEvents.filter((event) => !isFormSubmitEvent(event));
97
+ const kpis = computeKpis(trafficEvents, submissions);
98
+ const previous = computeKpis(previousTrafficEvents, previousSubmissions);
93
99
  const byDay = /* @__PURE__ */ new Map();
94
100
  const dayOf = (iso) => iso.slice(0, 10);
95
- for (const event of events) {
101
+ for (const event of trafficEvents) {
96
102
  const day = dayOf(event.created_at);
97
103
  const entry = byDay.get(day) || { visitors: /* @__PURE__ */ new Set(), conversions: 0 };
98
104
  const identity = event.visitor_key || event.session_key;
@@ -100,6 +106,12 @@ function aggregateAnalytics(events, previousEvents, range) {
100
106
  if (isConversion(event)) entry.conversions += 1;
101
107
  byDay.set(day, entry);
102
108
  }
109
+ for (const submission of submissions || []) {
110
+ const day = dayOf(submission.created_at);
111
+ const entry = byDay.get(day) || { visitors: /* @__PURE__ */ new Set(), conversions: 0 };
112
+ entry.conversions += 1;
113
+ byDay.set(day, entry);
114
+ }
103
115
  const trend = [];
104
116
  for (let cursor = /* @__PURE__ */ new Date(`${dayOf(range.from)}T00:00:00Z`); cursor.toISOString().slice(0, 10) <= dayOf(range.to); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
105
117
  const day = cursor.toISOString().slice(0, 10);
@@ -108,7 +120,7 @@ function aggregateAnalytics(events, previousEvents, range) {
108
120
  if (trend.length > 370) break;
109
121
  }
110
122
  const bySession = /* @__PURE__ */ new Map();
111
- for (const event of events) {
123
+ for (const event of trafficEvents) {
112
124
  if (!event.session_key) continue;
113
125
  const list = bySession.get(event.session_key) || [];
114
126
  list.push(event);
@@ -117,27 +129,39 @@ function aggregateAnalytics(events, previousEvents, range) {
117
129
  for (const list of bySession.values()) {
118
130
  list.sort((a, b) => a.created_at.localeCompare(b.created_at));
119
131
  }
132
+ const convertedSessionKeys = /* @__PURE__ */ new Set();
133
+ for (const event of trafficEvents) {
134
+ if (isConversion(event) && event.session_key) convertedSessionKeys.add(event.session_key);
135
+ }
136
+ for (const submission of submissions || []) {
137
+ if (submission.session_key) convertedSessionKeys.add(submission.session_key);
138
+ }
120
139
  const pages = /* @__PURE__ */ new Map();
121
140
  const pageEntry = (path) => {
122
141
  const entry = pages.get(path) || { views: 0, entries: 0, conversions: 0 };
123
142
  pages.set(path, entry);
124
143
  return entry;
125
144
  };
126
- for (const event of events) {
145
+ for (const event of trafficEvents) {
127
146
  if (event.type === "pageview") pageEntry(event.path).views += 1;
128
147
  if (isConversion(event)) pageEntry(event.path).conversions += 1;
129
148
  }
149
+ for (const submission of submissions || []) {
150
+ const list = bySession.get(submission.session_key);
151
+ const lastPageview = list?.filter((event) => event.type === "pageview" && event.created_at <= submission.created_at).at(-1);
152
+ if (lastPageview) pageEntry(lastPageview.path).conversions += 1;
153
+ }
130
154
  for (const list of bySession.values()) {
131
155
  const first = list.find((event) => event.type === "pageview");
132
156
  if (first) pageEntry(first.path).entries += 1;
133
157
  }
134
158
  const sources = /* @__PURE__ */ new Map();
135
- for (const list of bySession.values()) {
159
+ for (const [sessionKey, list] of bySession.entries()) {
136
160
  const first = list.find((event) => event.type === "pageview");
137
161
  const label = normalizeSource(first?.utm?.source || "", first?.referrer || "");
138
162
  const entry = sources.get(label) || { sessions: 0, conversions: 0 };
139
163
  entry.sessions += 1;
140
- if (list.some(isConversion)) entry.conversions += 1;
164
+ if (convertedSessionKeys.has(sessionKey)) entry.conversions += 1;
141
165
  sources.set(label, entry);
142
166
  }
143
167
  const locations = /* @__PURE__ */ new Map();
@@ -149,13 +173,13 @@ function aggregateAnalytics(events, previousEvents, range) {
149
173
  if (sample.device) devices.set(sample.device, (devices.get(sample.device) || 0) + 1);
150
174
  }
151
175
  const hours = new Array(24).fill(0);
152
- for (const event of events) {
176
+ for (const event of trafficEvents) {
153
177
  if (event.type !== "pageview") continue;
154
178
  const hour = new Date(event.created_at).getUTCHours();
155
179
  if (Number.isFinite(hour)) hours[hour] += 1;
156
180
  }
157
181
  const pathCounts = /* @__PURE__ */ new Map();
158
- for (const list of bySession.values()) {
182
+ for (const [sessionKey, list] of bySession.entries()) {
159
183
  const steps = [];
160
184
  for (const event of list) {
161
185
  if (event.type !== "pageview") continue;
@@ -166,22 +190,28 @@ function aggregateAnalytics(events, previousEvents, range) {
166
190
  const key = steps.join(" \u2192 ");
167
191
  const entry = pathCounts.get(key) || { count: 0, converted: 0 };
168
192
  entry.count += 1;
169
- if (list.some(isConversion)) entry.converted += 1;
193
+ if (convertedSessionKeys.has(sessionKey)) entry.converted += 1;
170
194
  pathCounts.set(key, entry);
171
195
  }
172
196
  const forms = /* @__PURE__ */ new Map();
173
- for (const event of events) {
197
+ for (const event of trafficEvents) {
174
198
  if (event.type !== "form") continue;
175
199
  const [slug, stage] = event.name.split(":");
176
200
  if (!slug || !stage) continue;
201
+ if (stage !== "viewed" && stage !== "start" && stage !== "submit") continue;
177
202
  const entry = forms.get(slug) || { views: 0, starts: 0, submits: 0 };
178
- if (stage === "view") entry.views += 1;
203
+ if (stage === "viewed") entry.views += 1;
179
204
  if (stage === "start") entry.starts += 1;
180
205
  if (stage === "submit" && event.server_verified === true) entry.submits += 1;
181
206
  forms.set(slug, entry);
182
207
  }
208
+ for (const submission of submissions || []) {
209
+ const entry = forms.get(submission.form) || { views: 0, starts: 0, submits: 0 };
210
+ entry.submits += 1;
211
+ forms.set(submission.form, entry);
212
+ }
183
213
  const notFound = /* @__PURE__ */ new Map();
184
- for (const event of events) {
214
+ for (const event of trafficEvents) {
185
215
  if (event.type === "not_found") notFound.set(event.path, (notFound.get(event.path) || 0) + 1);
186
216
  }
187
217
  return {
@@ -437,33 +467,62 @@ var encode = (value) => Buffer.from(value, "utf8").toString("base64url");
437
467
  var decode = (value) => Buffer.from(value, "base64url").toString("utf8");
438
468
  var sign = (payload, secret) => createHmac2("sha256", secret).update(payload).digest("base64url");
439
469
  var PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1e3;
470
+ var PREVIEW_TOKEN_MAX_USES = 8;
440
471
  var PREVIEW_SESSION_COOKIE = "orion_preview_session";
441
- function createPreviewToken(pageId, secret, ttlMs = PREVIEW_TOKEN_TTL_MS) {
442
- const payload = encode(`${pageId}|${Date.now() + ttlMs}`);
443
- return `${payload}.${sign(payload, secret)}`;
472
+ var keyRing = (keys) => typeof keys === "string" ? { active: { id: "legacy", secret: keys } } : keys;
473
+ function createPreviewToken(grant, keys, ttlMs = PREVIEW_TOKEN_TTL_MS) {
474
+ const active = keyRing(keys).active;
475
+ const issuedAt = Date.now();
476
+ const payload = encode(JSON.stringify({
477
+ version: 1,
478
+ audience: "cms-preview",
479
+ operation: "read-draft",
480
+ keyId: active.id,
481
+ ...grant,
482
+ issuedAt,
483
+ expiresAt: issuedAt + Math.min(Math.max(ttlMs, 1), PREVIEW_TOKEN_TTL_MS)
484
+ }));
485
+ return `${payload}.${sign(payload, active.secret)}`;
444
486
  }
445
- function verifyPreviewToken(token, secret) {
487
+ function verifyPreviewGrantToken(token, keys, now = Date.now()) {
446
488
  const segments = token.split(".");
447
489
  if (segments.length !== 2) return null;
448
490
  const [payload, signature] = segments;
449
491
  if (!payload || !signature) return null;
450
- const expected = sign(payload, secret);
451
- const expectedBuffer = Buffer.from(expected);
452
- const actualBuffer = Buffer.from(signature);
453
- if (expectedBuffer.length !== actualBuffer.length) return null;
454
- if (!timingSafeEqual(expectedBuffer, actualBuffer)) return null;
455
492
  try {
456
- const [pageId, expiresAt] = decode(payload).split("|");
457
- if (!pageId || Number(expiresAt) < Date.now()) return null;
458
- return pageId;
493
+ const claims = JSON.parse(decode(payload));
494
+ if (claims.version !== 1 || claims.audience !== "cms-preview" || claims.operation !== "read-draft" || typeof claims.keyId !== "string" || typeof claims.grantId !== "string" || typeof claims.pageId !== "string" || typeof claims.userId !== "string" || typeof claims.projectRef !== "string" || typeof claims.issuedAt !== "number" || !Number.isSafeInteger(claims.issuedAt) || typeof claims.expiresAt !== "number" || !Number.isSafeInteger(claims.expiresAt) || claims.issuedAt > now || claims.expiresAt <= now || claims.expiresAt - claims.issuedAt > PREVIEW_TOKEN_TTL_MS) {
495
+ return null;
496
+ }
497
+ const configured = keyRing(keys);
498
+ const signingKey = claims.keyId === configured.active.id || configured.active.id === "legacy" ? configured.active : claims.keyId === configured.previous?.id && now <= configured.previous.acceptUntil ? configured.previous : null;
499
+ if (!signingKey) return null;
500
+ const expectedBuffer = Buffer.from(sign(payload, signingKey.secret));
501
+ const actualBuffer = Buffer.from(signature);
502
+ if (expectedBuffer.length !== actualBuffer.length) return null;
503
+ if (!timingSafeEqual(expectedBuffer, actualBuffer)) return null;
504
+ return claims;
459
505
  } catch {
460
506
  return null;
461
507
  }
462
508
  }
463
- async function getPreviewPage(client, token, secret) {
464
- const pageId = verifyPreviewToken(token, secret);
465
- if (!pageId) return null;
466
- const { data } = await client.from("cms_pages").select("id, slug, path, title, seo, draft_layout").eq("id", pageId).maybeSingle();
509
+ function verifyPreviewToken(token, keys) {
510
+ return verifyPreviewGrantToken(token, keys)?.pageId ?? null;
511
+ }
512
+ async function getPreviewPage(client, token, keys, expectedProjectRef) {
513
+ const configured = keyRing(keys);
514
+ if (!configured.active.id || !configured.active.secret || !expectedProjectRef) return null;
515
+ const claims = verifyPreviewGrantToken(token, configured);
516
+ if (!claims || claims.projectRef !== expectedProjectRef) return null;
517
+ const { data: consumed, error: consumeError } = await client.rpc("cms_consume_preview_grant", {
518
+ p_grant_id: claims.grantId,
519
+ p_page_id: claims.pageId,
520
+ p_user_id: claims.userId,
521
+ p_project_ref: claims.projectRef,
522
+ p_key_id: claims.keyId
523
+ });
524
+ if (consumeError || consumed !== true) return null;
525
+ const { data } = await client.from("cms_pages").select("id, slug, path, title, seo, draft_layout").eq("id", claims.pageId).maybeSingle();
467
526
  if (!data) return null;
468
527
  return {
469
528
  id: String(data.id),
@@ -477,20 +536,67 @@ async function getPreviewPage(client, token, secret) {
477
536
 
478
537
  // src/server/supabase.ts
479
538
  import { createClient } from "@supabase/supabase-js";
539
+ var projectRefFromUrl = (supabaseUrl) => {
540
+ let parsed;
541
+ try {
542
+ parsed = new URL(supabaseUrl);
543
+ } catch {
544
+ throw new Error("Orion CMS: the Supabase URL is invalid.");
545
+ }
546
+ const match = /^([a-z0-9]{20})\.supabase\.co$/.exec(parsed.hostname);
547
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port || parsed.pathname !== "/" && parsed.pathname !== "" || parsed.search || parsed.hash || !match) {
548
+ throw new Error("Orion CMS: the standard HTTPS Supabase project URL is required.");
549
+ }
550
+ return match[1];
551
+ };
552
+ var legacyServiceKeyClaims = (key) => {
553
+ const segments = key.split(".");
554
+ if (segments.length !== 3) return null;
555
+ try {
556
+ const payload = Buffer.from(segments[1] || "", "base64url").toString("utf8");
557
+ const claims = JSON.parse(payload);
558
+ return claims && typeof claims === "object" && !Array.isArray(claims) ? claims : null;
559
+ } catch {
560
+ return null;
561
+ }
562
+ };
563
+ function validateCmsEnv(env) {
564
+ if (!/^[a-z0-9]{20}$/.test(env.expectedProjectRef)) {
565
+ throw new Error("Orion CMS: CMS_EXPECTED_SUPABASE_PROJECT_REF must be set.");
566
+ }
567
+ if (projectRefFromUrl(env.supabaseUrl) !== env.expectedProjectRef) {
568
+ throw new Error("Orion CMS: the Supabase URL does not match the expected project.");
569
+ }
570
+ if (env.serviceRoleKey.startsWith("sb_publishable_")) {
571
+ throw new Error("Orion CMS: a publishable key cannot be used as the server credential.");
572
+ }
573
+ if (!env.serviceRoleKey.startsWith("sb_secret_")) {
574
+ const claims = legacyServiceKeyClaims(env.serviceRoleKey);
575
+ if (!claims || claims.role !== "service_role") {
576
+ throw new Error("Orion CMS: a Supabase secret or legacy service-role key is required.");
577
+ }
578
+ if (claims.ref !== env.expectedProjectRef) {
579
+ throw new Error("Orion CMS: the legacy service-role key does not match the expected project.");
580
+ }
581
+ }
582
+ return env;
583
+ }
480
584
  function readCmsEnv() {
481
585
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || "";
482
- const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY || "";
586
+ const serviceRoleKey = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || "";
587
+ const expectedProjectRef = process.env.CMS_EXPECTED_SUPABASE_PROJECT_REF || "";
483
588
  if (!supabaseUrl || !serviceRoleKey) {
484
589
  throw new Error(
485
- "Orion CMS: NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (or SUPABASE_SECRET_KEY) must be set."
590
+ "Orion CMS: NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SECRET_KEY (or legacy SUPABASE_SERVICE_ROLE_KEY) must be set."
486
591
  );
487
592
  }
488
- return { supabaseUrl, serviceRoleKey };
593
+ return validateCmsEnv({ expectedProjectRef, supabaseUrl, serviceRoleKey });
489
594
  }
490
595
  var serviceClient = null;
491
596
  function getServiceClient(env = readCmsEnv()) {
492
597
  if (!serviceClient) {
493
- serviceClient = createClient(env.supabaseUrl, env.serviceRoleKey, {
598
+ const validated = validateCmsEnv(env);
599
+ serviceClient = createClient(validated.supabaseUrl, validated.serviceRoleKey, {
494
600
  auth: { persistSession: false, autoRefreshToken: false }
495
601
  });
496
602
  }
@@ -516,12 +622,19 @@ async function resolveUser(request, client = getServiceClient()) {
516
622
  }
517
623
 
518
624
  // src/server/sync.ts
625
+ import { createHash as createHash2 } from "crypto";
519
626
  var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
520
627
  var publicFormConfig = (value) => {
521
628
  const config = isRecord(value) ? { ...value } : {};
522
629
  delete config.notify;
523
630
  return config;
524
631
  };
632
+ var canonicalize = (value) => {
633
+ if (Array.isArray(value)) return value.map(canonicalize);
634
+ if (!isRecord(value)) return value;
635
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
636
+ };
637
+ var sameValue = (left, right) => JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
525
638
  var mimeByExtension = {
526
639
  avif: "image/avif",
527
640
  gif: "image/gif",
@@ -534,22 +647,15 @@ var mimeByExtension = {
534
647
  webp: "image/webp"
535
648
  };
536
649
  var cleanPath = (src) => src.split("?")[0]?.split("#")[0] || src;
537
- var filenameFromPath = (path) => {
538
- const clean = cleanPath(path);
539
- return clean.split("/").filter(Boolean).pop() || clean;
540
- };
541
- var mimeFromPath = (path) => {
542
- const extension = filenameFromPath(path).split(".").pop()?.toLowerCase() || "";
543
- return mimeByExtension[extension] || "";
544
- };
650
+ var filenameFromPath = (path) => cleanPath(path).split("/").filter(Boolean).pop() || cleanPath(path);
651
+ var mimeFromPath = (path) => mimeByExtension[filenameFromPath(path).split(".").pop()?.toLowerCase() || ""] || "";
545
652
  var shouldIndexMediaPath = (src) => Boolean(src) && !src.startsWith("data:") && !/^https?:\/\//i.test(src);
546
653
  var toSyncMedia = (value) => {
547
654
  const src = typeof value.src === "string" ? value.src.trim() : "";
548
655
  if (!shouldIndexMediaPath(src)) return null;
549
- const filename = typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src);
550
656
  return {
551
657
  storagePath: src,
552
- filename,
658
+ filename: typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src),
553
659
  alt: typeof value.alt === "string" ? value.alt : "",
554
660
  caption: typeof value.caption === "string" ? value.caption : "",
555
661
  mimeType: mimeFromPath(src)
@@ -582,79 +688,328 @@ var normalizeManualMedia = (item) => {
582
688
  filesize: typeof item.filesize === "number" ? item.filesize : null
583
689
  };
584
690
  };
585
- async function runContentSync(client, registry, input) {
586
- const pages = input.pages || [];
587
- const globals = input.globals || [];
588
- const forms = input.forms || [];
691
+ var prepareSync = (registry, input) => {
692
+ const pages = [];
589
693
  const media = /* @__PURE__ */ new Map();
590
- const synced = [];
591
694
  const skipped = [];
592
- for (const page of pages) {
695
+ for (const page of input.pages || []) {
593
696
  if (!page || typeof page.slug !== "string") continue;
594
697
  const validated = registry.validateLayout(page.layout ?? []);
595
698
  if (!validated.ok) {
596
699
  skipped.push({ slug: page.slug, issues: validated.issues });
597
700
  continue;
598
701
  }
599
- const { error } = await client.rpc("cms_sync_page", {
600
- p_slug: page.slug,
601
- p_path: typeof page.path === "string" ? page.path : page.slug === "home" ? "/" : `/${page.slug}`,
602
- p_title: typeof page.title === "string" ? page.title : page.slug,
603
- p_seo: isRecord(page.seo) ? page.seo : {},
604
- p_layout: validated.layout
605
- });
606
- if (error) {
607
- skipped.push({ slug: page.slug, issues: error.message });
608
- continue;
609
- }
610
702
  collectLayoutMedia(validated.layout, media);
611
- synced.push(page.slug);
703
+ pages.push({
704
+ slug: page.slug,
705
+ path: typeof page.path === "string" ? page.path : page.slug === "home" ? "/" : `/${page.slug}`,
706
+ title: typeof page.title === "string" ? page.title : page.slug,
707
+ seo: isRecord(page.seo) ? page.seo : {},
708
+ layout: validated.layout
709
+ });
612
710
  }
613
711
  for (const item of input.media || []) {
614
712
  const normalized = normalizeManualMedia(item);
615
713
  if (normalized && !media.has(normalized.storagePath)) media.set(normalized.storagePath, normalized);
616
714
  }
617
- let syncedMedia = 0;
618
- for (const item of media.values()) {
619
- const { data: existing, error: readError } = await client.from("cms_media").select("id").eq("storage_path", item.storagePath).maybeSingle();
620
- if (readError || existing) continue;
621
- const { error: insertError } = await client.from("cms_media").insert({
622
- storage_path: item.storagePath,
623
- filename: item.filename || filenameFromPath(item.storagePath),
624
- alt: item.alt || "",
625
- caption: item.caption || "",
626
- mime_type: item.mimeType || mimeFromPath(item.storagePath),
627
- width: item.width ?? null,
628
- height: item.height ?? null,
629
- filesize: item.filesize ?? null
630
- });
631
- if (!insertError) syncedMedia += 1;
632
- }
633
- const failed = [];
634
- let syncedGlobals = 0;
635
- for (const global of globals) {
636
- if (!global || typeof global.key !== "string" || !isRecord(global.data)) continue;
637
- const { error } = await client.from("cms_globals").upsert({ key: global.key, data: global.data, updated_at: (/* @__PURE__ */ new Date()).toISOString() });
638
- if (error) failed.push({ kind: "global", key: global.key, error: error.message });
639
- else syncedGlobals += 1;
640
- }
641
- let syncedForms = 0;
642
- for (const form of forms) {
643
- if (!form || typeof form.slug !== "string") continue;
644
- const { error } = await client.from("cms_forms").upsert(
715
+ return {
716
+ pages,
717
+ globals: (input.globals || []).filter((item) => Boolean(item && typeof item.key === "string" && isRecord(item.data))),
718
+ forms: (input.forms || []).filter((item) => Boolean(item && typeof item.slug === "string")).map((form) => ({
719
+ slug: form.slug,
720
+ title: typeof form.title === "string" ? form.title : form.slug,
721
+ config: publicFormConfig(form.config),
722
+ successMessage: typeof form.successMessage === "string" ? form.successMessage : ""
723
+ })),
724
+ media: [...media.values()],
725
+ redirects: (input.redirects || []).filter((item) => Boolean(
726
+ item && typeof item.fromPath === "string" && typeof item.toPath === "string"
727
+ )).map((item) => ({
728
+ fromPath: item.fromPath,
729
+ toPath: item.toPath,
730
+ permanent: item.permanent !== false
731
+ })),
732
+ skipped
733
+ };
734
+ };
735
+ var readOne = async (client, table, columns, key, value) => {
736
+ const { data, error } = await client.from(table).select(columns).eq(key, value).maybeSingle();
737
+ if (error) throw new Error(error.message);
738
+ return data;
739
+ };
740
+ var readMany = async (client, table, columns) => {
741
+ const { data, error } = await client.from(table).select(columns);
742
+ if (error) throw new Error(error.message);
743
+ return data || [];
744
+ };
745
+ var targetReceiptHash = async (client, expectedProjectRef = "") => {
746
+ if (expectedProjectRef === "memory") {
747
+ return createHash2("sha256").update("memory:uninitialized").digest("hex");
748
+ }
749
+ const marker = await readOne(client, "cms_target_identity", "project_ref", "singleton", true);
750
+ const projectRef = typeof marker?.project_ref === "string" ? marker.project_ref : "";
751
+ if (!expectedProjectRef && !projectRef) {
752
+ return createHash2("sha256").update("unbound-local:uninitialized").digest("hex");
753
+ }
754
+ if (!projectRef || projectRef !== expectedProjectRef) {
755
+ throw new Error("Sync target identity does not match the expected CMS project.");
756
+ }
757
+ const migrations = await readMany(client, "cms_migrations", "version");
758
+ const migrationHead = migrations.map((row) => String(row.version)).sort().at(-1) || "empty";
759
+ return createHash2("sha256").update(JSON.stringify(canonicalize({
760
+ migrationHead,
761
+ projectRefHash: createHash2("sha256").update(projectRef).digest("hex").slice(0, 16)
762
+ }))).digest("hex");
763
+ };
764
+ async function buildPlan(client, registry, input, expectedProjectRef = "") {
765
+ const prepared = prepareSync(registry, input);
766
+ const operations = [];
767
+ for (const page of prepared.pages) {
768
+ const current = await readOne(client, "cms_pages", "slug, path, title, seo, draft_layout, builder_owned", "slug", page.slug);
769
+ const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
770
+ { path: current.path, title: current.title, seo: current.seo, layout: current.draft_layout },
771
+ { path: page.path, title: page.title, seo: page.seo, layout: page.layout }
772
+ ) ? "noop" : "update";
773
+ operations.push({ kind: "page", key: page.slug, action });
774
+ }
775
+ for (const global of prepared.globals) {
776
+ const current = await readOne(client, "cms_globals", "key, data, builder_owned", "key", global.key);
777
+ const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(current.data, global.data) ? "noop" : "update";
778
+ operations.push({ kind: "global", key: global.key, action });
779
+ }
780
+ for (const form of prepared.forms) {
781
+ const current = await readOne(client, "cms_forms", "slug, title, config, success_message, builder_owned", "slug", form.slug);
782
+ const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
783
+ { title: current.title, config: current.config, successMessage: current.success_message },
784
+ { title: form.title, config: form.config, successMessage: form.successMessage }
785
+ ) ? "noop" : "update";
786
+ operations.push({ kind: "form", key: form.slug, action });
787
+ }
788
+ for (const item of prepared.media) {
789
+ const current = await readOne(client, "cms_media", "id, storage_path, filename, alt, caption, mime_type, width, height, filesize, builder_owned", "storage_path", item.storagePath);
790
+ const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
645
791
  {
646
- slug: form.slug,
647
- title: typeof form.title === "string" ? form.title : form.slug,
648
- config: publicFormConfig(form.config),
649
- success_message: typeof form.successMessage === "string" ? form.successMessage : "",
650
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
792
+ filename: current.filename,
793
+ alt: current.alt,
794
+ caption: current.caption,
795
+ mimeType: current.mime_type,
796
+ width: current.width,
797
+ height: current.height,
798
+ filesize: current.filesize
651
799
  },
652
- { onConflict: "slug" }
800
+ {
801
+ filename: item.filename,
802
+ alt: item.alt,
803
+ caption: item.caption,
804
+ mimeType: item.mimeType,
805
+ width: item.width ?? null,
806
+ height: item.height ?? null,
807
+ filesize: item.filesize ?? null
808
+ }
809
+ ) ? "noop" : "update";
810
+ operations.push({ kind: "media", key: item.storagePath, action });
811
+ }
812
+ for (const redirect of prepared.redirects) {
813
+ const current = await readOne(client, "cms_redirects", "from_path, to_path, permanent, builder_owned", "from_path", redirect.fromPath);
814
+ const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
815
+ { toPath: current.to_path, permanent: current.permanent },
816
+ { toPath: redirect.toPath, permanent: redirect.permanent }
817
+ ) ? "noop" : "update";
818
+ operations.push({ kind: "redirect", key: redirect.fromPath, action });
819
+ }
820
+ const ownershipSets = /* @__PURE__ */ new Map([
821
+ ["page", new Set(prepared.pages.map((item) => item.slug))],
822
+ ["global", new Set(prepared.globals.map((item) => item.key))],
823
+ ["form", new Set(prepared.forms.map((item) => item.slug))],
824
+ ["media", new Set(prepared.media.map((item) => item.storagePath))],
825
+ ["redirect", new Set(prepared.redirects.map((item) => item.fromPath))]
826
+ ]);
827
+ const remoteOwned = [
828
+ ["page", "cms_pages", "slug"],
829
+ ["global", "cms_globals", "key"],
830
+ ["form", "cms_forms", "slug"],
831
+ ["media", "cms_media", "storage_path"],
832
+ ["redirect", "cms_redirects", "from_path"]
833
+ ];
834
+ for (const [kind, table, key] of remoteOwned) {
835
+ const rows = await readMany(client, table, `${key}, builder_owned`);
836
+ for (const row of rows) {
837
+ const value = String(row[key] ?? "");
838
+ if (row.builder_owned === false && value && !ownershipSets.get(kind)?.has(value)) {
839
+ operations.push({ kind, key: value, action: "blocked-delete" });
840
+ }
841
+ }
842
+ }
843
+ const kindOrder = {
844
+ page: 0,
845
+ global: 1,
846
+ form: 2,
847
+ media: 3,
848
+ redirect: 4
849
+ };
850
+ operations.sort(
851
+ (left, right) => kindOrder[left.kind] - kindOrder[right.kind] || left.key.localeCompare(right.key)
852
+ );
853
+ const receiptHash = await targetReceiptHash(client, expectedProjectRef);
854
+ const manifestHash = createHash2("sha256").update(JSON.stringify(canonicalize({
855
+ input: prepared,
856
+ targetReceiptHash: receiptHash,
857
+ version: 1
858
+ }))).digest("hex");
859
+ return {
860
+ prepared,
861
+ plan: {
862
+ mode: "dry-run",
863
+ manifestHash,
864
+ targetReceiptHash: receiptHash,
865
+ operations,
866
+ skipped: prepared.skipped
867
+ }
868
+ };
869
+ }
870
+ async function runContentSync(client, registry, input, options = { mode: "dry-run" }) {
871
+ if (options.mode === "apply") {
872
+ const currentTargetReceiptHash = await targetReceiptHash(client, options.expectedProjectRef);
873
+ if (!options.expectedTargetReceiptHash || options.expectedTargetReceiptHash !== currentTargetReceiptHash) {
874
+ throw new Error("Sync target receipt changed. Run a new dry run for the current CMS target.");
875
+ }
876
+ const completed = await readOne(
877
+ client,
878
+ "cms_sync_runs",
879
+ "manifest_hash, target_receipt_hash, status, result",
880
+ "manifest_hash",
881
+ options.expectedManifestHash
653
882
  );
654
- if (error) failed.push({ kind: "form", key: form.slug, error: error.message });
655
- else syncedForms += 1;
883
+ if (completed?.status === "complete" && completed.target_receipt_hash === currentTargetReceiptHash && isRecord(completed.result)) {
884
+ return completed.result;
885
+ }
886
+ }
887
+ const { prepared, plan } = await buildPlan(client, registry, input, options.expectedProjectRef);
888
+ if (options.mode !== "apply") return plan;
889
+ if (!options.expectedManifestHash || options.expectedManifestHash !== plan.manifestHash) {
890
+ throw new Error("Sync manifest changed. Run a new dry run and apply its exact manifest hash.");
891
+ }
892
+ if (!options.expectedTargetReceiptHash || options.expectedTargetReceiptHash !== plan.targetReceiptHash) {
893
+ throw new Error("Sync target receipt changed. Run a new dry run for the current CMS target.");
894
+ }
895
+ const { data: lease, error: leaseError } = await client.rpc("cms_begin_sync", {
896
+ p_manifest_hash: plan.manifestHash,
897
+ p_target_receipt_hash: plan.targetReceiptHash
898
+ });
899
+ if (leaseError) throw new Error(leaseError.message);
900
+ const leaseResult = isRecord(lease) ? lease : {};
901
+ if (leaseResult.status === "busy") {
902
+ throw new Error("Another content sync is active for this CMS target.");
903
+ }
904
+ if (leaseResult.status === "complete" && isRecord(leaseResult.result)) {
905
+ return leaseResult.result;
906
+ }
907
+ if (!["started", "resume"].includes(String(leaseResult.status))) {
908
+ throw new Error("The CMS sync lease could not be acquired.");
909
+ }
910
+ try {
911
+ const synced = [];
912
+ const failed = [];
913
+ const conflicts = plan.operations.filter((operation) => operation.action === "conflict");
914
+ let globals = 0;
915
+ let forms = 0;
916
+ let media = 0;
917
+ for (const page of prepared.pages) {
918
+ const operation = plan.operations.find((item) => item.kind === "page" && item.key === page.slug);
919
+ if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
920
+ const { error } = await client.rpc("cms_sync_page", {
921
+ p_slug: page.slug,
922
+ p_path: page.path,
923
+ p_title: page.title,
924
+ p_seo: page.seo,
925
+ p_layout: page.layout
926
+ });
927
+ if (error) failed.push({ kind: "page", key: page.slug, error: error.message });
928
+ else synced.push(page.slug);
929
+ }
930
+ for (const global of prepared.globals) {
931
+ const operation = plan.operations.find((item) => item.kind === "global" && item.key === global.key);
932
+ if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
933
+ const { data, error } = await client.rpc("cms_sync_global", { p_key: global.key, p_data: global.data });
934
+ if (error) failed.push({ kind: "global", key: global.key, error: error.message });
935
+ else if (data === true) globals += 1;
936
+ else conflicts.push({ kind: "global", key: global.key, action: "conflict" });
937
+ }
938
+ for (const form of prepared.forms) {
939
+ const operation = plan.operations.find((item) => item.kind === "form" && item.key === form.slug);
940
+ if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
941
+ const { data, error } = await client.rpc("cms_sync_form", {
942
+ p_slug: form.slug,
943
+ p_title: form.title,
944
+ p_config: form.config,
945
+ p_success_message: form.successMessage
946
+ });
947
+ if (error) failed.push({ kind: "form", key: form.slug, error: error.message });
948
+ else if (data === true) forms += 1;
949
+ else conflicts.push({ kind: "form", key: form.slug, action: "conflict" });
950
+ }
951
+ for (const item of prepared.media) {
952
+ const operation = plan.operations.find((entry) => entry.kind === "media" && entry.key === item.storagePath);
953
+ if (!operation || !["create", "update"].includes(operation.action)) continue;
954
+ const { data, error } = await client.rpc("cms_sync_media", {
955
+ p_storage_path: item.storagePath,
956
+ p_filename: item.filename || filenameFromPath(item.storagePath),
957
+ p_alt: item.alt || "",
958
+ p_caption: item.caption || "",
959
+ p_mime_type: item.mimeType || mimeFromPath(item.storagePath),
960
+ p_width: item.width ?? null,
961
+ p_height: item.height ?? null,
962
+ p_filesize: item.filesize ?? null
963
+ });
964
+ if (error) failed.push({ kind: "media", key: item.storagePath, error: error.message });
965
+ else if (data === true) media += 1;
966
+ else conflicts.push({ kind: "media", key: item.storagePath, action: "conflict" });
967
+ }
968
+ for (const redirect of prepared.redirects) {
969
+ const operation = plan.operations.find((entry) => entry.kind === "redirect" && entry.key === redirect.fromPath);
970
+ if (!operation || !["create", "update"].includes(operation.action)) continue;
971
+ const { data, error } = await client.rpc("cms_sync_redirect", {
972
+ p_from_path: redirect.fromPath,
973
+ p_to_path: redirect.toPath,
974
+ p_permanent: redirect.permanent
975
+ });
976
+ if (error) failed.push({ kind: "redirect", key: redirect.fromPath, error: error.message });
977
+ else if (data !== true) conflicts.push({ kind: "redirect", key: redirect.fromPath, action: "conflict" });
978
+ }
979
+ const result = {
980
+ mode: "apply",
981
+ manifestHash: plan.manifestHash,
982
+ targetReceiptHash: plan.targetReceiptHash,
983
+ synced,
984
+ skipped: plan.skipped,
985
+ globals,
986
+ forms,
987
+ media,
988
+ failed,
989
+ conflicts
990
+ };
991
+ const completionStatus = failed.length === 0 ? "complete" : "failed";
992
+ const completionError = completionStatus === "failed" ? `${failed.length} content sync operation${failed.length === 1 ? "" : "s"} failed.` : null;
993
+ const { data: finished, error: finishError } = await client.rpc("cms_finish_sync", {
994
+ p_manifest_hash: plan.manifestHash,
995
+ p_status: completionStatus,
996
+ p_result: completionStatus === "complete" ? result : null,
997
+ p_error: completionError
998
+ });
999
+ if (finishError || finished !== true) {
1000
+ throw new Error(finishError?.message || "The CMS sync completion receipt was not recorded.");
1001
+ }
1002
+ if (completionError) throw new Error(completionError);
1003
+ return result;
1004
+ } catch (error) {
1005
+ await client.rpc("cms_finish_sync", {
1006
+ p_manifest_hash: plan.manifestHash,
1007
+ p_status: "failed",
1008
+ p_result: null,
1009
+ p_error: error instanceof Error ? error.message : "Content sync failed."
1010
+ });
1011
+ throw error;
656
1012
  }
657
- return { synced, skipped, globals: syncedGlobals, forms: syncedForms, media: syncedMedia, failed };
658
1013
  }
659
1014
 
660
1015
  // src/server/routes.ts
@@ -739,6 +1094,7 @@ var clientKey = (request) => {
739
1094
  };
740
1095
  var MAX_PUBLIC_BODY_BYTES = 64 * 1024;
741
1096
  var MAX_PUBLIC_BODY_DEPTH = 12;
1097
+ var MAX_SYNC_BODY_BYTES = 2 * 1024 * 1024;
742
1098
  function exceedsDepth(value, limit, depth = 0) {
743
1099
  if (depth > limit) return true;
744
1100
  if (Array.isArray(value)) return value.some((item) => exceedsDepth(item, limit, depth + 1));
@@ -746,13 +1102,38 @@ function exceedsDepth(value, limit, depth = 0) {
746
1102
  return false;
747
1103
  }
748
1104
  async function readJsonLimited(request, limits) {
749
- let text;
1105
+ const declaredLength = request.headers.get("content-length");
1106
+ if (declaredLength !== null) {
1107
+ if (!/^[0-9]+$/.test(declaredLength)) return null;
1108
+ const length = Number(declaredLength);
1109
+ if (!Number.isSafeInteger(length) || length > limits.maxBytes) return null;
1110
+ }
1111
+ if (!request.body) return null;
1112
+ const reader = request.body.getReader();
1113
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1114
+ let totalBytes = 0;
1115
+ let text = "";
750
1116
  try {
751
- text = await request.text();
1117
+ while (true) {
1118
+ const { done, value } = await reader.read();
1119
+ if (done) break;
1120
+ totalBytes += value.byteLength;
1121
+ if (totalBytes > limits.maxBytes) {
1122
+ await reader.cancel("CMS request body exceeded the configured byte limit.");
1123
+ return null;
1124
+ }
1125
+ text += decoder.decode(value, { stream: true });
1126
+ }
1127
+ text += decoder.decode();
752
1128
  } catch {
1129
+ try {
1130
+ await reader.cancel();
1131
+ } catch {
1132
+ }
753
1133
  return null;
1134
+ } finally {
1135
+ reader.releaseLock();
754
1136
  }
755
- if (text.length > limits.maxBytes) return null;
756
1137
  let parsed;
757
1138
  try {
758
1139
  parsed = JSON.parse(text);
@@ -763,7 +1144,7 @@ async function readJsonLimited(request, limits) {
763
1144
  if (exceedsDepth(parsed, limits.maxDepth)) return null;
764
1145
  return parsed;
765
1146
  }
766
- var hashedClientKey = (ip) => createHash2("sha256").update(ip).digest("hex").slice(0, 24);
1147
+ var hashedClientKey = (ip) => createHash3("sha256").update(ip).digest("hex").slice(0, 24);
767
1148
  var requestPagePath = (request) => {
768
1149
  const referrer = request.headers.get("referer");
769
1150
  if (!referrer) return "";
@@ -799,16 +1180,27 @@ function createDurableRateLimitStore(getClient, options) {
799
1180
  };
800
1181
  }
801
1182
  function createCmsRoutes(options) {
802
- const { registry, allowedOrigins, syncToken, knownGoodEmailDomains } = options;
1183
+ const { registry, allowedOrigins, syncToken, cronToken, knownGoodEmailDomains } = options;
803
1184
  const db = () => options.client ?? getServiceClient();
804
1185
  const hostedMemoryMode = options.memoryMode === true && process.env.NODE_ENV === "production";
805
1186
  const rateLimit = options.rateLimitStore === null ? null : options.rateLimitStore || (options.memoryMode ? createMemoryRateLimitStore() : createDurableRateLimitStore(db, { max: 5, windowMs: 6e4, bucket: "submit" }));
806
1187
  const sendEmail = hostedMemoryMode || options.sendEmail === null ? null : options.sendEmail || createResendSender();
807
1188
  const maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
808
- const previewSecret = () => {
809
- if (hostedMemoryMode) return "";
810
- return options.previewSecret || process.env.SUPABASE_SERVICE_ROLE_KEY || (options.memoryMode ? "orion-memory-preview-secret" : "");
811
- };
1189
+ const previewKeys = () => {
1190
+ if (hostedMemoryMode) return null;
1191
+ const activeSecret = options.previewSecret || (options.memoryMode ? "orion-memory-preview-secret" : "");
1192
+ const activeId = options.previewKeyId || (options.memoryMode ? "memory-v1" : "");
1193
+ if (!activeSecret || !activeId) return null;
1194
+ const previous = options.previewPreviousSecret && options.previewPreviousKeyId && options.previewPreviousValidUntil ? {
1195
+ id: options.previewPreviousKeyId,
1196
+ secret: options.previewPreviousSecret,
1197
+ acceptUntil: options.previewPreviousValidUntil
1198
+ } : void 0;
1199
+ return { active: { id: activeId, secret: activeSecret }, ...previous ? { previous } : {} };
1200
+ };
1201
+ const previewProjectRef = options.projectRef || (options.memoryMode ? "memory" : "");
1202
+ const analyticsSecret = options.analyticsSecret || (options.memoryMode ? "orion-memory-analytics-secret" : "");
1203
+ const autoReplyHashSecret = options.autoReplyHashSecret || (options.memoryMode ? "orion-memory-auto-reply-secret" : "");
812
1204
  const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
813
1205
  const autoReplyLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
814
1206
  const boundedAutoReplyMax = (name, fallback) => {
@@ -832,14 +1224,12 @@ function createCmsRoutes(options) {
832
1224
  "auto-reply-site-day"
833
1225
  )
834
1226
  };
835
- const autoReplyRecipientKey = (email) => createHmac3(
836
- "sha256",
837
- previewSecret() || syncToken || "orion-memory-auto-reply-secret"
838
- ).update(`recipient:${email}`).digest("hex").slice(0, 24);
1227
+ const autoReplyRecipientKey = (email) => createHmac3("sha256", autoReplyHashSecret).update(`recipient:${email}`).digest("hex").slice(0, 24);
839
1228
  const maySendAutoReply = async (config, data) => {
840
1229
  if (config.notify?.autoReply !== true) return false;
841
1230
  const recipient = resolveSubmitterEmail(config, data);
842
1231
  if (!recipient) return false;
1232
+ if (!autoReplyHashSecret) return false;
843
1233
  const now = Date.now();
844
1234
  if (await autoReplyLimits.recipientPerHour.isLimited(autoReplyRecipientKey(recipient), now)) {
845
1235
  return false;
@@ -891,8 +1281,8 @@ function createCmsRoutes(options) {
891
1281
  };
892
1282
  const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
893
1283
  const requestSessionKey = (request) => {
894
- const secret = previewSecret() || "orion-analytics";
895
- return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "", secret);
1284
+ if (!analyticsSecret) return "";
1285
+ return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "", analyticsSecret);
896
1286
  };
897
1287
  const guard = async (request, action) => {
898
1288
  if (hostedMemoryMode) return errors.unauthorized();
@@ -901,6 +1291,13 @@ function createCmsRoutes(options) {
901
1291
  if (!can(user, action)) return errors.forbidden();
902
1292
  return { user };
903
1293
  };
1294
+ const revokePreviewGrants = async (filter) => {
1295
+ let query = db().from("cms_preview_grants").update({ revoked_at: (/* @__PURE__ */ new Date()).toISOString() });
1296
+ if (filter.pageId) query = query.eq("page_id", filter.pageId);
1297
+ if (filter.userId) query = query.eq("user_id", filter.userId);
1298
+ const { error } = await query;
1299
+ return error?.message ?? null;
1300
+ };
904
1301
  const logActivity = async (user, action, subject) => {
905
1302
  try {
906
1303
  await db().from("cms_activity").insert({
@@ -1020,6 +1417,7 @@ function createCmsRoutes(options) {
1020
1417
  }
1021
1418
  patch.path = path;
1022
1419
  }
1420
+ patch.builder_owned = true;
1023
1421
  if (Object.keys(patch).length > 0) {
1024
1422
  const oldPath = String(current.path || "");
1025
1423
  const wasPublished = current.status === "published";
@@ -1033,7 +1431,12 @@ function createCmsRoutes(options) {
1033
1431
  const newPath = typeof patch.path === "string" ? patch.path : oldPath;
1034
1432
  if (newPath !== oldPath && wasPublished && oldPath !== "/") {
1035
1433
  await db().from("cms_redirects").delete().eq("from_path", newPath);
1036
- await db().from("cms_redirects").upsert({ from_path: oldPath, to_path: newPath, permanent: true }, { onConflict: "from_path" });
1434
+ await db().from("cms_redirects").upsert({
1435
+ from_path: oldPath,
1436
+ to_path: newPath,
1437
+ permanent: true,
1438
+ builder_owned: true
1439
+ }, { onConflict: "from_path" });
1037
1440
  }
1038
1441
  if (newPath !== oldPath) {
1039
1442
  await logActivity(auth.user, "page.rename", `${oldPath} \u2192 ${newPath}`);
@@ -1093,6 +1496,8 @@ function createCmsRoutes(options) {
1093
1496
  if (!Number.isSafeInteger(expectedVersionId) || expectedVersionId <= 0) {
1094
1497
  return errors.conflict("Page version conflict: save the draft again before publishing.");
1095
1498
  }
1499
+ const revokeError = await revokePreviewGrants({ pageId: id });
1500
+ if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
1096
1501
  const { data, error } = await db().rpc("cms_publish_page", {
1097
1502
  p_page_id: id,
1098
1503
  p_expected_version_id: expectedVersionId,
@@ -1109,6 +1514,8 @@ function createCmsRoutes(options) {
1109
1514
  const unpublishPage = async (request, id) => {
1110
1515
  const auth = await guard(request, "pages.publish");
1111
1516
  if (auth instanceof Response) return auth;
1517
+ const revokeError = await revokePreviewGrants({ pageId: id });
1518
+ if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
1112
1519
  const { data, error } = await db().from("cms_pages").update({
1113
1520
  status: "draft",
1114
1521
  publish_at: null,
@@ -1167,11 +1574,28 @@ function createCmsRoutes(options) {
1167
1574
  const previewToken = async (request, id) => {
1168
1575
  const auth = await guard(request, "pages.read");
1169
1576
  if (auth instanceof Response) return auth;
1170
- const secret = previewSecret();
1171
- if (!secret) return errors.badRequest("Preview is not configured on this site.");
1577
+ const keys = previewKeys();
1578
+ if (!keys || !previewProjectRef) return errors.badRequest("Preview is not configured on this site.");
1172
1579
  const { data } = await db().from("cms_pages").select("id, path").eq("id", id).maybeSingle();
1173
1580
  if (!data) return errors.notFound();
1174
- const token = createPreviewToken(id, secret);
1581
+ const grantId = randomUUID();
1582
+ const expiresAt = new Date(Date.now() + PREVIEW_TOKEN_TTL_MS).toISOString();
1583
+ const { error: grantError } = await db().from("cms_preview_grants").insert({
1584
+ id: grantId,
1585
+ page_id: id,
1586
+ user_id: auth.user.id,
1587
+ project_ref: previewProjectRef,
1588
+ key_id: keys.active.id,
1589
+ expires_at: expiresAt,
1590
+ max_uses: PREVIEW_TOKEN_MAX_USES
1591
+ });
1592
+ if (grantError) return errors.badRequest("Unable to create a preview grant.");
1593
+ const token = createPreviewToken({
1594
+ grantId,
1595
+ pageId: id,
1596
+ userId: auth.user.id,
1597
+ projectRef: previewProjectRef
1598
+ }, keys);
1175
1599
  const previewUrl = `/cms-preview/${encodeURIComponent(String(data.id))}`;
1176
1600
  const response = json({ path: data.path, url: previewUrl });
1177
1601
  const secure = process.env.NODE_ENV === "production" || new URL(request.url).protocol === "https:";
@@ -1183,6 +1607,14 @@ function createCmsRoutes(options) {
1183
1607
  );
1184
1608
  return response;
1185
1609
  };
1610
+ const revokePagePreviews = async (request, id) => {
1611
+ const auth = await guard(request, "pages.read");
1612
+ if (auth instanceof Response) return auth;
1613
+ const revokeError = await revokePreviewGrants({ pageId: id });
1614
+ if (revokeError) return errors.badRequest("Unable to revoke preview grants.");
1615
+ await logActivity(auth.user, "page.preview.revoke", id);
1616
+ return json({ success: true });
1617
+ };
1186
1618
  const listVersions = async (request, id) => {
1187
1619
  const auth = await guard(request, "pages.restore");
1188
1620
  if (auth instanceof Response) return auth;
@@ -1209,6 +1641,10 @@ function createCmsRoutes(options) {
1209
1641
  const restoreVersion = async (request, versionId) => {
1210
1642
  const auth = await guard(request, "pages.restore");
1211
1643
  if (auth instanceof Response) return auth;
1644
+ const { data: version } = await db().from("cms_page_versions").select("page_id").eq("id", Number(versionId)).maybeSingle();
1645
+ if (!version) return errors.notFound();
1646
+ const revokeError = await revokePreviewGrants({ pageId: String(version.page_id) });
1647
+ if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
1212
1648
  const { data, error } = await db().rpc("cms_restore_page_version", {
1213
1649
  p_version_id: Number(versionId),
1214
1650
  p_actor: auth.user.id
@@ -1254,6 +1690,7 @@ function createCmsRoutes(options) {
1254
1690
  const { data, error } = await db().from("cms_globals").upsert({
1255
1691
  key: version.key,
1256
1692
  data: version.data,
1693
+ builder_owned: true,
1257
1694
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1258
1695
  }).select("*").single();
1259
1696
  if (error) return errors.badRequest(error.message);
@@ -1343,7 +1780,8 @@ function createCmsRoutes(options) {
1343
1780
  mime_type: normalizedFile.type,
1344
1781
  width: prepared.width,
1345
1782
  height: prepared.height,
1346
- filesize: normalizedFile.size
1783
+ filesize: normalizedFile.size,
1784
+ builder_owned: true
1347
1785
  }).select("*").single();
1348
1786
  if (error) return errors.badRequest(error.message);
1349
1787
  if (!options.memoryMode) {
@@ -1361,7 +1799,10 @@ function createCmsRoutes(options) {
1361
1799
  if (auth instanceof Response) return auth;
1362
1800
  const body = await readJson(request);
1363
1801
  if (!body) return errors.badRequest("Invalid body.");
1364
- const patch = { updated_at: (/* @__PURE__ */ new Date()).toISOString() };
1802
+ const patch = {
1803
+ builder_owned: true,
1804
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1805
+ };
1365
1806
  if (typeof body.alt === "string") patch.alt = body.alt;
1366
1807
  if (typeof body.caption === "string") patch.caption = body.caption;
1367
1808
  if (typeof body.filename === "string" && body.filename.trim()) {
@@ -1420,6 +1861,7 @@ function createCmsRoutes(options) {
1420
1861
  filesize: normalizedFile.size,
1421
1862
  width: prepared.width,
1422
1863
  height: prepared.height,
1864
+ builder_owned: true,
1423
1865
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1424
1866
  }).eq("id", id).select("*").single();
1425
1867
  if (error) return errors.badRequest(error.message);
@@ -1488,7 +1930,8 @@ function createCmsRoutes(options) {
1488
1930
  slug,
1489
1931
  title,
1490
1932
  config: { steps: [{ title: "", fields: [] }] },
1491
- success_message: "Thanks \u2014 we received your submission."
1933
+ success_message: "Thanks \u2014 we received your submission.",
1934
+ builder_owned: true
1492
1935
  }).select("*").single();
1493
1936
  if (error) {
1494
1937
  if (error.message.includes("duplicate")) {
@@ -1502,9 +1945,15 @@ function createCmsRoutes(options) {
1502
1945
  const getForm = async (request, slug) => {
1503
1946
  const auth = await guard(request, "forms.read");
1504
1947
  if (auth instanceof Response) return auth;
1505
- const { data, error } = await db().from("cms_forms").select("*").eq("slug", slug).maybeSingle();
1948
+ const projection = can(auth.user, "forms.write") ? "id, slug, title, config, notify, success_message, updated_at" : "id, slug, title, config, success_message, updated_at";
1949
+ const { data, error } = await db().from("cms_forms").select(projection).eq("slug", slug).maybeSingle();
1506
1950
  if (error) return errors.badRequest(error.message);
1507
1951
  if (!data) return errors.notFound();
1952
+ if (!can(auth.user, "forms.write")) {
1953
+ const { notify, ...publicForm } = data;
1954
+ void notify;
1955
+ return json({ form: publicForm });
1956
+ }
1508
1957
  return json({ form: data });
1509
1958
  };
1510
1959
  const updateForm = async (request, slug) => {
@@ -1546,6 +1995,7 @@ function createCmsRoutes(options) {
1546
1995
  config,
1547
1996
  notify,
1548
1997
  success_message: typeof body.successMessage === "string" ? body.successMessage : "",
1998
+ builder_owned: true,
1549
1999
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1550
2000
  },
1551
2001
  { onConflict: "slug" }
@@ -1754,7 +2204,12 @@ function createCmsRoutes(options) {
1754
2204
  return errors.badRequest("To path must start with / or be a full URL.");
1755
2205
  }
1756
2206
  if (fromPath === toPath) return errors.badRequest("A redirect cannot point at itself.");
1757
- const { data, error } = await db().from("cms_redirects").upsert({ from_path: fromPath, to_path: toPath, permanent }, { onConflict: "from_path" }).select("*").single();
2207
+ const { data, error } = await db().from("cms_redirects").upsert({
2208
+ from_path: fromPath,
2209
+ to_path: toPath,
2210
+ permanent,
2211
+ builder_owned: true
2212
+ }, { onConflict: "from_path" }).select("*").single();
1758
2213
  if (error) return errors.badRequest(error.message);
1759
2214
  await logActivity(auth.user, "redirect.save", `${fromPath} \u2192 ${toPath}`);
1760
2215
  return json({ redirect: data }, 201);
@@ -1768,6 +2223,7 @@ function createCmsRoutes(options) {
1768
2223
  };
1769
2224
  const ingestEvents = async (request) => {
1770
2225
  if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
2226
+ if (!analyticsSecret) return json({ success: true });
1771
2227
  const userAgent = request.headers.get("user-agent") || "";
1772
2228
  if (isBotRequest(userAgent)) return json({ success: true });
1773
2229
  const now = Date.now();
@@ -1784,7 +2240,7 @@ function createCmsRoutes(options) {
1784
2240
  sessionKey: requestSessionKey(request),
1785
2241
  visitorKey: visitorKeyFor(
1786
2242
  body?.visitorId,
1787
- previewSecret() || "orion-analytics"
2243
+ analyticsSecret
1788
2244
  ),
1789
2245
  device: deviceFrom(userAgent),
1790
2246
  ...geoFrom(request)
@@ -1825,6 +2281,30 @@ function createCmsRoutes(options) {
1825
2281
  }
1826
2282
  return all;
1827
2283
  };
2284
+ const fetchFormSubmissions = async (fromIso, toIso) => {
2285
+ const all = [];
2286
+ let cursor = 0;
2287
+ for (let page = 0; page < 60; page += 1) {
2288
+ const { data, error } = await db().from("cms_form_submissions").select("id, form_id, session_key, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
2289
+ if (error) return all.length === 0 ? null : all;
2290
+ if (!data || data.length === 0) break;
2291
+ all.push(...data);
2292
+ cursor = Number(data[data.length - 1].id);
2293
+ if (data.length < 1e3) break;
2294
+ }
2295
+ return all;
2296
+ };
2297
+ const fetchFormSlugs = async () => {
2298
+ const { data, error } = await db().from("cms_forms").select("id, slug").limit(1e3);
2299
+ const slugs = /* @__PURE__ */ new Map();
2300
+ if (error || !data) return slugs;
2301
+ for (const form of data) {
2302
+ const id = String(form.id || "");
2303
+ const slug = String(form.slug || "");
2304
+ if (id && slug) slugs.set(id, slug);
2305
+ }
2306
+ return slugs;
2307
+ };
1828
2308
  const getAnalytics = async (request) => {
1829
2309
  const auth = await guard(request, "analytics.read");
1830
2310
  if (auth instanceof Response) return auth;
@@ -1836,11 +2316,26 @@ function createCmsRoutes(options) {
1836
2316
  const from = new Date(fromMs).toISOString();
1837
2317
  const to = new Date(toMs).toISOString();
1838
2318
  const previousFrom = new Date(fromMs - windowMs).toISOString();
1839
- const [events, previousEvents] = await Promise.all([
2319
+ const [events, previousEvents, submissionRows, formSlugs] = await Promise.all([
1840
2320
  fetchEvents(from, to),
1841
- fetchEvents(previousFrom, from)
2321
+ fetchEvents(previousFrom, from),
2322
+ fetchFormSubmissions(previousFrom, to),
2323
+ fetchFormSlugs()
1842
2324
  ]);
1843
- return json(aggregateAnalytics(events, previousEvents, { from, to }));
2325
+ const submissions = submissionRows?.map((submission) => ({
2326
+ form: formSlugs.get(String(submission.form_id)) || String(submission.form_id),
2327
+ session_key: String(submission.session_key || ""),
2328
+ created_at: String(submission.created_at)
2329
+ }));
2330
+ const currentSubmissions = submissions?.filter(
2331
+ (submission) => Date.parse(submission.created_at) >= fromMs
2332
+ );
2333
+ const previousSubmissions = submissions?.filter(
2334
+ (submission) => Date.parse(submission.created_at) < fromMs
2335
+ );
2336
+ return json(
2337
+ aggregateAnalytics(events, previousEvents, { from, to }, currentSubmissions, previousSubmissions)
2338
+ );
1844
2339
  };
1845
2340
  const pruneEvents = async () => {
1846
2341
  const cutoff = new Date(Date.now() - analyticsRetentionDays * 864e5).toISOString();
@@ -1861,7 +2356,7 @@ function createCmsRoutes(options) {
1861
2356
  const cronPublishDue = async (request) => {
1862
2357
  if (hostedMemoryMode) return errors.forbidden();
1863
2358
  const bearer = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
1864
- let authorized = tokenEquals(bearer, syncToken || "") || tokenEquals(bearer, process.env.CRON_SECRET || "");
2359
+ let authorized = tokenEquals(bearer, cronToken || "");
1865
2360
  if (!authorized) {
1866
2361
  const user = await resolveUser(request, db());
1867
2362
  authorized = Boolean(user && can(user, "pages.publish"));
@@ -2020,6 +2515,10 @@ function createCmsRoutes(options) {
2020
2515
  return errors.badRequest("You can only assign roles at or below your own.");
2021
2516
  }
2022
2517
  }
2518
+ if (body.role !== void 0 && body.role !== membership.role) {
2519
+ const revokeError = await revokePreviewGrants({ userId: canonicalUserId });
2520
+ if (revokeError) return errors.badRequest("Unable to revoke the user preview grants.");
2521
+ }
2023
2522
  if (typeof body.password === "string") {
2024
2523
  if (body.password.length < 8) return errors.badRequest("Password must be at least 8 characters.");
2025
2524
  const { error: passwordError } = await db().auth.admin.updateUserById(canonicalUserId, {
@@ -2048,6 +2547,8 @@ function createCmsRoutes(options) {
2048
2547
  return errors.badRequest("You can't remove your own account.");
2049
2548
  }
2050
2549
  if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
2550
+ const revokeError = await revokePreviewGrants({ userId: canonicalUserId });
2551
+ if (revokeError) return errors.badRequest("Unable to revoke the user preview grants.");
2051
2552
  const { error: authError } = await db().auth.admin.deleteUser(canonicalUserId);
2052
2553
  if (authError) return errors.badRequest(authError.message);
2053
2554
  const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", canonicalUserId);
@@ -2064,10 +2565,30 @@ function createCmsRoutes(options) {
2064
2565
  authorized = Boolean(user && can(user, "sync.run"));
2065
2566
  }
2066
2567
  if (!authorized) return errors.forbidden();
2067
- const body = await readJson(request);
2568
+ const body = await readJsonLimited(request, { maxBytes: MAX_SYNC_BODY_BYTES, maxDepth: 32 });
2068
2569
  if (!body) return errors.badRequest("Invalid body.");
2069
- const result = await runContentSync(db(), registry, body);
2070
- await revalidateContent();
2570
+ const input = isRecord2(body.input) ? body.input : body;
2571
+ const mode = body.mode === "apply" ? "apply" : "dry-run";
2572
+ const expectedManifestHash = typeof body.expectedManifestHash === "string" ? body.expectedManifestHash : "";
2573
+ const expectedTargetReceiptHash = typeof body.expectedTargetReceiptHash === "string" ? body.expectedTargetReceiptHash : "";
2574
+ let result;
2575
+ try {
2576
+ result = mode === "apply" ? await runContentSync(db(), registry, input, {
2577
+ mode,
2578
+ expectedManifestHash,
2579
+ expectedProjectRef: previewProjectRef,
2580
+ expectedTargetReceiptHash
2581
+ }) : await runContentSync(db(), registry, input, {
2582
+ mode,
2583
+ expectedProjectRef: previewProjectRef
2584
+ });
2585
+ } catch (error) {
2586
+ return errors.conflict(error instanceof Error ? error.message : "Sync manifest conflict.");
2587
+ }
2588
+ if (result.mode === "apply") {
2589
+ await logActivity(null, "sync.apply", result.manifestHash);
2590
+ await revalidateContent();
2591
+ }
2071
2592
  return json({ success: true, ...result });
2072
2593
  };
2073
2594
  const dispatch = async (request, context) => {
@@ -2091,6 +2612,8 @@ function createCmsRoutes(options) {
2091
2612
  return duplicatePage(request, second);
2092
2613
  } else if (third === "preview" && method === "POST") {
2093
2614
  return previewToken(request, second);
2615
+ } else if (third === "preview" && method === "DELETE") {
2616
+ return revokePagePreviews(request, second);
2094
2617
  } else if (third === "versions" && method === "GET") {
2095
2618
  return listVersions(request, second);
2096
2619
  }
@@ -2465,10 +2988,11 @@ function runRpc(store, fn, args = {}) {
2465
2988
  const key = String(args.p_key);
2466
2989
  let row = globals.find((r) => r.key === key);
2467
2990
  if (!row) {
2468
- row = { key, label: "", data: args.p_data ?? {}, updated_at: nowIso() };
2991
+ row = { key, label: "", data: args.p_data ?? {}, builder_owned: true, updated_at: nowIso() };
2469
2992
  globals.push(row);
2470
2993
  } else {
2471
2994
  row.data = args.p_data ?? {};
2995
+ row.builder_owned = true;
2472
2996
  row.updated_at = nowIso();
2473
2997
  }
2474
2998
  globalVersions.push({
@@ -2513,6 +3037,55 @@ function runRpc(store, fn, args = {}) {
2513
3037
  store.setTable("cms_rate_limits", kept);
2514
3038
  return { data: limits.length - kept.length, error: null };
2515
3039
  }
3040
+ case "cms_consume_preview_grant": {
3041
+ const grant = store.table("cms_preview_grants").find(
3042
+ (row) => row.id === args.p_grant_id && row.page_id === args.p_page_id && row.user_id === args.p_user_id && row.project_ref === args.p_project_ref && row.key_id === args.p_key_id
3043
+ );
3044
+ if (!grant || grant.revoked_at) return { data: false, error: null };
3045
+ const expiresAt = typeof grant.expires_at === "string" ? Date.parse(grant.expires_at) : NaN;
3046
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return { data: false, error: null };
3047
+ const useCount = typeof grant.use_count === "number" ? grant.use_count : 0;
3048
+ const maxUses = typeof grant.max_uses === "number" ? grant.max_uses : 0;
3049
+ if (useCount >= maxUses) return { data: false, error: null };
3050
+ grant.use_count = useCount + 1;
3051
+ grant.last_used_at = nowIso();
3052
+ return { data: true, error: null };
3053
+ }
3054
+ case "cms_begin_sync": {
3055
+ const runs = store.table("cms_sync_runs");
3056
+ const requested = runs.find((row) => row.manifest_hash === args.p_manifest_hash);
3057
+ if (requested?.status === "complete") {
3058
+ return { data: { status: "complete", result: requested.result }, error: null };
3059
+ }
3060
+ if (requested?.status === "active") return { data: { status: "busy" }, error: null };
3061
+ const active = runs.find(
3062
+ (row) => row.status === "active" && row.manifest_hash !== args.p_manifest_hash
3063
+ );
3064
+ if (active) return { data: { status: "busy" }, error: null };
3065
+ if (requested) {
3066
+ requested.status = "active";
3067
+ requested.target_receipt_hash = args.p_target_receipt_hash;
3068
+ requested.updated_at = nowIso();
3069
+ return { data: { status: "resume" }, error: null };
3070
+ }
3071
+ runs.push({
3072
+ manifest_hash: args.p_manifest_hash,
3073
+ target_receipt_hash: args.p_target_receipt_hash,
3074
+ status: "active",
3075
+ started_at: nowIso(),
3076
+ updated_at: nowIso()
3077
+ });
3078
+ return { data: { status: "started" }, error: null };
3079
+ }
3080
+ case "cms_finish_sync": {
3081
+ const run = store.table("cms_sync_runs").find((row) => row.manifest_hash === args.p_manifest_hash);
3082
+ if (!run || run.status !== "active") return { data: false, error: null };
3083
+ run.status = args.p_status;
3084
+ run.result = args.p_status === "complete" ? args.p_result : null;
3085
+ run.error = args.p_status === "failed" ? args.p_error : null;
3086
+ run.updated_at = nowIso();
3087
+ return { data: true, error: null };
3088
+ }
2516
3089
  case "cms_publish_due_pages": {
2517
3090
  const nowMs = Date.now();
2518
3091
  const published = [];
@@ -2591,6 +3164,90 @@ function runRpc(store, fn, args = {}) {
2591
3164
  }
2592
3165
  return { data: page, error: null };
2593
3166
  }
3167
+ case "cms_sync_global": {
3168
+ const globals = store.table("cms_globals");
3169
+ const key = String(args.p_key);
3170
+ let row = globals.find((item) => item.key === key);
3171
+ if (row?.builder_owned) return { data: false, error: null };
3172
+ if (!row) {
3173
+ row = { key, label: "", data: args.p_data ?? {}, builder_owned: false, updated_at: nowIso() };
3174
+ globals.push(row);
3175
+ } else {
3176
+ row.data = args.p_data ?? {};
3177
+ row.updated_at = nowIso();
3178
+ }
3179
+ return { data: true, error: null };
3180
+ }
3181
+ case "cms_sync_form": {
3182
+ const forms = store.table("cms_forms");
3183
+ const slug = String(args.p_slug);
3184
+ let row = forms.find((item) => item.slug === slug);
3185
+ if (row?.builder_owned) return { data: false, error: null };
3186
+ if (!row) {
3187
+ row = {
3188
+ id: newId(),
3189
+ slug,
3190
+ title: args.p_title ?? slug,
3191
+ config: args.p_config ?? {},
3192
+ notify: {},
3193
+ success_message: args.p_success_message ?? "",
3194
+ builder_owned: false,
3195
+ created_at: nowIso(),
3196
+ updated_at: nowIso()
3197
+ };
3198
+ forms.push(row);
3199
+ } else {
3200
+ row.title = args.p_title ?? slug;
3201
+ row.config = args.p_config ?? {};
3202
+ row.success_message = args.p_success_message ?? "";
3203
+ row.updated_at = nowIso();
3204
+ }
3205
+ return { data: true, error: null };
3206
+ }
3207
+ case "cms_sync_media": {
3208
+ const media = store.table("cms_media");
3209
+ const storagePath = String(args.p_storage_path);
3210
+ let row = media.find((item) => item.storage_path === storagePath);
3211
+ if (row?.builder_owned) return { data: false, error: null };
3212
+ const values = {
3213
+ storage_path: storagePath,
3214
+ filename: args.p_filename,
3215
+ alt: args.p_alt,
3216
+ caption: args.p_caption,
3217
+ mime_type: args.p_mime_type,
3218
+ width: args.p_width,
3219
+ height: args.p_height,
3220
+ filesize: args.p_filesize,
3221
+ builder_owned: false,
3222
+ updated_at: nowIso()
3223
+ };
3224
+ if (!row) {
3225
+ row = { id: newId(), created_at: nowIso(), ...values };
3226
+ media.push(row);
3227
+ } else {
3228
+ Object.assign(row, values);
3229
+ }
3230
+ return { data: true, error: null };
3231
+ }
3232
+ case "cms_sync_redirect": {
3233
+ const redirects = store.table("cms_redirects");
3234
+ const fromPath = String(args.p_from_path);
3235
+ let row = redirects.find((item) => item.from_path === fromPath);
3236
+ if (row?.builder_owned) return { data: false, error: null };
3237
+ const values = {
3238
+ from_path: fromPath,
3239
+ to_path: args.p_to_path,
3240
+ permanent: args.p_permanent,
3241
+ builder_owned: false
3242
+ };
3243
+ if (!row) {
3244
+ row = { id: newId(), created_at: nowIso(), ...values };
3245
+ redirects.push(row);
3246
+ } else {
3247
+ Object.assign(row, values);
3248
+ }
3249
+ return { data: true, error: null };
3250
+ }
2594
3251
  default:
2595
3252
  return { data: null, error: { message: `unknown function ${fn}` } };
2596
3253
  }
@@ -2646,6 +3303,7 @@ export {
2646
3303
  CONTENT_CACHE_TAG,
2647
3304
  MEMORY_DEV_TOKEN,
2648
3305
  PREVIEW_SESSION_COOKIE,
3306
+ PREVIEW_TOKEN_MAX_USES,
2649
3307
  PREVIEW_TOKEN_TTL_MS,
2650
3308
  aggregateAnalytics,
2651
3309
  can,
@@ -2669,6 +3327,8 @@ export {
2669
3327
  runContentSync,
2670
3328
  sessionKeyFor,
2671
3329
  setServiceClientForTesting,
3330
+ validateCmsEnv,
3331
+ verifyPreviewGrantToken,
2672
3332
  verifyPreviewToken,
2673
3333
  visitorKeyFor
2674
3334
  };