@orion-studios/cms 0.5.5 → 0.5.7

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,14 +1,16 @@
1
+ import {
2
+ CONTENT_CACHE_TAG
3
+ } from "../chunk-NSAZCP4I.js";
1
4
  import {
2
5
  createMemoryRateLimitStore,
6
+ getAutoReplyEmailFields,
3
7
  isOriginAllowed,
4
- processSubmission
5
- } from "../chunk-LQTXMLKZ.js";
6
- import {
7
- CONTENT_CACHE_TAG
8
- } from "../chunk-AYV6KYDP.js";
8
+ processSubmission,
9
+ resolveAutoReplyEmailField
10
+ } from "../chunk-CFZP7674.js";
9
11
 
10
12
  // src/server/routes.ts
11
- import { createHash as createHash2 } from "crypto";
13
+ import { createHash as createHash3, createHmac as createHmac3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
12
14
 
13
15
  // src/analytics/aggregate.ts
14
16
  var CONVERSION_NAMES = /* @__PURE__ */ new Set(["call", "email"]);
@@ -340,12 +342,16 @@ ${rendered}
340
342
  }
341
343
  return lines.join("\n");
342
344
  }
345
+ function resolveSubmitterEmail(config, data) {
346
+ const fieldName = resolveAutoReplyEmailField(config);
347
+ if (!fieldName) return void 0;
348
+ const value = data[fieldName];
349
+ return isEmail(value) ? value.trim().toLowerCase() : void 0;
350
+ }
343
351
  async function notifySubmission(args) {
344
352
  const notify = args.config.notify || {};
345
353
  const recipients = (notify.emails || []).filter(isEmail);
346
- const submitterEmail = Object.entries(args.data).find(
347
- ([key, value]) => key.toLowerCase().includes("email") && isEmail(value)
348
- )?.[1];
354
+ const submitterEmail = resolveSubmitterEmail(args.config, args.data);
349
355
  const subject = (notify.subject || "New {form} submission").replace(
350
356
  /\{form\}/g,
351
357
  args.formTitle || "form"
@@ -362,7 +368,7 @@ async function notifySubmission(args) {
362
368
  console.error("[orion-cms] submission notification failed:", error);
363
369
  }
364
370
  }
365
- if (notify.autoReply && submitterEmail) {
371
+ if (notify.autoReply === true && submitterEmail && args.autoReplyAllowed === true) {
366
372
  try {
367
373
  await args.sendEmail({
368
374
  to: [submitterEmail],
@@ -431,30 +437,62 @@ var encode = (value) => Buffer.from(value, "utf8").toString("base64url");
431
437
  var decode = (value) => Buffer.from(value, "base64url").toString("utf8");
432
438
  var sign = (payload, secret) => createHmac2("sha256", secret).update(payload).digest("base64url");
433
439
  var PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1e3;
434
- function createPreviewToken(pageId, secret, ttlMs = PREVIEW_TOKEN_TTL_MS) {
435
- const payload = encode(`${pageId}|${Date.now() + ttlMs}`);
436
- return `${payload}.${sign(payload, secret)}`;
440
+ var PREVIEW_TOKEN_MAX_USES = 8;
441
+ var PREVIEW_SESSION_COOKIE = "orion_preview_session";
442
+ var keyRing = (keys) => typeof keys === "string" ? { active: { id: "legacy", secret: keys } } : keys;
443
+ function createPreviewToken(grant, keys, ttlMs = PREVIEW_TOKEN_TTL_MS) {
444
+ const active = keyRing(keys).active;
445
+ const issuedAt = Date.now();
446
+ const payload = encode(JSON.stringify({
447
+ version: 1,
448
+ audience: "cms-preview",
449
+ operation: "read-draft",
450
+ keyId: active.id,
451
+ ...grant,
452
+ issuedAt,
453
+ expiresAt: issuedAt + Math.min(Math.max(ttlMs, 1), PREVIEW_TOKEN_TTL_MS)
454
+ }));
455
+ return `${payload}.${sign(payload, active.secret)}`;
437
456
  }
438
- function verifyPreviewToken(token, secret) {
439
- const [payload, signature] = token.split(".");
457
+ function verifyPreviewGrantToken(token, keys, now = Date.now()) {
458
+ const segments = token.split(".");
459
+ if (segments.length !== 2) return null;
460
+ const [payload, signature] = segments;
440
461
  if (!payload || !signature) return null;
441
- const expected = sign(payload, secret);
442
- const expectedBuffer = Buffer.from(expected);
443
- const actualBuffer = Buffer.from(signature);
444
- if (expectedBuffer.length !== actualBuffer.length) return null;
445
- if (!timingSafeEqual(expectedBuffer, actualBuffer)) return null;
446
462
  try {
447
- const [pageId, expiresAt] = decode(payload).split("|");
448
- if (!pageId || Number(expiresAt) < Date.now()) return null;
449
- return pageId;
463
+ const claims = JSON.parse(decode(payload));
464
+ 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) {
465
+ return null;
466
+ }
467
+ const configured = keyRing(keys);
468
+ 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;
469
+ if (!signingKey) return null;
470
+ const expectedBuffer = Buffer.from(sign(payload, signingKey.secret));
471
+ const actualBuffer = Buffer.from(signature);
472
+ if (expectedBuffer.length !== actualBuffer.length) return null;
473
+ if (!timingSafeEqual(expectedBuffer, actualBuffer)) return null;
474
+ return claims;
450
475
  } catch {
451
476
  return null;
452
477
  }
453
478
  }
454
- async function getPreviewPage(client, token, secret) {
455
- const pageId = verifyPreviewToken(token, secret);
456
- if (!pageId) return null;
457
- const { data } = await client.from("cms_pages").select("id, slug, path, title, seo, draft_layout").eq("id", pageId).maybeSingle();
479
+ function verifyPreviewToken(token, keys) {
480
+ return verifyPreviewGrantToken(token, keys)?.pageId ?? null;
481
+ }
482
+ async function getPreviewPage(client, token, keys, expectedProjectRef) {
483
+ const configured = keyRing(keys);
484
+ if (!configured.active.id || !configured.active.secret || !expectedProjectRef) return null;
485
+ const claims = verifyPreviewGrantToken(token, configured);
486
+ if (!claims || claims.projectRef !== expectedProjectRef) return null;
487
+ const { data: consumed, error: consumeError } = await client.rpc("cms_consume_preview_grant", {
488
+ p_grant_id: claims.grantId,
489
+ p_page_id: claims.pageId,
490
+ p_user_id: claims.userId,
491
+ p_project_ref: claims.projectRef,
492
+ p_key_id: claims.keyId
493
+ });
494
+ if (consumeError || consumed !== true) return null;
495
+ const { data } = await client.from("cms_pages").select("id, slug, path, title, seo, draft_layout").eq("id", claims.pageId).maybeSingle();
458
496
  if (!data) return null;
459
497
  return {
460
498
  id: String(data.id),
@@ -468,20 +506,67 @@ async function getPreviewPage(client, token, secret) {
468
506
 
469
507
  // src/server/supabase.ts
470
508
  import { createClient } from "@supabase/supabase-js";
509
+ var projectRefFromUrl = (supabaseUrl) => {
510
+ let parsed;
511
+ try {
512
+ parsed = new URL(supabaseUrl);
513
+ } catch {
514
+ throw new Error("Orion CMS: the Supabase URL is invalid.");
515
+ }
516
+ const match = /^([a-z0-9]{20})\.supabase\.co$/.exec(parsed.hostname);
517
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port || parsed.pathname !== "/" && parsed.pathname !== "" || parsed.search || parsed.hash || !match) {
518
+ throw new Error("Orion CMS: the standard HTTPS Supabase project URL is required.");
519
+ }
520
+ return match[1];
521
+ };
522
+ var legacyServiceKeyClaims = (key) => {
523
+ const segments = key.split(".");
524
+ if (segments.length !== 3) return null;
525
+ try {
526
+ const payload = Buffer.from(segments[1] || "", "base64url").toString("utf8");
527
+ const claims = JSON.parse(payload);
528
+ return claims && typeof claims === "object" && !Array.isArray(claims) ? claims : null;
529
+ } catch {
530
+ return null;
531
+ }
532
+ };
533
+ function validateCmsEnv(env) {
534
+ if (!/^[a-z0-9]{20}$/.test(env.expectedProjectRef)) {
535
+ throw new Error("Orion CMS: CMS_EXPECTED_SUPABASE_PROJECT_REF must be set.");
536
+ }
537
+ if (projectRefFromUrl(env.supabaseUrl) !== env.expectedProjectRef) {
538
+ throw new Error("Orion CMS: the Supabase URL does not match the expected project.");
539
+ }
540
+ if (env.serviceRoleKey.startsWith("sb_publishable_")) {
541
+ throw new Error("Orion CMS: a publishable key cannot be used as the server credential.");
542
+ }
543
+ if (!env.serviceRoleKey.startsWith("sb_secret_")) {
544
+ const claims = legacyServiceKeyClaims(env.serviceRoleKey);
545
+ if (!claims || claims.role !== "service_role") {
546
+ throw new Error("Orion CMS: a Supabase secret or legacy service-role key is required.");
547
+ }
548
+ if (claims.ref !== env.expectedProjectRef) {
549
+ throw new Error("Orion CMS: the legacy service-role key does not match the expected project.");
550
+ }
551
+ }
552
+ return env;
553
+ }
471
554
  function readCmsEnv() {
472
555
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || "";
473
- const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY || "";
556
+ const serviceRoleKey = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || "";
557
+ const expectedProjectRef = process.env.CMS_EXPECTED_SUPABASE_PROJECT_REF || "";
474
558
  if (!supabaseUrl || !serviceRoleKey) {
475
559
  throw new Error(
476
- "Orion CMS: NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (or SUPABASE_SECRET_KEY) must be set."
560
+ "Orion CMS: NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SECRET_KEY (or legacy SUPABASE_SERVICE_ROLE_KEY) must be set."
477
561
  );
478
562
  }
479
- return { supabaseUrl, serviceRoleKey };
563
+ return validateCmsEnv({ expectedProjectRef, supabaseUrl, serviceRoleKey });
480
564
  }
481
565
  var serviceClient = null;
482
566
  function getServiceClient(env = readCmsEnv()) {
483
567
  if (!serviceClient) {
484
- serviceClient = createClient(env.supabaseUrl, env.serviceRoleKey, {
568
+ const validated = validateCmsEnv(env);
569
+ serviceClient = createClient(validated.supabaseUrl, validated.serviceRoleKey, {
485
570
  auth: { persistSession: false, autoRefreshToken: false }
486
571
  });
487
572
  }
@@ -507,12 +592,19 @@ async function resolveUser(request, client = getServiceClient()) {
507
592
  }
508
593
 
509
594
  // src/server/sync.ts
595
+ import { createHash as createHash2 } from "crypto";
510
596
  var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
511
597
  var publicFormConfig = (value) => {
512
598
  const config = isRecord(value) ? { ...value } : {};
513
599
  delete config.notify;
514
600
  return config;
515
601
  };
602
+ var canonicalize = (value) => {
603
+ if (Array.isArray(value)) return value.map(canonicalize);
604
+ if (!isRecord(value)) return value;
605
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
606
+ };
607
+ var sameValue = (left, right) => JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
516
608
  var mimeByExtension = {
517
609
  avif: "image/avif",
518
610
  gif: "image/gif",
@@ -525,22 +617,15 @@ var mimeByExtension = {
525
617
  webp: "image/webp"
526
618
  };
527
619
  var cleanPath = (src) => src.split("?")[0]?.split("#")[0] || src;
528
- var filenameFromPath = (path) => {
529
- const clean = cleanPath(path);
530
- return clean.split("/").filter(Boolean).pop() || clean;
531
- };
532
- var mimeFromPath = (path) => {
533
- const extension = filenameFromPath(path).split(".").pop()?.toLowerCase() || "";
534
- return mimeByExtension[extension] || "";
535
- };
620
+ var filenameFromPath = (path) => cleanPath(path).split("/").filter(Boolean).pop() || cleanPath(path);
621
+ var mimeFromPath = (path) => mimeByExtension[filenameFromPath(path).split(".").pop()?.toLowerCase() || ""] || "";
536
622
  var shouldIndexMediaPath = (src) => Boolean(src) && !src.startsWith("data:") && !/^https?:\/\//i.test(src);
537
623
  var toSyncMedia = (value) => {
538
624
  const src = typeof value.src === "string" ? value.src.trim() : "";
539
625
  if (!shouldIndexMediaPath(src)) return null;
540
- const filename = typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src);
541
626
  return {
542
627
  storagePath: src,
543
- filename,
628
+ filename: typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src),
544
629
  alt: typeof value.alt === "string" ? value.alt : "",
545
630
  caption: typeof value.caption === "string" ? value.caption : "",
546
631
  mimeType: mimeFromPath(src)
@@ -573,83 +658,331 @@ var normalizeManualMedia = (item) => {
573
658
  filesize: typeof item.filesize === "number" ? item.filesize : null
574
659
  };
575
660
  };
576
- async function runContentSync(client, registry, input) {
577
- const pages = input.pages || [];
578
- const globals = input.globals || [];
579
- const forms = input.forms || [];
661
+ var prepareSync = (registry, input) => {
662
+ const pages = [];
580
663
  const media = /* @__PURE__ */ new Map();
581
- const synced = [];
582
664
  const skipped = [];
583
- for (const page of pages) {
665
+ for (const page of input.pages || []) {
584
666
  if (!page || typeof page.slug !== "string") continue;
585
667
  const validated = registry.validateLayout(page.layout ?? []);
586
668
  if (!validated.ok) {
587
669
  skipped.push({ slug: page.slug, issues: validated.issues });
588
670
  continue;
589
671
  }
590
- const { error } = await client.rpc("cms_sync_page", {
591
- p_slug: page.slug,
592
- p_path: typeof page.path === "string" ? page.path : page.slug === "home" ? "/" : `/${page.slug}`,
593
- p_title: typeof page.title === "string" ? page.title : page.slug,
594
- p_seo: isRecord(page.seo) ? page.seo : {},
595
- p_layout: validated.layout
596
- });
597
- if (error) {
598
- skipped.push({ slug: page.slug, issues: error.message });
599
- continue;
600
- }
601
672
  collectLayoutMedia(validated.layout, media);
602
- synced.push(page.slug);
673
+ pages.push({
674
+ slug: page.slug,
675
+ path: typeof page.path === "string" ? page.path : page.slug === "home" ? "/" : `/${page.slug}`,
676
+ title: typeof page.title === "string" ? page.title : page.slug,
677
+ seo: isRecord(page.seo) ? page.seo : {},
678
+ layout: validated.layout
679
+ });
603
680
  }
604
681
  for (const item of input.media || []) {
605
682
  const normalized = normalizeManualMedia(item);
606
683
  if (normalized && !media.has(normalized.storagePath)) media.set(normalized.storagePath, normalized);
607
684
  }
608
- let syncedMedia = 0;
609
- for (const item of media.values()) {
610
- const { data: existing, error: readError } = await client.from("cms_media").select("id").eq("storage_path", item.storagePath).maybeSingle();
611
- if (readError || existing) continue;
612
- const { error: insertError } = await client.from("cms_media").insert({
613
- storage_path: item.storagePath,
614
- filename: item.filename || filenameFromPath(item.storagePath),
615
- alt: item.alt || "",
616
- caption: item.caption || "",
617
- mime_type: item.mimeType || mimeFromPath(item.storagePath),
618
- width: item.width ?? null,
619
- height: item.height ?? null,
620
- filesize: item.filesize ?? null
621
- });
622
- if (!insertError) syncedMedia += 1;
623
- }
624
- const failed = [];
625
- let syncedGlobals = 0;
626
- for (const global of globals) {
627
- if (!global || typeof global.key !== "string" || !isRecord(global.data)) continue;
628
- const { error } = await client.from("cms_globals").upsert({ key: global.key, data: global.data, updated_at: (/* @__PURE__ */ new Date()).toISOString() });
629
- if (error) failed.push({ kind: "global", key: global.key, error: error.message });
630
- else syncedGlobals += 1;
631
- }
632
- let syncedForms = 0;
633
- for (const form of forms) {
634
- if (!form || typeof form.slug !== "string") continue;
635
- const { error } = await client.from("cms_forms").upsert(
685
+ return {
686
+ pages,
687
+ globals: (input.globals || []).filter((item) => Boolean(item && typeof item.key === "string" && isRecord(item.data))),
688
+ forms: (input.forms || []).filter((item) => Boolean(item && typeof item.slug === "string")).map((form) => ({
689
+ slug: form.slug,
690
+ title: typeof form.title === "string" ? form.title : form.slug,
691
+ config: publicFormConfig(form.config),
692
+ successMessage: typeof form.successMessage === "string" ? form.successMessage : ""
693
+ })),
694
+ media: [...media.values()],
695
+ redirects: (input.redirects || []).filter((item) => Boolean(
696
+ item && typeof item.fromPath === "string" && typeof item.toPath === "string"
697
+ )).map((item) => ({
698
+ fromPath: item.fromPath,
699
+ toPath: item.toPath,
700
+ permanent: item.permanent !== false
701
+ })),
702
+ skipped
703
+ };
704
+ };
705
+ var readOne = async (client, table, columns, key, value) => {
706
+ const { data, error } = await client.from(table).select(columns).eq(key, value).maybeSingle();
707
+ if (error) throw new Error(error.message);
708
+ return data;
709
+ };
710
+ var readMany = async (client, table, columns) => {
711
+ const { data, error } = await client.from(table).select(columns);
712
+ if (error) throw new Error(error.message);
713
+ return data || [];
714
+ };
715
+ var targetReceiptHash = async (client, expectedProjectRef = "") => {
716
+ if (expectedProjectRef === "memory") {
717
+ return createHash2("sha256").update("memory:uninitialized").digest("hex");
718
+ }
719
+ const marker = await readOne(client, "cms_target_identity", "project_ref", "singleton", true);
720
+ const projectRef = typeof marker?.project_ref === "string" ? marker.project_ref : "";
721
+ if (!expectedProjectRef && !projectRef) {
722
+ return createHash2("sha256").update("unbound-local:uninitialized").digest("hex");
723
+ }
724
+ if (!projectRef || projectRef !== expectedProjectRef) {
725
+ throw new Error("Sync target identity does not match the expected CMS project.");
726
+ }
727
+ const migrations = await readMany(client, "cms_migrations", "version");
728
+ const migrationHead = migrations.map((row) => String(row.version)).sort().at(-1) || "empty";
729
+ return createHash2("sha256").update(JSON.stringify(canonicalize({
730
+ migrationHead,
731
+ projectRefHash: createHash2("sha256").update(projectRef).digest("hex").slice(0, 16)
732
+ }))).digest("hex");
733
+ };
734
+ async function buildPlan(client, registry, input, expectedProjectRef = "") {
735
+ const prepared = prepareSync(registry, input);
736
+ const operations = [];
737
+ for (const page of prepared.pages) {
738
+ const current = await readOne(client, "cms_pages", "slug, path, title, seo, draft_layout, builder_owned", "slug", page.slug);
739
+ const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
740
+ { path: current.path, title: current.title, seo: current.seo, layout: current.draft_layout },
741
+ { path: page.path, title: page.title, seo: page.seo, layout: page.layout }
742
+ ) ? "noop" : "update";
743
+ operations.push({ kind: "page", key: page.slug, action });
744
+ }
745
+ for (const global of prepared.globals) {
746
+ const current = await readOne(client, "cms_globals", "key, data, builder_owned", "key", global.key);
747
+ const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(current.data, global.data) ? "noop" : "update";
748
+ operations.push({ kind: "global", key: global.key, action });
749
+ }
750
+ for (const form of prepared.forms) {
751
+ const current = await readOne(client, "cms_forms", "slug, title, config, success_message, builder_owned", "slug", form.slug);
752
+ const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
753
+ { title: current.title, config: current.config, successMessage: current.success_message },
754
+ { title: form.title, config: form.config, successMessage: form.successMessage }
755
+ ) ? "noop" : "update";
756
+ operations.push({ kind: "form", key: form.slug, action });
757
+ }
758
+ for (const item of prepared.media) {
759
+ const current = await readOne(client, "cms_media", "id, storage_path, filename, alt, caption, mime_type, width, height, filesize, builder_owned", "storage_path", item.storagePath);
760
+ const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
636
761
  {
637
- slug: form.slug,
638
- title: typeof form.title === "string" ? form.title : form.slug,
639
- config: publicFormConfig(form.config),
640
- success_message: typeof form.successMessage === "string" ? form.successMessage : "",
641
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
762
+ filename: current.filename,
763
+ alt: current.alt,
764
+ caption: current.caption,
765
+ mimeType: current.mime_type,
766
+ width: current.width,
767
+ height: current.height,
768
+ filesize: current.filesize
642
769
  },
643
- { onConflict: "slug" }
770
+ {
771
+ filename: item.filename,
772
+ alt: item.alt,
773
+ caption: item.caption,
774
+ mimeType: item.mimeType,
775
+ width: item.width ?? null,
776
+ height: item.height ?? null,
777
+ filesize: item.filesize ?? null
778
+ }
779
+ ) ? "noop" : "update";
780
+ operations.push({ kind: "media", key: item.storagePath, action });
781
+ }
782
+ for (const redirect of prepared.redirects) {
783
+ const current = await readOne(client, "cms_redirects", "from_path, to_path, permanent, builder_owned", "from_path", redirect.fromPath);
784
+ const action = !current ? "create" : current.builder_owned === true ? "conflict" : sameValue(
785
+ { toPath: current.to_path, permanent: current.permanent },
786
+ { toPath: redirect.toPath, permanent: redirect.permanent }
787
+ ) ? "noop" : "update";
788
+ operations.push({ kind: "redirect", key: redirect.fromPath, action });
789
+ }
790
+ const ownershipSets = /* @__PURE__ */ new Map([
791
+ ["page", new Set(prepared.pages.map((item) => item.slug))],
792
+ ["global", new Set(prepared.globals.map((item) => item.key))],
793
+ ["form", new Set(prepared.forms.map((item) => item.slug))],
794
+ ["media", new Set(prepared.media.map((item) => item.storagePath))],
795
+ ["redirect", new Set(prepared.redirects.map((item) => item.fromPath))]
796
+ ]);
797
+ const remoteOwned = [
798
+ ["page", "cms_pages", "slug"],
799
+ ["global", "cms_globals", "key"],
800
+ ["form", "cms_forms", "slug"],
801
+ ["media", "cms_media", "storage_path"],
802
+ ["redirect", "cms_redirects", "from_path"]
803
+ ];
804
+ for (const [kind, table, key] of remoteOwned) {
805
+ const rows = await readMany(client, table, `${key}, builder_owned`);
806
+ for (const row of rows) {
807
+ const value = String(row[key] ?? "");
808
+ if (row.builder_owned === false && value && !ownershipSets.get(kind)?.has(value)) {
809
+ operations.push({ kind, key: value, action: "blocked-delete" });
810
+ }
811
+ }
812
+ }
813
+ const kindOrder = {
814
+ page: 0,
815
+ global: 1,
816
+ form: 2,
817
+ media: 3,
818
+ redirect: 4
819
+ };
820
+ operations.sort(
821
+ (left, right) => kindOrder[left.kind] - kindOrder[right.kind] || left.key.localeCompare(right.key)
822
+ );
823
+ const receiptHash = await targetReceiptHash(client, expectedProjectRef);
824
+ const manifestHash = createHash2("sha256").update(JSON.stringify(canonicalize({
825
+ input: prepared,
826
+ targetReceiptHash: receiptHash,
827
+ version: 1
828
+ }))).digest("hex");
829
+ return {
830
+ prepared,
831
+ plan: {
832
+ mode: "dry-run",
833
+ manifestHash,
834
+ targetReceiptHash: receiptHash,
835
+ operations,
836
+ skipped: prepared.skipped
837
+ }
838
+ };
839
+ }
840
+ async function runContentSync(client, registry, input, options = { mode: "dry-run" }) {
841
+ if (options.mode === "apply") {
842
+ const currentTargetReceiptHash = await targetReceiptHash(client, options.expectedProjectRef);
843
+ if (!options.expectedTargetReceiptHash || options.expectedTargetReceiptHash !== currentTargetReceiptHash) {
844
+ throw new Error("Sync target receipt changed. Run a new dry run for the current CMS target.");
845
+ }
846
+ const completed = await readOne(
847
+ client,
848
+ "cms_sync_runs",
849
+ "manifest_hash, target_receipt_hash, status, result",
850
+ "manifest_hash",
851
+ options.expectedManifestHash
644
852
  );
645
- if (error) failed.push({ kind: "form", key: form.slug, error: error.message });
646
- else syncedForms += 1;
853
+ if (completed?.status === "complete" && completed.target_receipt_hash === currentTargetReceiptHash && isRecord(completed.result)) {
854
+ return completed.result;
855
+ }
856
+ }
857
+ const { prepared, plan } = await buildPlan(client, registry, input, options.expectedProjectRef);
858
+ if (options.mode !== "apply") return plan;
859
+ if (!options.expectedManifestHash || options.expectedManifestHash !== plan.manifestHash) {
860
+ throw new Error("Sync manifest changed. Run a new dry run and apply its exact manifest hash.");
861
+ }
862
+ if (!options.expectedTargetReceiptHash || options.expectedTargetReceiptHash !== plan.targetReceiptHash) {
863
+ throw new Error("Sync target receipt changed. Run a new dry run for the current CMS target.");
864
+ }
865
+ const { data: lease, error: leaseError } = await client.rpc("cms_begin_sync", {
866
+ p_manifest_hash: plan.manifestHash,
867
+ p_target_receipt_hash: plan.targetReceiptHash
868
+ });
869
+ if (leaseError) throw new Error(leaseError.message);
870
+ const leaseResult = isRecord(lease) ? lease : {};
871
+ if (leaseResult.status === "busy") {
872
+ throw new Error("Another content sync is active for this CMS target.");
873
+ }
874
+ if (leaseResult.status === "complete" && isRecord(leaseResult.result)) {
875
+ return leaseResult.result;
876
+ }
877
+ if (!["started", "resume"].includes(String(leaseResult.status))) {
878
+ throw new Error("The CMS sync lease could not be acquired.");
879
+ }
880
+ try {
881
+ const synced = [];
882
+ const failed = [];
883
+ const conflicts = plan.operations.filter((operation) => operation.action === "conflict");
884
+ let globals = 0;
885
+ let forms = 0;
886
+ let media = 0;
887
+ for (const page of prepared.pages) {
888
+ const operation = plan.operations.find((item) => item.kind === "page" && item.key === page.slug);
889
+ if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
890
+ const { error } = await client.rpc("cms_sync_page", {
891
+ p_slug: page.slug,
892
+ p_path: page.path,
893
+ p_title: page.title,
894
+ p_seo: page.seo,
895
+ p_layout: page.layout
896
+ });
897
+ if (error) failed.push({ kind: "page", key: page.slug, error: error.message });
898
+ else synced.push(page.slug);
899
+ }
900
+ for (const global of prepared.globals) {
901
+ const operation = plan.operations.find((item) => item.kind === "global" && item.key === global.key);
902
+ if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
903
+ const { data, error } = await client.rpc("cms_sync_global", { p_key: global.key, p_data: global.data });
904
+ if (error) failed.push({ kind: "global", key: global.key, error: error.message });
905
+ else if (data === true) globals += 1;
906
+ else conflicts.push({ kind: "global", key: global.key, action: "conflict" });
907
+ }
908
+ for (const form of prepared.forms) {
909
+ const operation = plan.operations.find((item) => item.kind === "form" && item.key === form.slug);
910
+ if (!operation || operation.action === "conflict" || operation.action === "noop") continue;
911
+ const { data, error } = await client.rpc("cms_sync_form", {
912
+ p_slug: form.slug,
913
+ p_title: form.title,
914
+ p_config: form.config,
915
+ p_success_message: form.successMessage
916
+ });
917
+ if (error) failed.push({ kind: "form", key: form.slug, error: error.message });
918
+ else if (data === true) forms += 1;
919
+ else conflicts.push({ kind: "form", key: form.slug, action: "conflict" });
920
+ }
921
+ for (const item of prepared.media) {
922
+ const operation = plan.operations.find((entry) => entry.kind === "media" && entry.key === item.storagePath);
923
+ if (!operation || !["create", "update"].includes(operation.action)) continue;
924
+ const { data, error } = await client.rpc("cms_sync_media", {
925
+ p_storage_path: item.storagePath,
926
+ p_filename: item.filename || filenameFromPath(item.storagePath),
927
+ p_alt: item.alt || "",
928
+ p_caption: item.caption || "",
929
+ p_mime_type: item.mimeType || mimeFromPath(item.storagePath),
930
+ p_width: item.width ?? null,
931
+ p_height: item.height ?? null,
932
+ p_filesize: item.filesize ?? null
933
+ });
934
+ if (error) failed.push({ kind: "media", key: item.storagePath, error: error.message });
935
+ else if (data === true) media += 1;
936
+ else conflicts.push({ kind: "media", key: item.storagePath, action: "conflict" });
937
+ }
938
+ for (const redirect of prepared.redirects) {
939
+ const operation = plan.operations.find((entry) => entry.kind === "redirect" && entry.key === redirect.fromPath);
940
+ if (!operation || !["create", "update"].includes(operation.action)) continue;
941
+ const { data, error } = await client.rpc("cms_sync_redirect", {
942
+ p_from_path: redirect.fromPath,
943
+ p_to_path: redirect.toPath,
944
+ p_permanent: redirect.permanent
945
+ });
946
+ if (error) failed.push({ kind: "redirect", key: redirect.fromPath, error: error.message });
947
+ else if (data !== true) conflicts.push({ kind: "redirect", key: redirect.fromPath, action: "conflict" });
948
+ }
949
+ const result = {
950
+ mode: "apply",
951
+ manifestHash: plan.manifestHash,
952
+ targetReceiptHash: plan.targetReceiptHash,
953
+ synced,
954
+ skipped: plan.skipped,
955
+ globals,
956
+ forms,
957
+ media,
958
+ failed,
959
+ conflicts
960
+ };
961
+ const completionStatus = failed.length === 0 ? "complete" : "failed";
962
+ const completionError = completionStatus === "failed" ? `${failed.length} content sync operation${failed.length === 1 ? "" : "s"} failed.` : null;
963
+ const { data: finished, error: finishError } = await client.rpc("cms_finish_sync", {
964
+ p_manifest_hash: plan.manifestHash,
965
+ p_status: completionStatus,
966
+ p_result: completionStatus === "complete" ? result : null,
967
+ p_error: completionError
968
+ });
969
+ if (finishError || finished !== true) {
970
+ throw new Error(finishError?.message || "The CMS sync completion receipt was not recorded.");
971
+ }
972
+ if (completionError) throw new Error(completionError);
973
+ return result;
974
+ } catch (error) {
975
+ await client.rpc("cms_finish_sync", {
976
+ p_manifest_hash: plan.manifestHash,
977
+ p_status: "failed",
978
+ p_result: null,
979
+ p_error: error instanceof Error ? error.message : "Content sync failed."
980
+ });
981
+ throw error;
647
982
  }
648
- return { synced, skipped, globals: syncedGlobals, forms: syncedForms, media: syncedMedia, failed };
649
983
  }
650
984
 
651
985
  // src/server/routes.ts
652
- import { timingSafeEqual as timingSafeEqual2 } from "crypto";
653
986
  var tokenEquals = (candidate, secret) => {
654
987
  if (!candidate || !secret) return false;
655
988
  const a = Buffer.from(candidate);
@@ -731,6 +1064,7 @@ var clientKey = (request) => {
731
1064
  };
732
1065
  var MAX_PUBLIC_BODY_BYTES = 64 * 1024;
733
1066
  var MAX_PUBLIC_BODY_DEPTH = 12;
1067
+ var MAX_SYNC_BODY_BYTES = 2 * 1024 * 1024;
734
1068
  function exceedsDepth(value, limit, depth = 0) {
735
1069
  if (depth > limit) return true;
736
1070
  if (Array.isArray(value)) return value.some((item) => exceedsDepth(item, limit, depth + 1));
@@ -738,13 +1072,38 @@ function exceedsDepth(value, limit, depth = 0) {
738
1072
  return false;
739
1073
  }
740
1074
  async function readJsonLimited(request, limits) {
741
- let text;
1075
+ const declaredLength = request.headers.get("content-length");
1076
+ if (declaredLength !== null) {
1077
+ if (!/^[0-9]+$/.test(declaredLength)) return null;
1078
+ const length = Number(declaredLength);
1079
+ if (!Number.isSafeInteger(length) || length > limits.maxBytes) return null;
1080
+ }
1081
+ if (!request.body) return null;
1082
+ const reader = request.body.getReader();
1083
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1084
+ let totalBytes = 0;
1085
+ let text = "";
742
1086
  try {
743
- text = await request.text();
1087
+ while (true) {
1088
+ const { done, value } = await reader.read();
1089
+ if (done) break;
1090
+ totalBytes += value.byteLength;
1091
+ if (totalBytes > limits.maxBytes) {
1092
+ await reader.cancel("CMS request body exceeded the configured byte limit.");
1093
+ return null;
1094
+ }
1095
+ text += decoder.decode(value, { stream: true });
1096
+ }
1097
+ text += decoder.decode();
744
1098
  } catch {
1099
+ try {
1100
+ await reader.cancel();
1101
+ } catch {
1102
+ }
745
1103
  return null;
1104
+ } finally {
1105
+ reader.releaseLock();
746
1106
  }
747
- if (text.length > limits.maxBytes) return null;
748
1107
  let parsed;
749
1108
  try {
750
1109
  parsed = JSON.parse(text);
@@ -755,7 +1114,7 @@ async function readJsonLimited(request, limits) {
755
1114
  if (exceedsDepth(parsed, limits.maxDepth)) return null;
756
1115
  return parsed;
757
1116
  }
758
- var hashedClientKey = (ip) => createHash2("sha256").update(ip).digest("hex").slice(0, 24);
1117
+ var hashedClientKey = (ip) => createHash3("sha256").update(ip).digest("hex").slice(0, 24);
759
1118
  var requestPagePath = (request) => {
760
1119
  const referrer = request.headers.get("referer");
761
1120
  if (!referrer) return "";
@@ -791,17 +1150,64 @@ function createDurableRateLimitStore(getClient, options) {
791
1150
  };
792
1151
  }
793
1152
  function createCmsRoutes(options) {
794
- const { registry, allowedOrigins, syncToken, knownGoodEmailDomains } = options;
1153
+ const { registry, allowedOrigins, syncToken, cronToken, knownGoodEmailDomains } = options;
795
1154
  const db = () => options.client ?? getServiceClient();
796
1155
  const hostedMemoryMode = options.memoryMode === true && process.env.NODE_ENV === "production";
797
1156
  const rateLimit = options.rateLimitStore === null ? null : options.rateLimitStore || (options.memoryMode ? createMemoryRateLimitStore() : createDurableRateLimitStore(db, { max: 5, windowMs: 6e4, bucket: "submit" }));
798
1157
  const sendEmail = hostedMemoryMode || options.sendEmail === null ? null : options.sendEmail || createResendSender();
799
1158
  const maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
800
- const previewSecret = () => {
801
- if (hostedMemoryMode) return "";
802
- return options.previewSecret || process.env.SUPABASE_SERVICE_ROLE_KEY || (options.memoryMode ? "orion-memory-preview-secret" : "");
803
- };
1159
+ const previewKeys = () => {
1160
+ if (hostedMemoryMode) return null;
1161
+ const activeSecret = options.previewSecret || (options.memoryMode ? "orion-memory-preview-secret" : "");
1162
+ const activeId = options.previewKeyId || (options.memoryMode ? "memory-v1" : "");
1163
+ if (!activeSecret || !activeId) return null;
1164
+ const previous = options.previewPreviousSecret && options.previewPreviousKeyId && options.previewPreviousValidUntil ? {
1165
+ id: options.previewPreviousKeyId,
1166
+ secret: options.previewPreviousSecret,
1167
+ acceptUntil: options.previewPreviousValidUntil
1168
+ } : void 0;
1169
+ return { active: { id: activeId, secret: activeSecret }, ...previous ? { previous } : {} };
1170
+ };
1171
+ const previewProjectRef = options.projectRef || (options.memoryMode ? "memory" : "");
1172
+ const analyticsSecret = options.analyticsSecret || (options.memoryMode ? "orion-memory-analytics-secret" : "");
1173
+ const autoReplyHashSecret = options.autoReplyHashSecret || (options.memoryMode ? "orion-memory-auto-reply-secret" : "");
804
1174
  const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
1175
+ const autoReplyLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
1176
+ const boundedAutoReplyMax = (name, fallback) => {
1177
+ const candidate = options.autoReplyLimits?.[name];
1178
+ return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate > 0 ? Math.min(candidate, fallback) : fallback;
1179
+ };
1180
+ const autoReplyLimits = {
1181
+ recipientPerHour: autoReplyLimit(
1182
+ boundedAutoReplyMax("recipientPerHour", 3),
1183
+ 36e5,
1184
+ "auto-reply-recipient-hour"
1185
+ ),
1186
+ sitePerMinute: autoReplyLimit(
1187
+ boundedAutoReplyMax("sitePerMinute", 10),
1188
+ 6e4,
1189
+ "auto-reply-site-minute"
1190
+ ),
1191
+ sitePerDay: autoReplyLimit(
1192
+ boundedAutoReplyMax("sitePerDay", 200),
1193
+ 864e5,
1194
+ "auto-reply-site-day"
1195
+ )
1196
+ };
1197
+ const autoReplyRecipientKey = (email) => createHmac3("sha256", autoReplyHashSecret).update(`recipient:${email}`).digest("hex").slice(0, 24);
1198
+ const maySendAutoReply = async (config, data) => {
1199
+ if (config.notify?.autoReply !== true) return false;
1200
+ const recipient = resolveSubmitterEmail(config, data);
1201
+ if (!recipient) return false;
1202
+ if (!autoReplyHashSecret) return false;
1203
+ const now = Date.now();
1204
+ if (await autoReplyLimits.recipientPerHour.isLimited(autoReplyRecipientKey(recipient), now)) {
1205
+ return false;
1206
+ }
1207
+ if (await autoReplyLimits.sitePerMinute.isLimited("all", now)) return false;
1208
+ if (await autoReplyLimits.sitePerDay.isLimited("all", now)) return false;
1209
+ return true;
1210
+ };
805
1211
  const boundedAnalyticsMax = (name, fallback) => {
806
1212
  const candidate = options.analyticsLimits?.[name];
807
1213
  return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate > 0 ? Math.min(candidate, fallback) : fallback;
@@ -845,8 +1251,8 @@ function createCmsRoutes(options) {
845
1251
  };
846
1252
  const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
847
1253
  const requestSessionKey = (request) => {
848
- const secret = previewSecret() || "orion-analytics";
849
- return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "", secret);
1254
+ if (!analyticsSecret) return "";
1255
+ return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "", analyticsSecret);
850
1256
  };
851
1257
  const guard = async (request, action) => {
852
1258
  if (hostedMemoryMode) return errors.unauthorized();
@@ -855,6 +1261,13 @@ function createCmsRoutes(options) {
855
1261
  if (!can(user, action)) return errors.forbidden();
856
1262
  return { user };
857
1263
  };
1264
+ const revokePreviewGrants = async (filter) => {
1265
+ let query = db().from("cms_preview_grants").update({ revoked_at: (/* @__PURE__ */ new Date()).toISOString() });
1266
+ if (filter.pageId) query = query.eq("page_id", filter.pageId);
1267
+ if (filter.userId) query = query.eq("user_id", filter.userId);
1268
+ const { error } = await query;
1269
+ return error?.message ?? null;
1270
+ };
858
1271
  const logActivity = async (user, action, subject) => {
859
1272
  try {
860
1273
  await db().from("cms_activity").insert({
@@ -974,6 +1387,7 @@ function createCmsRoutes(options) {
974
1387
  }
975
1388
  patch.path = path;
976
1389
  }
1390
+ patch.builder_owned = true;
977
1391
  if (Object.keys(patch).length > 0) {
978
1392
  const oldPath = String(current.path || "");
979
1393
  const wasPublished = current.status === "published";
@@ -987,7 +1401,12 @@ function createCmsRoutes(options) {
987
1401
  const newPath = typeof patch.path === "string" ? patch.path : oldPath;
988
1402
  if (newPath !== oldPath && wasPublished && oldPath !== "/") {
989
1403
  await db().from("cms_redirects").delete().eq("from_path", newPath);
990
- await db().from("cms_redirects").upsert({ from_path: oldPath, to_path: newPath, permanent: true }, { onConflict: "from_path" });
1404
+ await db().from("cms_redirects").upsert({
1405
+ from_path: oldPath,
1406
+ to_path: newPath,
1407
+ permanent: true,
1408
+ builder_owned: true
1409
+ }, { onConflict: "from_path" });
991
1410
  }
992
1411
  if (newPath !== oldPath) {
993
1412
  await logActivity(auth.user, "page.rename", `${oldPath} \u2192 ${newPath}`);
@@ -1047,6 +1466,8 @@ function createCmsRoutes(options) {
1047
1466
  if (!Number.isSafeInteger(expectedVersionId) || expectedVersionId <= 0) {
1048
1467
  return errors.conflict("Page version conflict: save the draft again before publishing.");
1049
1468
  }
1469
+ const revokeError = await revokePreviewGrants({ pageId: id });
1470
+ if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
1050
1471
  const { data, error } = await db().rpc("cms_publish_page", {
1051
1472
  p_page_id: id,
1052
1473
  p_expected_version_id: expectedVersionId,
@@ -1063,6 +1484,8 @@ function createCmsRoutes(options) {
1063
1484
  const unpublishPage = async (request, id) => {
1064
1485
  const auth = await guard(request, "pages.publish");
1065
1486
  if (auth instanceof Response) return auth;
1487
+ const revokeError = await revokePreviewGrants({ pageId: id });
1488
+ if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
1066
1489
  const { data, error } = await db().from("cms_pages").update({
1067
1490
  status: "draft",
1068
1491
  publish_at: null,
@@ -1121,32 +1544,46 @@ function createCmsRoutes(options) {
1121
1544
  const previewToken = async (request, id) => {
1122
1545
  const auth = await guard(request, "pages.read");
1123
1546
  if (auth instanceof Response) return auth;
1124
- const secret = previewSecret();
1125
- if (!secret) return errors.badRequest("Preview is not configured on this site.");
1547
+ const keys = previewKeys();
1548
+ if (!keys || !previewProjectRef) return errors.badRequest("Preview is not configured on this site.");
1126
1549
  const { data } = await db().from("cms_pages").select("id, path").eq("id", id).maybeSingle();
1127
1550
  if (!data) return errors.notFound();
1128
- const token = createPreviewToken(id, secret);
1129
- return json({ token, path: data.path, url: `${data.path}?preview=${encodeURIComponent(token)}` });
1130
- };
1131
- const previewPage = async (request) => {
1132
- if (hostedMemoryMode) return errors.notFound();
1133
- const secret = previewSecret();
1134
- if (!secret) return errors.notFound();
1135
- const token = new URL(request.url).searchParams.get("token") || "";
1136
- const pageId = verifyPreviewToken(token, secret);
1137
- if (!pageId) return errors.forbidden();
1138
- const { data } = await db().from("cms_pages").select("id, slug, path, title, seo, draft_layout").eq("id", pageId).maybeSingle();
1139
- if (!data) return errors.notFound();
1140
- return json({
1141
- page: {
1142
- id: data.id,
1143
- slug: data.slug,
1144
- path: data.path,
1145
- title: data.title,
1146
- seo: data.seo ?? {},
1147
- layout: data.draft_layout ?? []
1148
- }
1551
+ const grantId = randomUUID();
1552
+ const expiresAt = new Date(Date.now() + PREVIEW_TOKEN_TTL_MS).toISOString();
1553
+ const { error: grantError } = await db().from("cms_preview_grants").insert({
1554
+ id: grantId,
1555
+ page_id: id,
1556
+ user_id: auth.user.id,
1557
+ project_ref: previewProjectRef,
1558
+ key_id: keys.active.id,
1559
+ expires_at: expiresAt,
1560
+ max_uses: PREVIEW_TOKEN_MAX_USES
1149
1561
  });
1562
+ if (grantError) return errors.badRequest("Unable to create a preview grant.");
1563
+ const token = createPreviewToken({
1564
+ grantId,
1565
+ pageId: id,
1566
+ userId: auth.user.id,
1567
+ projectRef: previewProjectRef
1568
+ }, keys);
1569
+ const previewUrl = `/cms-preview/${encodeURIComponent(String(data.id))}`;
1570
+ const response = json({ path: data.path, url: previewUrl });
1571
+ const secure = process.env.NODE_ENV === "production" || new URL(request.url).protocol === "https:";
1572
+ response.headers.set("cache-control", "private, no-store");
1573
+ response.headers.set("referrer-policy", "no-referrer");
1574
+ response.headers.append(
1575
+ "set-cookie",
1576
+ `${PREVIEW_SESSION_COOKIE}=${token}; Path=${previewUrl}; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(PREVIEW_TOKEN_TTL_MS / 1e3)}${secure ? "; Secure" : ""}`
1577
+ );
1578
+ return response;
1579
+ };
1580
+ const revokePagePreviews = async (request, id) => {
1581
+ const auth = await guard(request, "pages.read");
1582
+ if (auth instanceof Response) return auth;
1583
+ const revokeError = await revokePreviewGrants({ pageId: id });
1584
+ if (revokeError) return errors.badRequest("Unable to revoke preview grants.");
1585
+ await logActivity(auth.user, "page.preview.revoke", id);
1586
+ return json({ success: true });
1150
1587
  };
1151
1588
  const listVersions = async (request, id) => {
1152
1589
  const auth = await guard(request, "pages.restore");
@@ -1174,6 +1611,10 @@ function createCmsRoutes(options) {
1174
1611
  const restoreVersion = async (request, versionId) => {
1175
1612
  const auth = await guard(request, "pages.restore");
1176
1613
  if (auth instanceof Response) return auth;
1614
+ const { data: version } = await db().from("cms_page_versions").select("page_id").eq("id", Number(versionId)).maybeSingle();
1615
+ if (!version) return errors.notFound();
1616
+ const revokeError = await revokePreviewGrants({ pageId: String(version.page_id) });
1617
+ if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
1177
1618
  const { data, error } = await db().rpc("cms_restore_page_version", {
1178
1619
  p_version_id: Number(versionId),
1179
1620
  p_actor: auth.user.id
@@ -1219,6 +1660,7 @@ function createCmsRoutes(options) {
1219
1660
  const { data, error } = await db().from("cms_globals").upsert({
1220
1661
  key: version.key,
1221
1662
  data: version.data,
1663
+ builder_owned: true,
1222
1664
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1223
1665
  }).select("*").single();
1224
1666
  if (error) return errors.badRequest(error.message);
@@ -1308,7 +1750,8 @@ function createCmsRoutes(options) {
1308
1750
  mime_type: normalizedFile.type,
1309
1751
  width: prepared.width,
1310
1752
  height: prepared.height,
1311
- filesize: normalizedFile.size
1753
+ filesize: normalizedFile.size,
1754
+ builder_owned: true
1312
1755
  }).select("*").single();
1313
1756
  if (error) return errors.badRequest(error.message);
1314
1757
  if (!options.memoryMode) {
@@ -1326,7 +1769,10 @@ function createCmsRoutes(options) {
1326
1769
  if (auth instanceof Response) return auth;
1327
1770
  const body = await readJson(request);
1328
1771
  if (!body) return errors.badRequest("Invalid body.");
1329
- const patch = { updated_at: (/* @__PURE__ */ new Date()).toISOString() };
1772
+ const patch = {
1773
+ builder_owned: true,
1774
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1775
+ };
1330
1776
  if (typeof body.alt === "string") patch.alt = body.alt;
1331
1777
  if (typeof body.caption === "string") patch.caption = body.caption;
1332
1778
  if (typeof body.filename === "string" && body.filename.trim()) {
@@ -1385,6 +1831,7 @@ function createCmsRoutes(options) {
1385
1831
  filesize: normalizedFile.size,
1386
1832
  width: prepared.width,
1387
1833
  height: prepared.height,
1834
+ builder_owned: true,
1388
1835
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1389
1836
  }).eq("id", id).select("*").single();
1390
1837
  if (error) return errors.badRequest(error.message);
@@ -1453,7 +1900,8 @@ function createCmsRoutes(options) {
1453
1900
  slug,
1454
1901
  title,
1455
1902
  config: { steps: [{ title: "", fields: [] }] },
1456
- success_message: "Thanks \u2014 we received your submission."
1903
+ success_message: "Thanks \u2014 we received your submission.",
1904
+ builder_owned: true
1457
1905
  }).select("*").single();
1458
1906
  if (error) {
1459
1907
  if (error.message.includes("duplicate")) {
@@ -1467,9 +1915,15 @@ function createCmsRoutes(options) {
1467
1915
  const getForm = async (request, slug) => {
1468
1916
  const auth = await guard(request, "forms.read");
1469
1917
  if (auth instanceof Response) return auth;
1470
- const { data, error } = await db().from("cms_forms").select("*").eq("slug", slug).maybeSingle();
1918
+ const projection = can(auth.user, "forms.write") ? "id, slug, title, config, notify, success_message, updated_at" : "id, slug, title, config, success_message, updated_at";
1919
+ const { data, error } = await db().from("cms_forms").select(projection).eq("slug", slug).maybeSingle();
1471
1920
  if (error) return errors.badRequest(error.message);
1472
1921
  if (!data) return errors.notFound();
1922
+ if (!can(auth.user, "forms.write")) {
1923
+ const { notify, ...publicForm } = data;
1924
+ void notify;
1925
+ return json({ form: publicForm });
1926
+ }
1473
1927
  return json({ form: data });
1474
1928
  };
1475
1929
  const updateForm = async (request, slug) => {
@@ -1478,8 +1932,32 @@ function createCmsRoutes(options) {
1478
1932
  const body = await readJson(request);
1479
1933
  if (!body) return errors.badRequest("Invalid body.");
1480
1934
  const config = isRecord2(body.config) ? { ...body.config } : {};
1481
- const notify = isRecord2(body.notify) ? body.notify : isRecord2(config.notify) ? config.notify : {};
1935
+ if (body.notify !== void 0 && !isRecord2(body.notify)) {
1936
+ return errors.badRequest("Notification settings must be an object.");
1937
+ }
1938
+ if (body.notify === void 0 && config.notify !== void 0 && !isRecord2(config.notify)) {
1939
+ return errors.badRequest("Notification settings must be an object.");
1940
+ }
1941
+ const rawNotify = isRecord2(body.notify) ? body.notify : isRecord2(config.notify) ? config.notify : {};
1482
1942
  delete config.notify;
1943
+ if (rawNotify.autoReply !== void 0 && typeof rawNotify.autoReply !== "boolean") {
1944
+ return errors.badRequest("autoReply must be a boolean.");
1945
+ }
1946
+ if (rawNotify.autoReplyEmailField !== void 0 && (typeof rawNotify.autoReplyEmailField !== "string" || !rawNotify.autoReplyEmailField.trim())) {
1947
+ return errors.badRequest("autoReplyEmailField must name a declared email field.");
1948
+ }
1949
+ const emailFields = getAutoReplyEmailFields(config);
1950
+ const selectedEmailField = typeof rawNotify.autoReplyEmailField === "string" ? rawNotify.autoReplyEmailField.trim() : "";
1951
+ if (selectedEmailField && !emailFields.includes(selectedEmailField)) {
1952
+ return errors.badRequest("autoReplyEmailField must name a declared email field.");
1953
+ }
1954
+ if (rawNotify.autoReply === true && emailFields.length !== 1 && !selectedEmailField) {
1955
+ return errors.badRequest("Auto-reply requires one selected declared email field.");
1956
+ }
1957
+ const notify = {
1958
+ ...rawNotify,
1959
+ ...selectedEmailField ? { autoReplyEmailField: selectedEmailField } : {}
1960
+ };
1483
1961
  const { data, error } = await db().from("cms_forms").upsert(
1484
1962
  {
1485
1963
  slug,
@@ -1487,6 +1965,7 @@ function createCmsRoutes(options) {
1487
1965
  config,
1488
1966
  notify,
1489
1967
  success_message: typeof body.successMessage === "string" ? body.successMessage : "",
1968
+ builder_owned: true,
1490
1969
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1491
1970
  },
1492
1971
  { onConflict: "slug" }
@@ -1542,7 +2021,7 @@ function createCmsRoutes(options) {
1542
2021
  const csvEscape = (value) => {
1543
2022
  let text = Array.isArray(value) ? value.join("; ") : typeof value === "string" ? value : value === null || value === void 0 ? "" : JSON.stringify(value);
1544
2023
  if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
1545
- return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
2024
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
1546
2025
  };
1547
2026
  const exportSubmissions = async (request) => {
1548
2027
  const auth = await guard(request, "submissions.read");
@@ -1660,17 +2139,18 @@ function createCmsRoutes(options) {
1660
2139
  } catch {
1661
2140
  }
1662
2141
  if (sendEmail) {
2142
+ const notificationConfig = {
2143
+ ...form.config || {},
2144
+ ...isRecord2(form.notify) && Object.keys(form.notify).length > 0 ? { notify: form.notify } : {}
2145
+ };
1663
2146
  await notifySubmission({
1664
2147
  sendEmail,
1665
2148
  formTitle: String(form.title || form.slug),
1666
- // Notify settings live in their own column; merge for legacy configs.
1667
- config: {
1668
- ...form.config || {},
1669
- ...isRecord2(form.notify) && Object.keys(form.notify).length > 0 ? { notify: form.notify } : {}
1670
- },
2149
+ config: notificationConfig,
1671
2150
  successMessage: String(form.success_message || ""),
1672
2151
  data: result.normalizedData,
1673
- siteName: options.siteName
2152
+ siteName: options.siteName,
2153
+ autoReplyAllowed: await maySendAutoReply(notificationConfig, result.normalizedData)
1674
2154
  });
1675
2155
  }
1676
2156
  return json({ success: true, id: created.id });
@@ -1694,7 +2174,12 @@ function createCmsRoutes(options) {
1694
2174
  return errors.badRequest("To path must start with / or be a full URL.");
1695
2175
  }
1696
2176
  if (fromPath === toPath) return errors.badRequest("A redirect cannot point at itself.");
1697
- const { data, error } = await db().from("cms_redirects").upsert({ from_path: fromPath, to_path: toPath, permanent }, { onConflict: "from_path" }).select("*").single();
2177
+ const { data, error } = await db().from("cms_redirects").upsert({
2178
+ from_path: fromPath,
2179
+ to_path: toPath,
2180
+ permanent,
2181
+ builder_owned: true
2182
+ }, { onConflict: "from_path" }).select("*").single();
1698
2183
  if (error) return errors.badRequest(error.message);
1699
2184
  await logActivity(auth.user, "redirect.save", `${fromPath} \u2192 ${toPath}`);
1700
2185
  return json({ redirect: data }, 201);
@@ -1708,6 +2193,7 @@ function createCmsRoutes(options) {
1708
2193
  };
1709
2194
  const ingestEvents = async (request) => {
1710
2195
  if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
2196
+ if (!analyticsSecret) return json({ success: true });
1711
2197
  const userAgent = request.headers.get("user-agent") || "";
1712
2198
  if (isBotRequest(userAgent)) return json({ success: true });
1713
2199
  const now = Date.now();
@@ -1724,7 +2210,7 @@ function createCmsRoutes(options) {
1724
2210
  sessionKey: requestSessionKey(request),
1725
2211
  visitorKey: visitorKeyFor(
1726
2212
  body?.visitorId,
1727
- previewSecret() || "orion-analytics"
2213
+ analyticsSecret
1728
2214
  ),
1729
2215
  device: deviceFrom(userAgent),
1730
2216
  ...geoFrom(request)
@@ -1801,7 +2287,7 @@ function createCmsRoutes(options) {
1801
2287
  const cronPublishDue = async (request) => {
1802
2288
  if (hostedMemoryMode) return errors.forbidden();
1803
2289
  const bearer = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
1804
- let authorized = tokenEquals(bearer, syncToken || "") || tokenEquals(bearer, process.env.CRON_SECRET || "");
2290
+ let authorized = tokenEquals(bearer, cronToken || "");
1805
2291
  if (!authorized) {
1806
2292
  const user = await resolveUser(request, db());
1807
2293
  authorized = Boolean(user && can(user, "pages.publish"));
@@ -1960,6 +2446,10 @@ function createCmsRoutes(options) {
1960
2446
  return errors.badRequest("You can only assign roles at or below your own.");
1961
2447
  }
1962
2448
  }
2449
+ if (body.role !== void 0 && body.role !== membership.role) {
2450
+ const revokeError = await revokePreviewGrants({ userId: canonicalUserId });
2451
+ if (revokeError) return errors.badRequest("Unable to revoke the user preview grants.");
2452
+ }
1963
2453
  if (typeof body.password === "string") {
1964
2454
  if (body.password.length < 8) return errors.badRequest("Password must be at least 8 characters.");
1965
2455
  const { error: passwordError } = await db().auth.admin.updateUserById(canonicalUserId, {
@@ -1988,6 +2478,8 @@ function createCmsRoutes(options) {
1988
2478
  return errors.badRequest("You can't remove your own account.");
1989
2479
  }
1990
2480
  if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
2481
+ const revokeError = await revokePreviewGrants({ userId: canonicalUserId });
2482
+ if (revokeError) return errors.badRequest("Unable to revoke the user preview grants.");
1991
2483
  const { error: authError } = await db().auth.admin.deleteUser(canonicalUserId);
1992
2484
  if (authError) return errors.badRequest(authError.message);
1993
2485
  const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", canonicalUserId);
@@ -2004,10 +2496,30 @@ function createCmsRoutes(options) {
2004
2496
  authorized = Boolean(user && can(user, "sync.run"));
2005
2497
  }
2006
2498
  if (!authorized) return errors.forbidden();
2007
- const body = await readJson(request);
2499
+ const body = await readJsonLimited(request, { maxBytes: MAX_SYNC_BODY_BYTES, maxDepth: 32 });
2008
2500
  if (!body) return errors.badRequest("Invalid body.");
2009
- const result = await runContentSync(db(), registry, body);
2010
- await revalidateContent();
2501
+ const input = isRecord2(body.input) ? body.input : body;
2502
+ const mode = body.mode === "apply" ? "apply" : "dry-run";
2503
+ const expectedManifestHash = typeof body.expectedManifestHash === "string" ? body.expectedManifestHash : "";
2504
+ const expectedTargetReceiptHash = typeof body.expectedTargetReceiptHash === "string" ? body.expectedTargetReceiptHash : "";
2505
+ let result;
2506
+ try {
2507
+ result = mode === "apply" ? await runContentSync(db(), registry, input, {
2508
+ mode,
2509
+ expectedManifestHash,
2510
+ expectedProjectRef: previewProjectRef,
2511
+ expectedTargetReceiptHash
2512
+ }) : await runContentSync(db(), registry, input, {
2513
+ mode,
2514
+ expectedProjectRef: previewProjectRef
2515
+ });
2516
+ } catch (error) {
2517
+ return errors.conflict(error instanceof Error ? error.message : "Sync manifest conflict.");
2518
+ }
2519
+ if (result.mode === "apply") {
2520
+ await logActivity(null, "sync.apply", result.manifestHash);
2521
+ await revalidateContent();
2522
+ }
2011
2523
  return json({ success: true, ...result });
2012
2524
  };
2013
2525
  const dispatch = async (request, context) => {
@@ -2031,11 +2543,12 @@ function createCmsRoutes(options) {
2031
2543
  return duplicatePage(request, second);
2032
2544
  } else if (third === "preview" && method === "POST") {
2033
2545
  return previewToken(request, second);
2546
+ } else if (third === "preview" && method === "DELETE") {
2547
+ return revokePagePreviews(request, second);
2034
2548
  } else if (third === "versions" && method === "GET") {
2035
2549
  return listVersions(request, second);
2036
2550
  }
2037
2551
  }
2038
- if (head === "preview" && !second && method === "GET") return previewPage(request);
2039
2552
  if (head === "versions" && second) {
2040
2553
  if (third === "restore" && method === "POST") return restoreVersion(request, second);
2041
2554
  if (!third && method === "GET") return getVersion(request, second);
@@ -2406,10 +2919,11 @@ function runRpc(store, fn, args = {}) {
2406
2919
  const key = String(args.p_key);
2407
2920
  let row = globals.find((r) => r.key === key);
2408
2921
  if (!row) {
2409
- row = { key, label: "", data: args.p_data ?? {}, updated_at: nowIso() };
2922
+ row = { key, label: "", data: args.p_data ?? {}, builder_owned: true, updated_at: nowIso() };
2410
2923
  globals.push(row);
2411
2924
  } else {
2412
2925
  row.data = args.p_data ?? {};
2926
+ row.builder_owned = true;
2413
2927
  row.updated_at = nowIso();
2414
2928
  }
2415
2929
  globalVersions.push({
@@ -2454,6 +2968,55 @@ function runRpc(store, fn, args = {}) {
2454
2968
  store.setTable("cms_rate_limits", kept);
2455
2969
  return { data: limits.length - kept.length, error: null };
2456
2970
  }
2971
+ case "cms_consume_preview_grant": {
2972
+ const grant = store.table("cms_preview_grants").find(
2973
+ (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
2974
+ );
2975
+ if (!grant || grant.revoked_at) return { data: false, error: null };
2976
+ const expiresAt = typeof grant.expires_at === "string" ? Date.parse(grant.expires_at) : NaN;
2977
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return { data: false, error: null };
2978
+ const useCount = typeof grant.use_count === "number" ? grant.use_count : 0;
2979
+ const maxUses = typeof grant.max_uses === "number" ? grant.max_uses : 0;
2980
+ if (useCount >= maxUses) return { data: false, error: null };
2981
+ grant.use_count = useCount + 1;
2982
+ grant.last_used_at = nowIso();
2983
+ return { data: true, error: null };
2984
+ }
2985
+ case "cms_begin_sync": {
2986
+ const runs = store.table("cms_sync_runs");
2987
+ const requested = runs.find((row) => row.manifest_hash === args.p_manifest_hash);
2988
+ if (requested?.status === "complete") {
2989
+ return { data: { status: "complete", result: requested.result }, error: null };
2990
+ }
2991
+ if (requested?.status === "active") return { data: { status: "busy" }, error: null };
2992
+ const active = runs.find(
2993
+ (row) => row.status === "active" && row.manifest_hash !== args.p_manifest_hash
2994
+ );
2995
+ if (active) return { data: { status: "busy" }, error: null };
2996
+ if (requested) {
2997
+ requested.status = "active";
2998
+ requested.target_receipt_hash = args.p_target_receipt_hash;
2999
+ requested.updated_at = nowIso();
3000
+ return { data: { status: "resume" }, error: null };
3001
+ }
3002
+ runs.push({
3003
+ manifest_hash: args.p_manifest_hash,
3004
+ target_receipt_hash: args.p_target_receipt_hash,
3005
+ status: "active",
3006
+ started_at: nowIso(),
3007
+ updated_at: nowIso()
3008
+ });
3009
+ return { data: { status: "started" }, error: null };
3010
+ }
3011
+ case "cms_finish_sync": {
3012
+ const run = store.table("cms_sync_runs").find((row) => row.manifest_hash === args.p_manifest_hash);
3013
+ if (!run || run.status !== "active") return { data: false, error: null };
3014
+ run.status = args.p_status;
3015
+ run.result = args.p_status === "complete" ? args.p_result : null;
3016
+ run.error = args.p_status === "failed" ? args.p_error : null;
3017
+ run.updated_at = nowIso();
3018
+ return { data: true, error: null };
3019
+ }
2457
3020
  case "cms_publish_due_pages": {
2458
3021
  const nowMs = Date.now();
2459
3022
  const published = [];
@@ -2532,6 +3095,90 @@ function runRpc(store, fn, args = {}) {
2532
3095
  }
2533
3096
  return { data: page, error: null };
2534
3097
  }
3098
+ case "cms_sync_global": {
3099
+ const globals = store.table("cms_globals");
3100
+ const key = String(args.p_key);
3101
+ let row = globals.find((item) => item.key === key);
3102
+ if (row?.builder_owned) return { data: false, error: null };
3103
+ if (!row) {
3104
+ row = { key, label: "", data: args.p_data ?? {}, builder_owned: false, updated_at: nowIso() };
3105
+ globals.push(row);
3106
+ } else {
3107
+ row.data = args.p_data ?? {};
3108
+ row.updated_at = nowIso();
3109
+ }
3110
+ return { data: true, error: null };
3111
+ }
3112
+ case "cms_sync_form": {
3113
+ const forms = store.table("cms_forms");
3114
+ const slug = String(args.p_slug);
3115
+ let row = forms.find((item) => item.slug === slug);
3116
+ if (row?.builder_owned) return { data: false, error: null };
3117
+ if (!row) {
3118
+ row = {
3119
+ id: newId(),
3120
+ slug,
3121
+ title: args.p_title ?? slug,
3122
+ config: args.p_config ?? {},
3123
+ notify: {},
3124
+ success_message: args.p_success_message ?? "",
3125
+ builder_owned: false,
3126
+ created_at: nowIso(),
3127
+ updated_at: nowIso()
3128
+ };
3129
+ forms.push(row);
3130
+ } else {
3131
+ row.title = args.p_title ?? slug;
3132
+ row.config = args.p_config ?? {};
3133
+ row.success_message = args.p_success_message ?? "";
3134
+ row.updated_at = nowIso();
3135
+ }
3136
+ return { data: true, error: null };
3137
+ }
3138
+ case "cms_sync_media": {
3139
+ const media = store.table("cms_media");
3140
+ const storagePath = String(args.p_storage_path);
3141
+ let row = media.find((item) => item.storage_path === storagePath);
3142
+ if (row?.builder_owned) return { data: false, error: null };
3143
+ const values = {
3144
+ storage_path: storagePath,
3145
+ filename: args.p_filename,
3146
+ alt: args.p_alt,
3147
+ caption: args.p_caption,
3148
+ mime_type: args.p_mime_type,
3149
+ width: args.p_width,
3150
+ height: args.p_height,
3151
+ filesize: args.p_filesize,
3152
+ builder_owned: false,
3153
+ updated_at: nowIso()
3154
+ };
3155
+ if (!row) {
3156
+ row = { id: newId(), created_at: nowIso(), ...values };
3157
+ media.push(row);
3158
+ } else {
3159
+ Object.assign(row, values);
3160
+ }
3161
+ return { data: true, error: null };
3162
+ }
3163
+ case "cms_sync_redirect": {
3164
+ const redirects = store.table("cms_redirects");
3165
+ const fromPath = String(args.p_from_path);
3166
+ let row = redirects.find((item) => item.from_path === fromPath);
3167
+ if (row?.builder_owned) return { data: false, error: null };
3168
+ const values = {
3169
+ from_path: fromPath,
3170
+ to_path: args.p_to_path,
3171
+ permanent: args.p_permanent,
3172
+ builder_owned: false
3173
+ };
3174
+ if (!row) {
3175
+ row = { id: newId(), created_at: nowIso(), ...values };
3176
+ redirects.push(row);
3177
+ } else {
3178
+ Object.assign(row, values);
3179
+ }
3180
+ return { data: true, error: null };
3181
+ }
2535
3182
  default:
2536
3183
  return { data: null, error: { message: `unknown function ${fn}` } };
2537
3184
  }
@@ -2586,6 +3233,8 @@ function getMemoryCms() {
2586
3233
  export {
2587
3234
  CONTENT_CACHE_TAG,
2588
3235
  MEMORY_DEV_TOKEN,
3236
+ PREVIEW_SESSION_COOKIE,
3237
+ PREVIEW_TOKEN_MAX_USES,
2589
3238
  PREVIEW_TOKEN_TTL_MS,
2590
3239
  aggregateAnalytics,
2591
3240
  can,
@@ -2609,6 +3258,8 @@ export {
2609
3258
  runContentSync,
2610
3259
  sessionKeyFor,
2611
3260
  setServiceClientForTesting,
3261
+ validateCmsEnv,
3262
+ verifyPreviewGrantToken,
2612
3263
  verifyPreviewToken,
2613
3264
  visitorKeyFor
2614
3265
  };