@orion-studios/cms 0.5.6 → 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,6 +1,6 @@
1
1
  import {
2
2
  CONTENT_CACHE_TAG
3
- } from "../chunk-AYV6KYDP.js";
3
+ } from "../chunk-NSAZCP4I.js";
4
4
  import {
5
5
  createMemoryRateLimitStore,
6
6
  getAutoReplyEmailFields,
@@ -10,7 +10,7 @@ import {
10
10
  } from "../chunk-CFZP7674.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"]);
@@ -437,33 +437,62 @@ var encode = (value) => Buffer.from(value, "utf8").toString("base64url");
437
437
  var decode = (value) => Buffer.from(value, "base64url").toString("utf8");
438
438
  var sign = (payload, secret) => createHmac2("sha256", secret).update(payload).digest("base64url");
439
439
  var PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1e3;
440
+ var PREVIEW_TOKEN_MAX_USES = 8;
440
441
  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)}`;
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)}`;
444
456
  }
445
- function verifyPreviewToken(token, secret) {
457
+ function verifyPreviewGrantToken(token, keys, now = Date.now()) {
446
458
  const segments = token.split(".");
447
459
  if (segments.length !== 2) return null;
448
460
  const [payload, signature] = segments;
449
461
  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
462
  try {
456
- const [pageId, expiresAt] = decode(payload).split("|");
457
- if (!pageId || Number(expiresAt) < Date.now()) return null;
458
- 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;
459
475
  } catch {
460
476
  return null;
461
477
  }
462
478
  }
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();
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();
467
496
  if (!data) return null;
468
497
  return {
469
498
  id: String(data.id),
@@ -477,20 +506,67 @@ async function getPreviewPage(client, token, secret) {
477
506
 
478
507
  // src/server/supabase.ts
479
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
+ }
480
554
  function readCmsEnv() {
481
555
  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 || "";
556
+ const serviceRoleKey = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || "";
557
+ const expectedProjectRef = process.env.CMS_EXPECTED_SUPABASE_PROJECT_REF || "";
483
558
  if (!supabaseUrl || !serviceRoleKey) {
484
559
  throw new Error(
485
- "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."
486
561
  );
487
562
  }
488
- return { supabaseUrl, serviceRoleKey };
563
+ return validateCmsEnv({ expectedProjectRef, supabaseUrl, serviceRoleKey });
489
564
  }
490
565
  var serviceClient = null;
491
566
  function getServiceClient(env = readCmsEnv()) {
492
567
  if (!serviceClient) {
493
- serviceClient = createClient(env.supabaseUrl, env.serviceRoleKey, {
568
+ const validated = validateCmsEnv(env);
569
+ serviceClient = createClient(validated.supabaseUrl, validated.serviceRoleKey, {
494
570
  auth: { persistSession: false, autoRefreshToken: false }
495
571
  });
496
572
  }
@@ -516,12 +592,19 @@ async function resolveUser(request, client = getServiceClient()) {
516
592
  }
517
593
 
518
594
  // src/server/sync.ts
595
+ import { createHash as createHash2 } from "crypto";
519
596
  var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
520
597
  var publicFormConfig = (value) => {
521
598
  const config = isRecord(value) ? { ...value } : {};
522
599
  delete config.notify;
523
600
  return config;
524
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));
525
608
  var mimeByExtension = {
526
609
  avif: "image/avif",
527
610
  gif: "image/gif",
@@ -534,22 +617,15 @@ var mimeByExtension = {
534
617
  webp: "image/webp"
535
618
  };
536
619
  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
- };
620
+ var filenameFromPath = (path) => cleanPath(path).split("/").filter(Boolean).pop() || cleanPath(path);
621
+ var mimeFromPath = (path) => mimeByExtension[filenameFromPath(path).split(".").pop()?.toLowerCase() || ""] || "";
545
622
  var shouldIndexMediaPath = (src) => Boolean(src) && !src.startsWith("data:") && !/^https?:\/\//i.test(src);
546
623
  var toSyncMedia = (value) => {
547
624
  const src = typeof value.src === "string" ? value.src.trim() : "";
548
625
  if (!shouldIndexMediaPath(src)) return null;
549
- const filename = typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src);
550
626
  return {
551
627
  storagePath: src,
552
- filename,
628
+ filename: typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src),
553
629
  alt: typeof value.alt === "string" ? value.alt : "",
554
630
  caption: typeof value.caption === "string" ? value.caption : "",
555
631
  mimeType: mimeFromPath(src)
@@ -582,79 +658,328 @@ var normalizeManualMedia = (item) => {
582
658
  filesize: typeof item.filesize === "number" ? item.filesize : null
583
659
  };
584
660
  };
585
- async function runContentSync(client, registry, input) {
586
- const pages = input.pages || [];
587
- const globals = input.globals || [];
588
- const forms = input.forms || [];
661
+ var prepareSync = (registry, input) => {
662
+ const pages = [];
589
663
  const media = /* @__PURE__ */ new Map();
590
- const synced = [];
591
664
  const skipped = [];
592
- for (const page of pages) {
665
+ for (const page of input.pages || []) {
593
666
  if (!page || typeof page.slug !== "string") continue;
594
667
  const validated = registry.validateLayout(page.layout ?? []);
595
668
  if (!validated.ok) {
596
669
  skipped.push({ slug: page.slug, issues: validated.issues });
597
670
  continue;
598
671
  }
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
672
  collectLayoutMedia(validated.layout, media);
611
- 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
+ });
612
680
  }
613
681
  for (const item of input.media || []) {
614
682
  const normalized = normalizeManualMedia(item);
615
683
  if (normalized && !media.has(normalized.storagePath)) media.set(normalized.storagePath, normalized);
616
684
  }
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(
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(
645
761
  {
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()
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
651
769
  },
652
- { 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
653
852
  );
654
- if (error) failed.push({ kind: "form", key: form.slug, error: error.message });
655
- 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;
656
982
  }
657
- return { synced, skipped, globals: syncedGlobals, forms: syncedForms, media: syncedMedia, failed };
658
983
  }
659
984
 
660
985
  // src/server/routes.ts
@@ -739,6 +1064,7 @@ var clientKey = (request) => {
739
1064
  };
740
1065
  var MAX_PUBLIC_BODY_BYTES = 64 * 1024;
741
1066
  var MAX_PUBLIC_BODY_DEPTH = 12;
1067
+ var MAX_SYNC_BODY_BYTES = 2 * 1024 * 1024;
742
1068
  function exceedsDepth(value, limit, depth = 0) {
743
1069
  if (depth > limit) return true;
744
1070
  if (Array.isArray(value)) return value.some((item) => exceedsDepth(item, limit, depth + 1));
@@ -746,13 +1072,38 @@ function exceedsDepth(value, limit, depth = 0) {
746
1072
  return false;
747
1073
  }
748
1074
  async function readJsonLimited(request, limits) {
749
- 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 = "";
750
1086
  try {
751
- 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();
752
1098
  } catch {
1099
+ try {
1100
+ await reader.cancel();
1101
+ } catch {
1102
+ }
753
1103
  return null;
1104
+ } finally {
1105
+ reader.releaseLock();
754
1106
  }
755
- if (text.length > limits.maxBytes) return null;
756
1107
  let parsed;
757
1108
  try {
758
1109
  parsed = JSON.parse(text);
@@ -763,7 +1114,7 @@ async function readJsonLimited(request, limits) {
763
1114
  if (exceedsDepth(parsed, limits.maxDepth)) return null;
764
1115
  return parsed;
765
1116
  }
766
- 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);
767
1118
  var requestPagePath = (request) => {
768
1119
  const referrer = request.headers.get("referer");
769
1120
  if (!referrer) return "";
@@ -799,16 +1150,27 @@ function createDurableRateLimitStore(getClient, options) {
799
1150
  };
800
1151
  }
801
1152
  function createCmsRoutes(options) {
802
- const { registry, allowedOrigins, syncToken, knownGoodEmailDomains } = options;
1153
+ const { registry, allowedOrigins, syncToken, cronToken, knownGoodEmailDomains } = options;
803
1154
  const db = () => options.client ?? getServiceClient();
804
1155
  const hostedMemoryMode = options.memoryMode === true && process.env.NODE_ENV === "production";
805
1156
  const rateLimit = options.rateLimitStore === null ? null : options.rateLimitStore || (options.memoryMode ? createMemoryRateLimitStore() : createDurableRateLimitStore(db, { max: 5, windowMs: 6e4, bucket: "submit" }));
806
1157
  const sendEmail = hostedMemoryMode || options.sendEmail === null ? null : options.sendEmail || createResendSender();
807
1158
  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
- };
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" : "");
812
1174
  const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
813
1175
  const autoReplyLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
814
1176
  const boundedAutoReplyMax = (name, fallback) => {
@@ -832,14 +1194,12 @@ function createCmsRoutes(options) {
832
1194
  "auto-reply-site-day"
833
1195
  )
834
1196
  };
835
- const autoReplyRecipientKey = (email) => createHmac3(
836
- "sha256",
837
- previewSecret() || syncToken || "orion-memory-auto-reply-secret"
838
- ).update(`recipient:${email}`).digest("hex").slice(0, 24);
1197
+ const autoReplyRecipientKey = (email) => createHmac3("sha256", autoReplyHashSecret).update(`recipient:${email}`).digest("hex").slice(0, 24);
839
1198
  const maySendAutoReply = async (config, data) => {
840
1199
  if (config.notify?.autoReply !== true) return false;
841
1200
  const recipient = resolveSubmitterEmail(config, data);
842
1201
  if (!recipient) return false;
1202
+ if (!autoReplyHashSecret) return false;
843
1203
  const now = Date.now();
844
1204
  if (await autoReplyLimits.recipientPerHour.isLimited(autoReplyRecipientKey(recipient), now)) {
845
1205
  return false;
@@ -891,8 +1251,8 @@ function createCmsRoutes(options) {
891
1251
  };
892
1252
  const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
893
1253
  const requestSessionKey = (request) => {
894
- const secret = previewSecret() || "orion-analytics";
895
- 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);
896
1256
  };
897
1257
  const guard = async (request, action) => {
898
1258
  if (hostedMemoryMode) return errors.unauthorized();
@@ -901,6 +1261,13 @@ function createCmsRoutes(options) {
901
1261
  if (!can(user, action)) return errors.forbidden();
902
1262
  return { user };
903
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
+ };
904
1271
  const logActivity = async (user, action, subject) => {
905
1272
  try {
906
1273
  await db().from("cms_activity").insert({
@@ -1020,6 +1387,7 @@ function createCmsRoutes(options) {
1020
1387
  }
1021
1388
  patch.path = path;
1022
1389
  }
1390
+ patch.builder_owned = true;
1023
1391
  if (Object.keys(patch).length > 0) {
1024
1392
  const oldPath = String(current.path || "");
1025
1393
  const wasPublished = current.status === "published";
@@ -1033,7 +1401,12 @@ function createCmsRoutes(options) {
1033
1401
  const newPath = typeof patch.path === "string" ? patch.path : oldPath;
1034
1402
  if (newPath !== oldPath && wasPublished && oldPath !== "/") {
1035
1403
  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" });
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" });
1037
1410
  }
1038
1411
  if (newPath !== oldPath) {
1039
1412
  await logActivity(auth.user, "page.rename", `${oldPath} \u2192 ${newPath}`);
@@ -1093,6 +1466,8 @@ function createCmsRoutes(options) {
1093
1466
  if (!Number.isSafeInteger(expectedVersionId) || expectedVersionId <= 0) {
1094
1467
  return errors.conflict("Page version conflict: save the draft again before publishing.");
1095
1468
  }
1469
+ const revokeError = await revokePreviewGrants({ pageId: id });
1470
+ if (revokeError) return errors.badRequest("Unable to revoke existing preview grants.");
1096
1471
  const { data, error } = await db().rpc("cms_publish_page", {
1097
1472
  p_page_id: id,
1098
1473
  p_expected_version_id: expectedVersionId,
@@ -1109,6 +1484,8 @@ function createCmsRoutes(options) {
1109
1484
  const unpublishPage = async (request, id) => {
1110
1485
  const auth = await guard(request, "pages.publish");
1111
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.");
1112
1489
  const { data, error } = await db().from("cms_pages").update({
1113
1490
  status: "draft",
1114
1491
  publish_at: null,
@@ -1167,11 +1544,28 @@ function createCmsRoutes(options) {
1167
1544
  const previewToken = async (request, id) => {
1168
1545
  const auth = await guard(request, "pages.read");
1169
1546
  if (auth instanceof Response) return auth;
1170
- const secret = previewSecret();
1171
- 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.");
1172
1549
  const { data } = await db().from("cms_pages").select("id, path").eq("id", id).maybeSingle();
1173
1550
  if (!data) return errors.notFound();
1174
- const token = createPreviewToken(id, secret);
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
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);
1175
1569
  const previewUrl = `/cms-preview/${encodeURIComponent(String(data.id))}`;
1176
1570
  const response = json({ path: data.path, url: previewUrl });
1177
1571
  const secure = process.env.NODE_ENV === "production" || new URL(request.url).protocol === "https:";
@@ -1183,6 +1577,14 @@ function createCmsRoutes(options) {
1183
1577
  );
1184
1578
  return response;
1185
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 });
1587
+ };
1186
1588
  const listVersions = async (request, id) => {
1187
1589
  const auth = await guard(request, "pages.restore");
1188
1590
  if (auth instanceof Response) return auth;
@@ -1209,6 +1611,10 @@ function createCmsRoutes(options) {
1209
1611
  const restoreVersion = async (request, versionId) => {
1210
1612
  const auth = await guard(request, "pages.restore");
1211
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.");
1212
1618
  const { data, error } = await db().rpc("cms_restore_page_version", {
1213
1619
  p_version_id: Number(versionId),
1214
1620
  p_actor: auth.user.id
@@ -1254,6 +1660,7 @@ function createCmsRoutes(options) {
1254
1660
  const { data, error } = await db().from("cms_globals").upsert({
1255
1661
  key: version.key,
1256
1662
  data: version.data,
1663
+ builder_owned: true,
1257
1664
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1258
1665
  }).select("*").single();
1259
1666
  if (error) return errors.badRequest(error.message);
@@ -1343,7 +1750,8 @@ function createCmsRoutes(options) {
1343
1750
  mime_type: normalizedFile.type,
1344
1751
  width: prepared.width,
1345
1752
  height: prepared.height,
1346
- filesize: normalizedFile.size
1753
+ filesize: normalizedFile.size,
1754
+ builder_owned: true
1347
1755
  }).select("*").single();
1348
1756
  if (error) return errors.badRequest(error.message);
1349
1757
  if (!options.memoryMode) {
@@ -1361,7 +1769,10 @@ function createCmsRoutes(options) {
1361
1769
  if (auth instanceof Response) return auth;
1362
1770
  const body = await readJson(request);
1363
1771
  if (!body) return errors.badRequest("Invalid body.");
1364
- const patch = { updated_at: (/* @__PURE__ */ new Date()).toISOString() };
1772
+ const patch = {
1773
+ builder_owned: true,
1774
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1775
+ };
1365
1776
  if (typeof body.alt === "string") patch.alt = body.alt;
1366
1777
  if (typeof body.caption === "string") patch.caption = body.caption;
1367
1778
  if (typeof body.filename === "string" && body.filename.trim()) {
@@ -1420,6 +1831,7 @@ function createCmsRoutes(options) {
1420
1831
  filesize: normalizedFile.size,
1421
1832
  width: prepared.width,
1422
1833
  height: prepared.height,
1834
+ builder_owned: true,
1423
1835
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1424
1836
  }).eq("id", id).select("*").single();
1425
1837
  if (error) return errors.badRequest(error.message);
@@ -1488,7 +1900,8 @@ function createCmsRoutes(options) {
1488
1900
  slug,
1489
1901
  title,
1490
1902
  config: { steps: [{ title: "", fields: [] }] },
1491
- success_message: "Thanks \u2014 we received your submission."
1903
+ success_message: "Thanks \u2014 we received your submission.",
1904
+ builder_owned: true
1492
1905
  }).select("*").single();
1493
1906
  if (error) {
1494
1907
  if (error.message.includes("duplicate")) {
@@ -1502,9 +1915,15 @@ function createCmsRoutes(options) {
1502
1915
  const getForm = async (request, slug) => {
1503
1916
  const auth = await guard(request, "forms.read");
1504
1917
  if (auth instanceof Response) return auth;
1505
- 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();
1506
1920
  if (error) return errors.badRequest(error.message);
1507
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
+ }
1508
1927
  return json({ form: data });
1509
1928
  };
1510
1929
  const updateForm = async (request, slug) => {
@@ -1546,6 +1965,7 @@ function createCmsRoutes(options) {
1546
1965
  config,
1547
1966
  notify,
1548
1967
  success_message: typeof body.successMessage === "string" ? body.successMessage : "",
1968
+ builder_owned: true,
1549
1969
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1550
1970
  },
1551
1971
  { onConflict: "slug" }
@@ -1754,7 +2174,12 @@ function createCmsRoutes(options) {
1754
2174
  return errors.badRequest("To path must start with / or be a full URL.");
1755
2175
  }
1756
2176
  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();
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();
1758
2183
  if (error) return errors.badRequest(error.message);
1759
2184
  await logActivity(auth.user, "redirect.save", `${fromPath} \u2192 ${toPath}`);
1760
2185
  return json({ redirect: data }, 201);
@@ -1768,6 +2193,7 @@ function createCmsRoutes(options) {
1768
2193
  };
1769
2194
  const ingestEvents = async (request) => {
1770
2195
  if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
2196
+ if (!analyticsSecret) return json({ success: true });
1771
2197
  const userAgent = request.headers.get("user-agent") || "";
1772
2198
  if (isBotRequest(userAgent)) return json({ success: true });
1773
2199
  const now = Date.now();
@@ -1784,7 +2210,7 @@ function createCmsRoutes(options) {
1784
2210
  sessionKey: requestSessionKey(request),
1785
2211
  visitorKey: visitorKeyFor(
1786
2212
  body?.visitorId,
1787
- previewSecret() || "orion-analytics"
2213
+ analyticsSecret
1788
2214
  ),
1789
2215
  device: deviceFrom(userAgent),
1790
2216
  ...geoFrom(request)
@@ -1861,7 +2287,7 @@ function createCmsRoutes(options) {
1861
2287
  const cronPublishDue = async (request) => {
1862
2288
  if (hostedMemoryMode) return errors.forbidden();
1863
2289
  const bearer = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
1864
- let authorized = tokenEquals(bearer, syncToken || "") || tokenEquals(bearer, process.env.CRON_SECRET || "");
2290
+ let authorized = tokenEquals(bearer, cronToken || "");
1865
2291
  if (!authorized) {
1866
2292
  const user = await resolveUser(request, db());
1867
2293
  authorized = Boolean(user && can(user, "pages.publish"));
@@ -2020,6 +2446,10 @@ function createCmsRoutes(options) {
2020
2446
  return errors.badRequest("You can only assign roles at or below your own.");
2021
2447
  }
2022
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
+ }
2023
2453
  if (typeof body.password === "string") {
2024
2454
  if (body.password.length < 8) return errors.badRequest("Password must be at least 8 characters.");
2025
2455
  const { error: passwordError } = await db().auth.admin.updateUserById(canonicalUserId, {
@@ -2048,6 +2478,8 @@ function createCmsRoutes(options) {
2048
2478
  return errors.badRequest("You can't remove your own account.");
2049
2479
  }
2050
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.");
2051
2483
  const { error: authError } = await db().auth.admin.deleteUser(canonicalUserId);
2052
2484
  if (authError) return errors.badRequest(authError.message);
2053
2485
  const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", canonicalUserId);
@@ -2064,10 +2496,30 @@ function createCmsRoutes(options) {
2064
2496
  authorized = Boolean(user && can(user, "sync.run"));
2065
2497
  }
2066
2498
  if (!authorized) return errors.forbidden();
2067
- const body = await readJson(request);
2499
+ const body = await readJsonLimited(request, { maxBytes: MAX_SYNC_BODY_BYTES, maxDepth: 32 });
2068
2500
  if (!body) return errors.badRequest("Invalid body.");
2069
- const result = await runContentSync(db(), registry, body);
2070
- 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
+ }
2071
2523
  return json({ success: true, ...result });
2072
2524
  };
2073
2525
  const dispatch = async (request, context) => {
@@ -2091,6 +2543,8 @@ function createCmsRoutes(options) {
2091
2543
  return duplicatePage(request, second);
2092
2544
  } else if (third === "preview" && method === "POST") {
2093
2545
  return previewToken(request, second);
2546
+ } else if (third === "preview" && method === "DELETE") {
2547
+ return revokePagePreviews(request, second);
2094
2548
  } else if (third === "versions" && method === "GET") {
2095
2549
  return listVersions(request, second);
2096
2550
  }
@@ -2465,10 +2919,11 @@ function runRpc(store, fn, args = {}) {
2465
2919
  const key = String(args.p_key);
2466
2920
  let row = globals.find((r) => r.key === key);
2467
2921
  if (!row) {
2468
- row = { key, label: "", data: args.p_data ?? {}, updated_at: nowIso() };
2922
+ row = { key, label: "", data: args.p_data ?? {}, builder_owned: true, updated_at: nowIso() };
2469
2923
  globals.push(row);
2470
2924
  } else {
2471
2925
  row.data = args.p_data ?? {};
2926
+ row.builder_owned = true;
2472
2927
  row.updated_at = nowIso();
2473
2928
  }
2474
2929
  globalVersions.push({
@@ -2513,6 +2968,55 @@ function runRpc(store, fn, args = {}) {
2513
2968
  store.setTable("cms_rate_limits", kept);
2514
2969
  return { data: limits.length - kept.length, error: null };
2515
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
+ }
2516
3020
  case "cms_publish_due_pages": {
2517
3021
  const nowMs = Date.now();
2518
3022
  const published = [];
@@ -2591,6 +3095,90 @@ function runRpc(store, fn, args = {}) {
2591
3095
  }
2592
3096
  return { data: page, error: null };
2593
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
+ }
2594
3182
  default:
2595
3183
  return { data: null, error: { message: `unknown function ${fn}` } };
2596
3184
  }
@@ -2646,6 +3234,7 @@ export {
2646
3234
  CONTENT_CACHE_TAG,
2647
3235
  MEMORY_DEV_TOKEN,
2648
3236
  PREVIEW_SESSION_COOKIE,
3237
+ PREVIEW_TOKEN_MAX_USES,
2649
3238
  PREVIEW_TOKEN_TTL_MS,
2650
3239
  aggregateAnalytics,
2651
3240
  can,
@@ -2669,6 +3258,8 @@ export {
2669
3258
  runContentSync,
2670
3259
  sessionKeyFor,
2671
3260
  setServiceClientForTesting,
3261
+ validateCmsEnv,
3262
+ verifyPreviewGrantToken,
2672
3263
  verifyPreviewToken,
2673
3264
  visitorKeyFor
2674
3265
  };