@orion-studios/cms 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,7 +62,8 @@ root for the full procedure.
62
62
  Four tiers, enforced in the API (`src/server/permissions.ts`), UI adapts:
63
63
 
64
64
  - `content` — edit text/images on existing sections (no add/remove/reorder)
65
- - `editor` full content control: structure, new pages, publish
65
+ - `editor`: full content control including structure, new pages, exact-version publish,
66
+ submissions, detailed analytics, and replacement of existing media
66
67
  - `admin` — everything + user management (Users panel in the Studio)
67
68
  - `developer` — full access; the tier Orion Studios keeps for itself
68
69
 
@@ -81,6 +82,28 @@ own role, never delete yourself.
81
82
  | `CMS_STATIC=true` | optional | static mode (no CMS) |
82
83
  | `CMS_MEMORY=true` | optional | force memory mode |
83
84
 
85
+ The memory backend is privileged only outside production. In production it
86
+ can serve public ephemeral forms and events, but it rejects Studio and guarded
87
+ routes, the fixed development token, preview fallback, sync, publish cron,
88
+ notification email, and user management. `NEXT_PUBLIC_CMS_MEMORY` and other
89
+ public flags cannot enable the development credential.
90
+
91
+ ## Publication contract
92
+
93
+ Draft title, SEO, and layout are saved together as one immutable page version.
94
+ Manual publish requires that version's current `draft_version_id`. Scheduling
95
+ stores the selected version ID and later draft edits do not change what will go
96
+ live. A missing, stale, foreign, or already-published version fails with `409`.
97
+ Content roles may create media and edit its metadata. Only editor, admin, and
98
+ developer roles may replace an existing file or read submissions and detailed
99
+ analytics.
100
+
101
+ Run the additive SQL migrations in filename order before deploying code that
102
+ calls the exact-version RPCs. `bootstrap.sql` has the same final schema,
103
+ functions, grants, and published-metadata backfill for new projects. If a
104
+ release fails after the migration, keep the fail-closed RPCs and roll forward.
105
+ Do not restore the old mutable publish signature.
106
+
84
107
  ## Development
85
108
 
86
109
  ```bash
@@ -5,8 +5,8 @@ var toPublicPage = (row) => ({
5
5
  id: String(row.id),
6
6
  slug: String(row.slug),
7
7
  path: String(row.path),
8
- title: String(row.title ?? ""),
9
- seo: row.seo ?? {},
8
+ title: String(row.published_title ?? ""),
9
+ seo: row.published_seo ?? {},
10
10
  layout: row.published_layout ?? []
11
11
  });
12
12
  function createContentClient(options = {}) {
@@ -20,7 +20,7 @@ function createContentClient(options = {}) {
20
20
  const client = createClient(supabaseUrl, anonKey, {
21
21
  auth: { persistSession: false, autoRefreshToken: false }
22
22
  });
23
- const selectPage = "id, slug, path, title, seo, published_layout";
23
+ const selectPage = "id, slug, path, published_title, published_seo, published_layout";
24
24
  return {
25
25
  async getPageByPath(path) {
26
26
  const { data } = await client.from("cms_pages").select(selectPage).eq("path", path).eq("status", "published").maybeSingle();
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CONTENT_CACHE_TAG,
3
3
  createContentClient
4
- } from "../chunk-HVJCF2IZ.js";
4
+ } from "../chunk-AYV6KYDP.js";
5
5
  export {
6
6
  CONTENT_CACHE_TAG,
7
7
  createContentClient
@@ -1,4 +1,4 @@
1
- import * as react_jsx_runtime from 'react/jsx-runtime';
1
+ import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
3
  import { F as FormConfig } from '../submission-CzrfXu17.js';
4
4
 
@@ -40,6 +40,6 @@ type FormRendererProps = {
40
40
  preview?: boolean;
41
41
  onSuccess?: () => void;
42
42
  };
43
- declare function FormRenderer({ slug, config, successMessage, basePath, classNames, title, intro, submitLabel, preview, onSuccess, }: FormRendererProps): react_jsx_runtime.JSX.Element;
43
+ declare function FormRenderer({ slug, config, successMessage, basePath, classNames, title, intro, submitLabel, preview, onSuccess, }: FormRendererProps): react.JSX.Element;
44
44
 
45
45
  export { FormRenderer, type FormRendererClassNames, type FormRendererProps };
@@ -303,7 +303,7 @@ declare function resolveUser(request: Request, client?: SupabaseClient): Promise
303
303
  * of what the Studio UI shows.
304
304
  */
305
305
  type CmsRole = CmsUser['role'];
306
- type CmsAction = 'pages.read' | 'pages.saveDraft' | 'pages.changeStructure' | 'pages.publish' | 'pages.create' | 'pages.delete' | 'pages.restore' | 'globals.read' | 'globals.write' | 'media.read' | 'media.upload' | 'media.update' | 'media.delete' | 'forms.read' | 'forms.write' | 'forms.delete' | 'submissions.read' | 'submissions.manage' | 'redirects.manage' | 'activity.read' | 'analytics.read' | 'sync.run' | 'users.manage';
306
+ type CmsAction = 'pages.read' | 'pages.saveDraft' | 'pages.changeStructure' | 'pages.publish' | 'pages.create' | 'pages.delete' | 'pages.restore' | 'globals.read' | 'globals.write' | 'media.read' | 'media.upload' | 'media.update' | 'media.replace' | 'media.delete' | 'forms.read' | 'forms.write' | 'forms.delete' | 'submissions.read' | 'submissions.manage' | 'redirects.manage' | 'activity.read' | 'analytics.read' | 'sync.run' | 'users.manage';
307
307
  declare function can(user: CmsUser | null, action: CmsAction): boolean;
308
308
  /**
309
309
  * Structural change detection for the content tier: `content` users may edit
@@ -379,8 +379,9 @@ declare function runContentSync(client: SupabaseClient, registry: BlockRegistry,
379
379
  * route factory only enables it when explicitly passed.
380
380
  *
381
381
  * Emulates exactly the supabase-js surface the CMS uses: filtered selects,
382
- * insert/upsert/delete, the four cms_* RPC functions, storage, and
383
- * auth.getUser.
382
+ * insert/upsert/delete, the cms_* RPC functions, storage, and
383
+ * auth.getUser. The fixed development credential is accepted only when the
384
+ * process is not running in production.
384
385
  */
385
386
  type Row = Record<string, unknown>;
386
387
  declare const MEMORY_DEV_TOKEN = "orion-dev-token";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  CONTENT_CACHE_TAG
3
- } from "../chunk-HVJCF2IZ.js";
3
+ } from "../chunk-AYV6KYDP.js";
4
4
  import {
5
5
  createMemoryRateLimitStore,
6
6
  isOriginAllowed,
@@ -393,15 +393,16 @@ var MIN_ROLE = {
393
393
  "media.read": "content",
394
394
  "media.upload": "content",
395
395
  "media.update": "content",
396
+ "media.replace": "editor",
396
397
  "media.delete": "editor",
397
398
  "forms.read": "content",
398
399
  "forms.write": "editor",
399
400
  "forms.delete": "admin",
400
- "submissions.read": "content",
401
+ "submissions.read": "editor",
401
402
  "submissions.manage": "editor",
402
403
  "redirects.manage": "editor",
403
404
  "activity.read": "editor",
404
- "analytics.read": "content",
405
+ "analytics.read": "editor",
405
406
  "sync.run": "admin",
406
407
  "users.manage": "admin"
407
408
  };
@@ -755,10 +756,14 @@ function createDurableRateLimitStore(getClient, options) {
755
756
  function createCmsRoutes(options) {
756
757
  const { registry, allowedOrigins, syncToken, knownGoodEmailDomains } = options;
757
758
  const db = () => options.client ?? getServiceClient();
759
+ const hostedMemoryMode = options.memoryMode === true && process.env.NODE_ENV === "production";
758
760
  const rateLimit = options.rateLimitStore === null ? null : options.rateLimitStore || (options.memoryMode ? createMemoryRateLimitStore() : createDurableRateLimitStore(db, { max: 5, windowMs: 6e4, bucket: "submit" }));
759
- const sendEmail = options.sendEmail === null ? null : options.sendEmail || createResendSender();
761
+ const sendEmail = hostedMemoryMode || options.sendEmail === null ? null : options.sendEmail || createResendSender();
760
762
  const maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
761
- const previewSecret = () => options.previewSecret || process.env.SUPABASE_SERVICE_ROLE_KEY || (options.memoryMode ? "orion-memory-preview-secret" : "");
763
+ const previewSecret = () => {
764
+ if (hostedMemoryMode) return "";
765
+ return options.previewSecret || process.env.SUPABASE_SERVICE_ROLE_KEY || (options.memoryMode ? "orion-memory-preview-secret" : "");
766
+ };
762
767
  const eventsRateLimit = options.memoryMode ? createMemoryRateLimitStore({ max: 120, windowMs: 6e4 }) : createDurableRateLimitStore(db, { max: 120, windowMs: 6e4, bucket: "events" });
763
768
  const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
764
769
  const requestSessionKey = (request) => {
@@ -766,6 +771,7 @@ function createCmsRoutes(options) {
766
771
  return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "", secret);
767
772
  };
768
773
  const guard = async (request, action) => {
774
+ if (hostedMemoryMode) return errors.unauthorized();
769
775
  const user = await resolveUser(request, db());
770
776
  if (!user) return errors.unauthorized();
771
777
  if (!can(user, action)) return errors.forbidden();
@@ -784,19 +790,21 @@ function createCmsRoutes(options) {
784
790
  };
785
791
  const hasDraftChanges = (page) => {
786
792
  if (page.status !== "published") return true;
787
- return JSON.stringify(page.draft_layout ?? []) !== JSON.stringify(page.published_layout ?? []);
793
+ return page.title !== page.published_title || JSON.stringify(page.seo ?? {}) !== JSON.stringify(page.published_seo ?? {}) || JSON.stringify(page.draft_layout ?? []) !== JSON.stringify(page.published_layout ?? []);
788
794
  };
789
795
  const listPages = async (request) => {
790
796
  const auth = await guard(request, "pages.read");
791
797
  if (auth instanceof Response) return auth;
792
798
  const { data, error } = await db().from("cms_pages").select(
793
- "id, slug, path, title, status, builder_owned, updated_at, published_at, publish_at, draft_layout, published_layout"
799
+ "id, slug, path, title, status, builder_owned, updated_at, published_at, publish_at, draft_version_id, published_version_id, scheduled_version_id, published_title, published_seo, draft_layout, published_layout"
794
800
  ).order("path");
795
801
  if (error) return errors.badRequest(error.message);
796
802
  const pages = (data || []).map((page) => {
797
- const { draft_layout, published_layout, ...summary } = page;
803
+ const { draft_layout, published_layout, published_title, published_seo, ...summary } = page;
798
804
  void draft_layout;
799
805
  void published_layout;
806
+ void published_title;
807
+ void published_seo;
800
808
  return { ...summary, has_draft_changes: hasDraftChanges(page) };
801
809
  });
802
810
  return json({ pages });
@@ -824,13 +832,22 @@ function createCmsRoutes(options) {
824
832
  if (!PATH_PATTERN.test(path)) {
825
833
  return errors.badRequest("Path must look like /about or /services/pest-control.");
826
834
  }
827
- const { data, error } = await db().from("cms_pages").insert({ slug, path, title: body.title, seo: isRecord2(body.seo) ? body.seo : {} }).select("*").single();
835
+ const { data: created, error } = await db().from("cms_pages").insert({ slug, path, title: body.title, seo: isRecord2(body.seo) ? body.seo : {} }).select("*").single();
828
836
  if (error) {
829
837
  if (error.message.includes("duplicate")) {
830
838
  return errors.badRequest("A page with this slug or path already exists.");
831
839
  }
832
840
  return errors.badRequest(error.message);
833
841
  }
842
+ if (!created) return errors.badRequest("Could not create page.");
843
+ const { data, error: versionError } = await db().rpc("cms_save_page_draft", {
844
+ p_page_id: created.id,
845
+ p_title: body.title,
846
+ p_seo: isRecord2(body.seo) ? body.seo : {},
847
+ p_layout: [],
848
+ p_actor: auth.user.id
849
+ });
850
+ if (versionError) return errors.badRequest(versionError.message);
834
851
  await logActivity(auth.user, "page.create", path);
835
852
  return json({ page: data }, 201);
836
853
  };
@@ -900,6 +917,7 @@ function createCmsRoutes(options) {
900
917
  }
901
918
  }
902
919
  }
920
+ const hasContentChange = body.title !== void 0 || body.seo !== void 0 || layout !== null;
903
921
  if (wantsSchedule) {
904
922
  if (!can(auth.user, "pages.publish")) return errors.forbidden();
905
923
  let publishAt = null;
@@ -908,15 +926,27 @@ function createCmsRoutes(options) {
908
926
  if (Number.isNaN(parsed)) return errors.badRequest("publishAt must be a valid date.");
909
927
  publishAt = new Date(parsed).toISOString();
910
928
  }
911
- const { error: error2 } = await db().from("cms_pages").update({ publish_at: publishAt }).eq("id", id);
912
- if (error2) return errors.badRequest(error2.message);
929
+ const expectedVersionId = Number(body.expectedVersionId);
930
+ if (publishAt && (!Number.isSafeInteger(expectedVersionId) || expectedVersionId <= 0)) {
931
+ return errors.conflict("Page version conflict: save the draft again before scheduling.");
932
+ }
933
+ const { data: data2, error: error2 } = await db().rpc("cms_schedule_page_publish", {
934
+ p_page_id: id,
935
+ p_expected_version_id: publishAt ? expectedVersionId : null,
936
+ p_publish_at: publishAt,
937
+ p_actor: auth.user.id
938
+ });
939
+ if (error2) {
940
+ if (/page version conflict/i.test(error2.message)) return errors.conflict(error2.message);
941
+ return errors.badRequest(error2.message);
942
+ }
913
943
  await logActivity(
914
944
  auth.user,
915
945
  publishAt ? "page.schedule" : "page.unschedule",
916
946
  `${current?.path ?? id}${publishAt ? ` @ ${publishAt}` : ""}`
917
947
  );
948
+ if (!hasContentChange) return json({ page: data2 });
918
949
  }
919
- const hasContentChange = body.title !== void 0 || body.seo !== void 0 || layout !== null;
920
950
  if (!hasContentChange) {
921
951
  const { data: data2 } = await db().from("cms_pages").select("*").eq("id", id).maybeSingle();
922
952
  return json({ page: data2 });
@@ -934,12 +964,20 @@ function createCmsRoutes(options) {
934
964
  const publishPage = async (request, id) => {
935
965
  const auth = await guard(request, "pages.publish");
936
966
  if (auth instanceof Response) return auth;
967
+ const body = await readJson(request);
968
+ const expectedVersionId = Number(body?.expectedVersionId);
969
+ if (!Number.isSafeInteger(expectedVersionId) || expectedVersionId <= 0) {
970
+ return errors.conflict("Page version conflict: save the draft again before publishing.");
971
+ }
937
972
  const { data, error } = await db().rpc("cms_publish_page", {
938
973
  p_page_id: id,
974
+ p_expected_version_id: expectedVersionId,
939
975
  p_actor: auth.user.id
940
976
  });
941
- if (error) return errors.badRequest(error.message);
942
- await db().from("cms_pages").update({ publish_at: null }).eq("id", id);
977
+ if (error) {
978
+ if (/page version conflict/i.test(error.message)) return errors.conflict(error.message);
979
+ return errors.badRequest(error.message);
980
+ }
943
981
  await logActivity(auth.user, "page.publish", String(data?.path ?? id));
944
982
  await revalidateContent();
945
983
  return json({ page: data });
@@ -947,7 +985,12 @@ function createCmsRoutes(options) {
947
985
  const unpublishPage = async (request, id) => {
948
986
  const auth = await guard(request, "pages.publish");
949
987
  if (auth instanceof Response) return auth;
950
- const { data, error } = await db().from("cms_pages").update({ status: "draft", publish_at: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).select("*").single();
988
+ const { data, error } = await db().from("cms_pages").update({
989
+ status: "draft",
990
+ publish_at: null,
991
+ scheduled_version_id: null,
992
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
993
+ }).eq("id", id).select("*").single();
951
994
  if (error) return errors.badRequest(error.message);
952
995
  await logActivity(auth.user, "page.unpublish", String(data?.path ?? id));
953
996
  await revalidateContent();
@@ -965,7 +1008,7 @@ function createCmsRoutes(options) {
965
1008
  if (!existing) break;
966
1009
  slug = `${baseSlug}-${attempt}`;
967
1010
  }
968
- const { data, error } = await db().from("cms_pages").insert({
1011
+ const { data: created, error } = await db().from("cms_pages").insert({
969
1012
  slug,
970
1013
  path: `/${slug}`,
971
1014
  title: `${source.title} (copy)`,
@@ -975,6 +1018,15 @@ function createCmsRoutes(options) {
975
1018
  builder_owned: true
976
1019
  }).select("*").single();
977
1020
  if (error) return errors.badRequest(error.message);
1021
+ if (!created) return errors.badRequest("Could not duplicate page.");
1022
+ const { data, error: versionError } = await db().rpc("cms_save_page_draft", {
1023
+ p_page_id: created.id,
1024
+ p_title: created.title,
1025
+ p_seo: created.seo ?? {},
1026
+ p_layout: created.draft_layout ?? [],
1027
+ p_actor: auth.user.id
1028
+ });
1029
+ if (versionError) return errors.badRequest(versionError.message);
978
1030
  await logActivity(auth.user, "page.duplicate", `${source.path} \u2192 /${slug}`);
979
1031
  return json({ page: data }, 201);
980
1032
  };
@@ -999,6 +1051,7 @@ function createCmsRoutes(options) {
999
1051
  return json({ token, path: data.path, url: `${data.path}?preview=${encodeURIComponent(token)}` });
1000
1052
  };
1001
1053
  const previewPage = async (request) => {
1054
+ if (hostedMemoryMode) return errors.notFound();
1002
1055
  const secret = previewSecret();
1003
1056
  if (!secret) return errors.notFound();
1004
1057
  const token = new URL(request.url).searchParams.get("token") || "";
@@ -1186,7 +1239,7 @@ function createCmsRoutes(options) {
1186
1239
  return json({ usage: await findMediaUsage(id) });
1187
1240
  };
1188
1241
  const replaceMedia = async (request, id) => {
1189
- const auth = await guard(request, "media.upload");
1242
+ const auth = await guard(request, "media.replace");
1190
1243
  if (auth instanceof Response) return auth;
1191
1244
  const { data: doc } = await db().from("cms_media").select("*").eq("id", id).maybeSingle();
1192
1245
  if (!doc) return errors.notFound();
@@ -1244,6 +1297,7 @@ function createCmsRoutes(options) {
1244
1297
  if (auth instanceof Response) return auth;
1245
1298
  const { data: forms, error } = await db().from("cms_forms").select("id, slug, title, config, success_message, updated_at").order("title");
1246
1299
  if (error) return errors.badRequest(error.message);
1300
+ if (!can(auth.user, "submissions.read")) return json({ forms: forms || [] });
1247
1301
  const { data: submissions } = await db().from("cms_form_submissions").select("form_id, read_at").limit(1e4);
1248
1302
  const counts = /* @__PURE__ */ new Map();
1249
1303
  for (const submission of submissions || []) {
@@ -1346,7 +1400,7 @@ function createCmsRoutes(options) {
1346
1400
  return json({ submissions: rows.slice(0, limit), hasMore });
1347
1401
  };
1348
1402
  const updateSubmission = async (request, id) => {
1349
- const auth = await guard(request, "submissions.read");
1403
+ const auth = await guard(request, "submissions.manage");
1350
1404
  if (auth instanceof Response) return auth;
1351
1405
  const body = await readJson(request);
1352
1406
  if (!body || typeof body.read !== "boolean") return errors.badRequest("read boolean required.");
@@ -1580,6 +1634,7 @@ function createCmsRoutes(options) {
1580
1634
  return published;
1581
1635
  };
1582
1636
  const cronPublishDue = async (request) => {
1637
+ if (hostedMemoryMode) return errors.forbidden();
1583
1638
  const bearer = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
1584
1639
  let authorized = tokenEquals(bearer, syncToken || "") || tokenEquals(bearer, process.env.CRON_SECRET || "");
1585
1640
  if (!authorized) {
@@ -1596,19 +1651,29 @@ function createCmsRoutes(options) {
1596
1651
  if (auth instanceof Response) return auth;
1597
1652
  await publishDuePages().catch(() => void 0);
1598
1653
  const { data: pages } = await db().from("cms_pages").select(
1599
- "id, title, path, status, updated_at, published_at, publish_at, draft_layout, published_layout"
1654
+ "id, title, path, status, updated_at, published_at, publish_at, published_title, published_seo, draft_layout, published_layout"
1600
1655
  );
1601
1656
  const allPages = pages || [];
1602
1657
  const pendingDrafts = allPages.filter((page) => hasDraftChanges(page));
1603
- const recentPages = [...allPages].sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))).slice(0, 5).map(({ draft_layout, published_layout, ...summary }) => {
1658
+ const recentPages = [...allPages].sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))).slice(0, 5).map(({ draft_layout, published_layout, published_title, published_seo, ...summary }) => {
1604
1659
  void draft_layout;
1605
1660
  void published_layout;
1661
+ void published_title;
1662
+ void published_seo;
1606
1663
  return summary;
1607
1664
  });
1608
1665
  const lastPublishedAt = allPages.map((page) => page.published_at).filter(Boolean).sort().pop();
1609
- const { data: submissions } = await db().from("cms_form_submissions").select("id, form_id, data, read_at, created_at").order("created_at", { ascending: false }).limit(200);
1610
- const allSubmissions = submissions || [];
1611
- const unread = allSubmissions.filter((submission) => !submission.read_at);
1666
+ let submissionsSummary;
1667
+ if (can(auth.user, "submissions.read")) {
1668
+ const { data: submissions } = await db().from("cms_form_submissions").select("id, form_id, data, read_at, created_at").order("created_at", { ascending: false }).limit(200);
1669
+ const allSubmissions = submissions || [];
1670
+ const unread = allSubmissions.filter((submission) => !submission.read_at);
1671
+ submissionsSummary = {
1672
+ total: allSubmissions.length,
1673
+ unread: unread.length,
1674
+ recent: allSubmissions.slice(0, 5)
1675
+ };
1676
+ }
1612
1677
  let activity = [];
1613
1678
  if (can(auth.user, "activity.read")) {
1614
1679
  const { data: events } = await db().from("cms_activity").select("id, actor_name, action, subject, created_at").order("created_at", { ascending: false }).limit(12);
@@ -1627,11 +1692,7 @@ function createCmsRoutes(options) {
1627
1692
  recent: recentPages,
1628
1693
  lastPublishedAt: lastPublishedAt ?? null
1629
1694
  },
1630
- submissions: {
1631
- total: allSubmissions.length,
1632
- unread: unread.length,
1633
- recent: allSubmissions.slice(0, 5)
1634
- },
1695
+ ...submissionsSummary ? { submissions: submissionsSummary } : {},
1635
1696
  activity
1636
1697
  });
1637
1698
  };
@@ -1747,6 +1808,7 @@ function createCmsRoutes(options) {
1747
1808
  return json({ success: true });
1748
1809
  };
1749
1810
  const runSync = async (request) => {
1811
+ if (hostedMemoryMode) return errors.forbidden();
1750
1812
  const bearer = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
1751
1813
  let authorized = tokenEquals(bearer, syncToken || "");
1752
1814
  if (!authorized) {
@@ -1859,6 +1921,7 @@ function createCmsRoutes(options) {
1859
1921
  }
1860
1922
  if (head === "sync" && method === "POST") return runSync(request);
1861
1923
  if (head === "me" && method === "GET") {
1924
+ if (hostedMemoryMode) return errors.unauthorized();
1862
1925
  const user = await resolveUser(request, db());
1863
1926
  if (!user) return errors.unauthorized();
1864
1927
  return json({ user });
@@ -1995,6 +2058,12 @@ var MemoryQuery = class _MemoryQuery {
1995
2058
  }
1996
2059
  values.draft_layout = values.draft_layout ?? [];
1997
2060
  values.published_layout = values.published_layout ?? null;
2061
+ values.published_title = values.published_title ?? null;
2062
+ values.published_seo = values.published_seo ?? null;
2063
+ values.draft_version_id = values.draft_version_id ?? null;
2064
+ values.published_version_id = values.published_version_id ?? null;
2065
+ values.scheduled_version_id = values.scheduled_version_id ?? null;
2066
+ values.publish_at = values.publish_at ?? null;
1998
2067
  values.status = values.status ?? "draft";
1999
2068
  values.builder_owned = values.builder_owned ?? false;
2000
2069
  values.seo = values.seo ?? {};
@@ -2059,15 +2128,19 @@ var MemoryStore = class {
2059
2128
  return this.serial;
2060
2129
  }
2061
2130
  };
2131
+ var versionConflict = (message) => ({
2132
+ data: null,
2133
+ error: { message: `Page version conflict: ${message}` }
2134
+ });
2062
2135
  function runRpc(store, fn, args = {}) {
2063
2136
  const pages = store.table("cms_pages");
2064
2137
  const versions = store.table("cms_page_versions");
2065
2138
  const snapshot = (page, kind, layout) => {
2066
2139
  const latest = [...versions].filter((v) => v.page_id === page.id).pop();
2067
2140
  if ((kind === "draft" || kind === "sync") && latest && JSON.stringify(latest.title) === JSON.stringify(page.title ?? "") && JSON.stringify(latest.seo) === JSON.stringify(page.seo ?? {}) && JSON.stringify(latest.layout) === JSON.stringify(layout ?? [])) {
2068
- return;
2141
+ return latest;
2069
2142
  }
2070
- versions.push({
2143
+ const version = {
2071
2144
  id: store.nextSerial(),
2072
2145
  page_id: page.id,
2073
2146
  kind,
@@ -2076,7 +2149,17 @@ function runRpc(store, fn, args = {}) {
2076
2149
  layout: layout ?? [],
2077
2150
  created_by: args.p_actor ?? null,
2078
2151
  created_at: nowIso()
2079
- });
2152
+ };
2153
+ versions.push(version);
2154
+ return version;
2155
+ };
2156
+ const reviewedVersion = (page, expectedVersionId) => {
2157
+ const id = Number(expectedVersionId);
2158
+ if (!Number.isSafeInteger(id) || id <= 0 || page.draft_version_id !== id) return null;
2159
+ const version = versions.find((row) => row.id === id && row.page_id === page.id);
2160
+ if (!version) return null;
2161
+ if (version.title !== (page.title ?? "") || JSON.stringify(version.seo ?? {}) !== JSON.stringify(page.seo ?? {}) || JSON.stringify(version.layout ?? []) !== JSON.stringify(page.draft_layout ?? [])) return null;
2162
+ return version;
2080
2163
  };
2081
2164
  switch (fn) {
2082
2165
  case "cms_save_page_draft": {
@@ -2087,17 +2170,43 @@ function runRpc(store, fn, args = {}) {
2087
2170
  if (args.p_layout != null) page.draft_layout = args.p_layout;
2088
2171
  page.builder_owned = Boolean(page.builder_owned) || args.p_mark_builder_owned !== false;
2089
2172
  page.updated_at = nowIso();
2090
- snapshot(page, "draft", page.draft_layout);
2173
+ const version = snapshot(page, "draft", page.draft_layout);
2174
+ page.draft_version_id = version.id;
2091
2175
  return { data: page, error: null };
2092
2176
  }
2093
2177
  case "cms_publish_page": {
2094
2178
  const page = pages.find((row) => row.id === args.p_page_id);
2095
2179
  if (!page) return { data: null, error: { message: `page ${args.p_page_id} not found` } };
2096
- page.published_layout = page.draft_layout;
2180
+ const version = reviewedVersion(page, args.p_expected_version_id);
2181
+ if (!version) return versionConflict("the expected draft is missing or stale");
2182
+ if (page.status === "published" && page.published_version_id === version.id) {
2183
+ return versionConflict("the expected draft is already published");
2184
+ }
2185
+ page.published_title = version.title;
2186
+ page.published_seo = version.seo;
2187
+ page.published_layout = version.layout;
2188
+ page.published_version_id = version.id;
2097
2189
  page.status = "published";
2098
2190
  page.published_at = nowIso();
2191
+ page.publish_at = null;
2192
+ page.scheduled_version_id = null;
2193
+ page.updated_at = nowIso();
2194
+ return { data: page, error: null };
2195
+ }
2196
+ case "cms_schedule_page_publish": {
2197
+ const page = pages.find((row) => row.id === args.p_page_id);
2198
+ if (!page) return { data: null, error: { message: `page ${args.p_page_id} not found` } };
2199
+ if (args.p_publish_at == null) {
2200
+ page.publish_at = null;
2201
+ page.scheduled_version_id = null;
2202
+ page.updated_at = nowIso();
2203
+ return { data: page, error: null };
2204
+ }
2205
+ const version = reviewedVersion(page, args.p_expected_version_id);
2206
+ if (!version) return versionConflict("the expected draft is missing or stale");
2207
+ page.publish_at = args.p_publish_at;
2208
+ page.scheduled_version_id = version.id;
2099
2209
  page.updated_at = nowIso();
2100
- snapshot(page, "publish", page.published_layout);
2101
2210
  return { data: page, error: null };
2102
2211
  }
2103
2212
  case "cms_update_global": {
@@ -2146,12 +2255,19 @@ function runRpc(store, fn, args = {}) {
2146
2255
  for (const page of pages) {
2147
2256
  const at = typeof page.publish_at === "string" ? Date.parse(page.publish_at) : NaN;
2148
2257
  if (Number.isNaN(at) || at > nowMs) continue;
2149
- page.published_layout = page.draft_layout;
2258
+ const version = versions.find(
2259
+ (row) => row.id === page.scheduled_version_id && row.page_id === page.id
2260
+ );
2261
+ if (!version) continue;
2262
+ page.published_title = version.title;
2263
+ page.published_seo = version.seo;
2264
+ page.published_layout = version.layout;
2265
+ page.published_version_id = version.id;
2150
2266
  page.status = "published";
2151
2267
  page.published_at = nowIso();
2152
2268
  page.publish_at = null;
2269
+ page.scheduled_version_id = null;
2153
2270
  page.updated_at = nowIso();
2154
- snapshot(page, "publish", page.published_layout);
2155
2271
  published.push(String(page.path));
2156
2272
  }
2157
2273
  return { data: published, error: null };
@@ -2166,7 +2282,8 @@ function runRpc(store, fn, args = {}) {
2166
2282
  page.seo = version.seo;
2167
2283
  page.builder_owned = true;
2168
2284
  page.updated_at = nowIso();
2169
- snapshot(page, "restore", version.layout);
2285
+ const restored = snapshot(page, "restore", version.layout);
2286
+ page.draft_version_id = restored.id;
2170
2287
  return { data: page, error: null };
2171
2288
  }
2172
2289
  case "cms_sync_page": {
@@ -2179,12 +2296,18 @@ function runRpc(store, fn, args = {}) {
2179
2296
  title: args.p_title,
2180
2297
  seo: args.p_seo ?? {},
2181
2298
  draft_layout: args.p_layout ?? [],
2299
+ published_title: args.p_title,
2300
+ published_seo: args.p_seo ?? {},
2182
2301
  published_layout: args.p_layout ?? [],
2183
2302
  status: "published",
2184
2303
  builder_owned: false,
2185
2304
  created_at: nowIso(),
2186
2305
  updated_at: nowIso(),
2187
- published_at: nowIso()
2306
+ published_at: nowIso(),
2307
+ draft_version_id: null,
2308
+ published_version_id: null,
2309
+ scheduled_version_id: null,
2310
+ publish_at: null
2188
2311
  };
2189
2312
  pages.push(page);
2190
2313
  } else if (!page.builder_owned) {
@@ -2192,10 +2315,16 @@ function runRpc(store, fn, args = {}) {
2192
2315
  page.title = args.p_title;
2193
2316
  page.seo = args.p_seo ?? {};
2194
2317
  page.draft_layout = args.p_layout ?? [];
2318
+ page.published_title = args.p_title;
2319
+ page.published_seo = args.p_seo ?? {};
2195
2320
  page.published_layout = args.p_layout ?? [];
2196
2321
  page.updated_at = nowIso();
2197
2322
  }
2198
- snapshot(page, "sync", page.draft_layout);
2323
+ const version = snapshot(page, "sync", page.draft_layout);
2324
+ if (!page.builder_owned) {
2325
+ page.draft_version_id = version.id;
2326
+ page.published_version_id = version.id;
2327
+ }
2199
2328
  return { data: page, error: null };
2200
2329
  }
2201
2330
  default:
@@ -2214,7 +2343,7 @@ function createMemoryCms() {
2214
2343
  from: (table) => new MemoryQuery(store, table),
2215
2344
  rpc: async (fn, args) => runRpc(store, fn, args),
2216
2345
  auth: {
2217
- getUser: async (token) => token === MEMORY_DEV_TOKEN ? { data: { user: { id: DEV_USER_ID, email: "dev@local" } }, error: null } : { data: { user: null }, error: { message: "invalid token" } },
2346
+ getUser: async (token) => process.env.NODE_ENV !== "production" && token === MEMORY_DEV_TOKEN ? { data: { user: { id: DEV_USER_ID, email: "dev@local" } }, error: null } : { data: { user: null }, error: { message: "invalid token" } },
2218
2347
  admin: {
2219
2348
  listUsers: async () => ({ data: { users: [...store.table("auth_users")] }, error: null }),
2220
2349
  createUser: async (attributes) => {
@@ -1,4 +1,4 @@
1
- import * as react_jsx_runtime from 'react/jsx-runtime';
1
+ import * as react from 'react';
2
2
  import { ComponentType, InputHTMLAttributes } from 'react';
3
3
  import { z } from 'zod';
4
4
  import { SupabaseClient, Session } from '@supabase/supabase-js';
@@ -138,7 +138,7 @@ type StudioConfig = {
138
138
  /** Supabase project URL used for media transform URLs. Defaults to env. */
139
139
  supabaseUrl?: string;
140
140
  };
141
- declare function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }: StudioConfig): react_jsx_runtime.JSX.Element;
141
+ declare function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }: StudioConfig): react.JSX.Element;
142
142
 
143
143
  declare function getBrowserSupabase(): SupabaseClient;
144
144
  declare function useStudioSession(): {
@@ -149,7 +149,7 @@ declare function useStudioSession(): {
149
149
  };
150
150
 
151
151
  type PasswordInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
152
- declare function PasswordInput({ className, ...props }: PasswordInputProps): react_jsx_runtime.JSX.Element;
152
+ declare function PasswordInput({ className, ...props }: PasswordInputProps): react.JSX.Element;
153
153
 
154
154
  type AnalyticsKpis = {
155
155
  visitors: number;
@@ -228,11 +228,16 @@ type StudioPageSummary = {
228
228
  updated_at: string;
229
229
  published_at: string | null;
230
230
  publish_at: string | null;
231
+ draft_version_id: number | null;
232
+ published_version_id: number | null;
233
+ scheduled_version_id: number | null;
231
234
  /** Computed by GET /pages: draft layout differs from the live one. */
232
235
  has_draft_changes?: boolean;
233
236
  };
234
237
  type StudioPage = StudioPageSummary & {
235
238
  seo: Record<string, unknown>;
239
+ published_title: string | null;
240
+ published_seo: Record<string, unknown> | null;
236
241
  draft_layout: PageLayout;
237
242
  published_layout: PageLayout | null;
238
243
  };
@@ -314,7 +319,7 @@ type StudioDashboard = {
314
319
  recent: StudioPageSummary[];
315
320
  lastPublishedAt: string | null;
316
321
  };
317
- submissions: {
322
+ submissions?: {
318
323
  total: number;
319
324
  unread: number;
320
325
  recent: StudioSubmission[];
@@ -370,10 +375,14 @@ declare function createStudioApi(options: {
370
375
  slug?: string;
371
376
  path?: string;
372
377
  publishAt?: string | null;
378
+ expectedVersionId?: number;
373
379
  }) => Promise<{
374
380
  page: StudioPage;
375
381
  }>;
376
- publish: (id: string) => Promise<{
382
+ publish: (id: string, expectedVersionId: number) => Promise<{
383
+ page: StudioPage;
384
+ }>;
385
+ schedulePublish: (id: string, publishAt: string | null, expectedVersionId?: number) => Promise<{
377
386
  page: StudioPage;
378
387
  }>;
379
388
  unpublish: (id: string) => Promise<{