@orion-studios/cms 0.5.2 → 0.5.4

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.
@@ -389,7 +389,7 @@ var MIN_ROLE = {
389
389
  "pages.delete": "admin",
390
390
  "pages.restore": "editor",
391
391
  "globals.read": "content",
392
- "globals.write": "content",
392
+ "globals.write": "editor",
393
393
  "media.read": "content",
394
394
  "media.upload": "content",
395
395
  "media.update": "content",
@@ -663,6 +663,8 @@ var errors = {
663
663
  var isRecord2 = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
664
664
  var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
665
665
  var PATH_PATTERN = /^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/;
666
+ var USER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
667
+ var canonicalizeUserId = (value) => USER_ID_PATTERN.test(value) ? value.toLowerCase() : null;
666
668
  var DEFAULT_MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
667
669
  var ALLOWED_UPLOAD_TYPES = /* @__PURE__ */ new Set([
668
670
  "image/jpeg",
@@ -1706,23 +1708,30 @@ function createCmsRoutes(options) {
1706
1708
  const listUsers = async (request) => {
1707
1709
  const auth = await guard(request, "users.manage");
1708
1710
  if (auth instanceof Response) return auth;
1709
- const { data: authData, error: authError } = await db().auth.admin.listUsers({ perPage: 1e3 });
1710
- if (authError) return errors.badRequest(authError.message);
1711
1711
  const { data: profiles, error: profileError } = await db().from("cms_profiles").select("user_id, role, name");
1712
1712
  if (profileError) return errors.badRequest(profileError.message);
1713
- const roleById = new Map(
1714
- (profiles || []).map((profile) => [profile.user_id, profile])
1715
- );
1716
- const users = authData.users.map((user) => {
1717
- const profile = roleById.get(user.id);
1718
- return {
1713
+ const profileById = /* @__PURE__ */ new Map();
1714
+ for (const profile of profiles || []) {
1715
+ const userId = typeof profile.user_id === "string" ? canonicalizeUserId(profile.user_id) : null;
1716
+ if (!userId || !isCmsRole(profile.role)) continue;
1717
+ profileById.set(userId, {
1718
+ role: profile.role,
1719
+ name: typeof profile.name === "string" ? profile.name : ""
1720
+ });
1721
+ }
1722
+ const { data: authData, error: authError } = await db().auth.admin.listUsers({ perPage: 1e3 });
1723
+ if (authError) return errors.badRequest(authError.message);
1724
+ const users = authData.users.flatMap((user) => {
1725
+ const canonicalUserId = canonicalizeUserId(user.id);
1726
+ const profile = canonicalUserId ? profileById.get(canonicalUserId) : void 0;
1727
+ return profile ? [{
1719
1728
  id: user.id,
1720
1729
  email: user.email ?? null,
1721
- name: profile?.name || "",
1722
- role: profile?.role ?? null,
1730
+ name: profile.name,
1731
+ role: profile.role,
1723
1732
  created_at: user.created_at ?? null,
1724
1733
  last_sign_in_at: user.last_sign_in_at ?? null
1725
- };
1734
+ }] : [];
1726
1735
  });
1727
1736
  return json({ users, assignableRoles: assignableRoles(auth.user.role) });
1728
1737
  };
@@ -1757,20 +1766,31 @@ function createCmsRoutes(options) {
1757
1766
  await logActivity(auth.user, "user.create", email);
1758
1767
  return json({ user: { id: userId, email, name, role } }, 201);
1759
1768
  };
1760
- const targetRole = async (userId) => {
1761
- const { data } = await db().from("cms_profiles").select("role").eq("user_id", userId).maybeSingle();
1762
- return data?.role ?? null;
1769
+ const loadMembership = async (userId) => {
1770
+ const { data, error } = await db().from("cms_profiles").select("role").eq("user_id", userId).maybeSingle();
1771
+ if (error) return { status: "query-error", message: error.message };
1772
+ if (!data) return { status: "missing" };
1773
+ if (!isCmsRole(data.role)) return { status: "invalid-role" };
1774
+ return { status: "valid", role: data.role };
1775
+ };
1776
+ const membershipError = (membership) => {
1777
+ if (membership.status === "missing") return errors.notFound();
1778
+ if (membership.status === "invalid-role") return errors.badRequest("Invalid CMS profile role.");
1779
+ return errors.badRequest(membership.message);
1763
1780
  };
1764
1781
  const updateUser = async (request, userId) => {
1765
1782
  const auth = await guard(request, "users.manage");
1766
1783
  if (auth instanceof Response) return auth;
1784
+ const canonicalUserId = canonicalizeUserId(userId);
1785
+ if (!canonicalUserId) return errors.badRequest("Invalid user ID.");
1767
1786
  const body = await readJson(request);
1768
1787
  if (!body) return errors.badRequest("Invalid body.");
1769
- const current = await targetRole(userId);
1770
- if (current && !outranksOrEqual(auth.user.role, current)) return errors.forbidden();
1788
+ const membership = await loadMembership(canonicalUserId);
1789
+ if (membership.status !== "valid") return membershipError(membership);
1790
+ if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
1771
1791
  if (body.role !== void 0) {
1772
1792
  if (!isCmsRole(body.role)) return errors.badRequest("Invalid role.");
1773
- if (userId === auth.user.id && body.role !== auth.user.role) {
1793
+ if (canonicalUserId === auth.user.id && body.role !== auth.user.role) {
1774
1794
  return errors.badRequest("You can't change your own role.");
1775
1795
  }
1776
1796
  if (!outranksOrEqual(auth.user.role, body.role)) {
@@ -1779,32 +1799,37 @@ function createCmsRoutes(options) {
1779
1799
  }
1780
1800
  if (typeof body.password === "string") {
1781
1801
  if (body.password.length < 8) return errors.badRequest("Password must be at least 8 characters.");
1782
- const { error: passwordError } = await db().auth.admin.updateUserById(userId, {
1802
+ const { error: passwordError } = await db().auth.admin.updateUserById(canonicalUserId, {
1783
1803
  password: body.password
1784
1804
  });
1785
1805
  if (passwordError) return errors.badRequest(passwordError.message);
1786
1806
  }
1787
1807
  if (body.role !== void 0 || typeof body.name === "string") {
1788
- const patch = { user_id: userId };
1808
+ const patch = {};
1789
1809
  if (body.role !== void 0) patch.role = body.role;
1790
1810
  if (typeof body.name === "string") patch.name = body.name.trim();
1791
- const { error: profileError } = await db().from("cms_profiles").upsert(patch, { onConflict: "user_id" });
1811
+ const { error: profileError } = await db().from("cms_profiles").update(patch).eq("user_id", canonicalUserId);
1792
1812
  if (profileError) return errors.badRequest(profileError.message);
1793
1813
  }
1794
- await logActivity(auth.user, "user.update", userId);
1814
+ await logActivity(auth.user, "user.update", canonicalUserId);
1795
1815
  return json({ success: true });
1796
1816
  };
1797
1817
  const deleteUser = async (request, userId) => {
1798
1818
  const auth = await guard(request, "users.manage");
1799
1819
  if (auth instanceof Response) return auth;
1800
- if (userId === auth.user.id) return errors.badRequest("You can't remove your own account.");
1801
- const current = await targetRole(userId);
1802
- if (current && !outranksOrEqual(auth.user.role, current)) return errors.forbidden();
1803
- const { error: authError } = await db().auth.admin.deleteUser(userId);
1820
+ const canonicalUserId = canonicalizeUserId(userId);
1821
+ if (!canonicalUserId) return errors.badRequest("Invalid user ID.");
1822
+ const membership = await loadMembership(canonicalUserId);
1823
+ if (membership.status !== "valid") return membershipError(membership);
1824
+ if (canonicalUserId === auth.user.id) {
1825
+ return errors.badRequest("You can't remove your own account.");
1826
+ }
1827
+ if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
1828
+ const { error: authError } = await db().auth.admin.deleteUser(canonicalUserId);
1804
1829
  if (authError) return errors.badRequest(authError.message);
1805
- const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", userId);
1830
+ const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", canonicalUserId);
1806
1831
  if (profileError) return errors.badRequest(profileError.message);
1807
- await logActivity(auth.user, "user.delete", userId);
1832
+ await logActivity(auth.user, "user.delete", canonicalUserId);
1808
1833
  return json({ success: true });
1809
1834
  };
1810
1835
  const runSync = async (request) => {
@@ -2204,6 +2229,9 @@ function runRpc(store, fn, args = {}) {
2204
2229
  }
2205
2230
  const version = reviewedVersion(page, args.p_expected_version_id);
2206
2231
  if (!version) return versionConflict("the expected draft is missing or stale");
2232
+ if (page.published_version_id === version.id) {
2233
+ return versionConflict("the expected draft is already published");
2234
+ }
2207
2235
  page.publish_at = args.p_publish_at;
2208
2236
  page.scheduled_version_id = version.id;
2209
2237
  page.updated_at = nowIso();
@@ -149,7 +149,7 @@ var MIN_ROLE = {
149
149
  "pages.delete": "admin",
150
150
  "pages.restore": "editor",
151
151
  "globals.read": "content",
152
- "globals.write": "content",
152
+ "globals.write": "editor",
153
153
  "media.read": "content",
154
154
  "media.upload": "content",
155
155
  "media.update": "content",
@@ -179,13 +179,14 @@ var SECTION_ACTION = {
179
179
  forms: "forms.read",
180
180
  submissions: "submissions.read",
181
181
  analytics: "analytics.read",
182
- globals: "globals.read",
182
+ globals: "globals.write",
183
183
  redirects: "redirects.manage",
184
184
  users: "users.manage"
185
185
  };
186
186
  var userForRole = (role) => ({ id: "", email: null, name: "", role });
187
187
  var studioCan = (role, action) => can(userForRole(role), action);
188
188
  var canViewStudioSection = (role, section) => studioCan(role, SECTION_ACTION[section]);
189
+ var resolveStudioSection = (role, section) => role && canViewStudioSection(role, section) ? section : "dashboard";
189
190
  var studioAccess = (role) => ({
190
191
  changePageStructure: studioCan(role, "pages.changeStructure"),
191
192
  publishPages: studioCan(role, "pages.publish"),
@@ -3728,11 +3729,15 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3728
3729
  useEffect11(() => {
3729
3730
  if (!session) {
3730
3731
  setRole(null);
3732
+ setSection("dashboard");
3733
+ setOpenPageId(null);
3734
+ setSubmissionsFormId(null);
3731
3735
  return;
3732
3736
  }
3733
3737
  api.me().then(
3734
3738
  ({ user }) => {
3735
3739
  setRole(user.role);
3740
+ setSection((currentSection) => resolveStudioSection(user.role, currentSection));
3736
3741
  refreshMedia();
3737
3742
  refreshUnread();
3738
3743
  refreshPages();
@@ -3784,6 +3789,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3784
3789
  if (!role) return /* @__PURE__ */ jsx12("div", { className: "ost-root ost-loading", children: "Loading Studio\u2026" });
3785
3790
  const access = studioAccess(role);
3786
3791
  const nav = NAV.filter((item) => canViewStudioSection(role, item.key));
3792
+ const selectedSection = resolveStudioSection(role, section);
3787
3793
  const goTo = (key) => {
3788
3794
  setSection(key);
3789
3795
  setOpenPageId(null);
@@ -3806,7 +3812,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3806
3812
  nav.map((item) => /* @__PURE__ */ jsxs12(
3807
3813
  "button",
3808
3814
  {
3809
- className: `ost-nav-item${section === item.key && !openPageId ? " is-active" : ""}`,
3815
+ className: `ost-nav-item${selectedSection === item.key && !openPageId ? " is-active" : ""}`,
3810
3816
  onClick: () => goTo(item.key),
3811
3817
  type: "button",
3812
3818
  children: [
@@ -3841,7 +3847,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3841
3847
  role
3842
3848
  },
3843
3849
  openPageId
3844
- ) : section === "pages" ? /* @__PURE__ */ jsx12(PagesView, { api, canCreate: access.createPages, onOpen: openPage }) : section === "media" ? /* @__PURE__ */ jsx12(
3850
+ ) : selectedSection === "pages" ? /* @__PURE__ */ jsx12(PagesView, { api, canCreate: access.createPages, onOpen: openPage }) : selectedSection === "media" ? /* @__PURE__ */ jsx12(
3845
3851
  MediaView,
3846
3852
  {
3847
3853
  api,
@@ -3852,7 +3858,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3852
3858
  onChanged: refreshMedia,
3853
3859
  onUpload: uploadMedia
3854
3860
  }
3855
- ) : section === "forms" ? /* @__PURE__ */ jsx12(
3861
+ ) : selectedSection === "forms" ? /* @__PURE__ */ jsx12(
3856
3862
  FormsView,
3857
3863
  {
3858
3864
  api,
@@ -3864,7 +3870,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3864
3870
  setSection("submissions");
3865
3871
  }
3866
3872
  }
3867
- ) : section === "submissions" && access.readSubmissions ? /* @__PURE__ */ jsx12(
3873
+ ) : selectedSection === "submissions" && access.readSubmissions ? /* @__PURE__ */ jsx12(
3868
3874
  SubmissionsView,
3869
3875
  {
3870
3876
  api,
@@ -3873,7 +3879,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3873
3879
  onUnreadChanged: refreshUnread
3874
3880
  },
3875
3881
  submissionsFormId || "all"
3876
- ) : section === "analytics" && access.readAnalytics ? /* @__PURE__ */ jsx12(AnalyticsView, { api }) : section === "globals" ? /* @__PURE__ */ jsx12(
3882
+ ) : selectedSection === "analytics" && access.readAnalytics ? /* @__PURE__ */ jsx12(AnalyticsView, { api }) : selectedSection === "globals" ? /* @__PURE__ */ jsx12(
3877
3883
  GlobalsView,
3878
3884
  {
3879
3885
  api,
@@ -3883,7 +3889,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3883
3889
  onUploadMedia: uploadMedia,
3884
3890
  pages
3885
3891
  }
3886
- ) : section === "redirects" && access.manageRedirects ? /* @__PURE__ */ jsx12(RedirectsView, { api }) : section === "users" && access.manageUsers ? /* @__PURE__ */ jsx12(UsersView, { api, meId: session.user.id }) : /* @__PURE__ */ jsx12(
3892
+ ) : selectedSection === "redirects" && access.manageRedirects ? /* @__PURE__ */ jsx12(RedirectsView, { api }) : selectedSection === "users" && access.manageUsers ? /* @__PURE__ */ jsx12(UsersView, { api, meId: session.user.id }) : /* @__PURE__ */ jsx12(
3887
3893
  DashboardView,
3888
3894
  {
3889
3895
  api,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orion-studios/cms",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Orion CMS v2 core engine \u2014 JSONB content model on Supabase primitives",
5
5
  "type": "module",
6
6
  "exports": {
package/sql/bootstrap.sql CHANGED
@@ -152,6 +152,16 @@ set publish_at = null,
152
152
  where publish_at is not null
153
153
  and scheduled_version_id is null;
154
154
 
155
+ -- A published version is not a future publication candidate. Clear invalid
156
+ -- schedules left by versions of the scheduling function that omitted this
157
+ -- guard, so repeated bootstrap runs repair existing databases fail closed.
158
+ update cms_pages
159
+ set publish_at = null,
160
+ scheduled_version_id = null,
161
+ updated_at = now()
162
+ where scheduled_version_id is not null
163
+ and scheduled_version_id = published_version_id;
164
+
155
165
  create unique index if not exists cms_page_versions_page_id_id_idx
156
166
  on cms_page_versions (page_id, id);
157
167
  create index if not exists cms_pages_scheduled_publish_due_idx
@@ -624,6 +634,10 @@ begin
624
634
  raise exception 'Page version conflict: expected draft is missing or stale';
625
635
  end if;
626
636
 
637
+ if current_page.published_version_id = p_expected_version_id then
638
+ raise exception 'Page version conflict: expected draft is already published';
639
+ end if;
640
+
627
641
  update cms_pages
628
642
  set publish_at = p_publish_at,
629
643
  scheduled_version_id = reviewed.id,
@@ -0,0 +1,88 @@
1
+ -- SEC-005: reject scheduling a version that is already published.
2
+
3
+ -- Repair schedules accepted by the 0.5.2 function. The version is already
4
+ -- public, so there is no future immutable draft to publish. Editors must
5
+ -- explicitly select a newer draft.
6
+ update cms_pages
7
+ set publish_at = null,
8
+ scheduled_version_id = null,
9
+ updated_at = now()
10
+ where scheduled_version_id is not null
11
+ and scheduled_version_id = published_version_id;
12
+
13
+ create or replace function cms_schedule_page_publish(
14
+ p_page_id uuid,
15
+ p_expected_version_id bigint,
16
+ p_publish_at timestamptz,
17
+ p_actor uuid
18
+ ) returns cms_pages
19
+ language plpgsql
20
+ security definer
21
+ set search_path = public
22
+ as $$
23
+ declare
24
+ current_page cms_pages;
25
+ reviewed cms_page_versions;
26
+ result cms_pages;
27
+ begin
28
+ select * into current_page
29
+ from cms_pages
30
+ where id = p_page_id
31
+ for update;
32
+
33
+ if current_page.id is null then
34
+ raise exception 'page % not found', p_page_id;
35
+ end if;
36
+
37
+ if p_publish_at is null then
38
+ update cms_pages
39
+ set publish_at = null,
40
+ scheduled_version_id = null,
41
+ updated_at = now()
42
+ where id = p_page_id
43
+ returning * into result;
44
+ return result;
45
+ end if;
46
+
47
+ select * into reviewed
48
+ from cms_page_versions
49
+ where id = p_expected_version_id
50
+ and page_id = p_page_id;
51
+
52
+ if reviewed.id is null
53
+ or current_page.draft_version_id is distinct from p_expected_version_id
54
+ or reviewed.title is distinct from current_page.title
55
+ or reviewed.seo is distinct from current_page.seo
56
+ or reviewed.layout is distinct from current_page.draft_layout then
57
+ raise exception 'Page version conflict: expected draft is missing or stale';
58
+ end if;
59
+
60
+ if current_page.published_version_id = p_expected_version_id then
61
+ raise exception 'Page version conflict: expected draft is already published';
62
+ end if;
63
+
64
+ update cms_pages
65
+ set publish_at = p_publish_at,
66
+ scheduled_version_id = reviewed.id,
67
+ updated_at = now()
68
+ where id = p_page_id
69
+ returning * into result;
70
+
71
+ return result;
72
+ end;
73
+ $$;
74
+
75
+ do $fn_lockdown$
76
+ begin
77
+ revoke execute on function cms_schedule_page_publish(uuid, bigint, timestamptz, uuid) from public;
78
+ if exists (select 1 from pg_roles where rolname = 'anon') then
79
+ revoke execute on function cms_schedule_page_publish(uuid, bigint, timestamptz, uuid) from anon;
80
+ end if;
81
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
82
+ revoke execute on function cms_schedule_page_publish(uuid, bigint, timestamptz, uuid) from authenticated;
83
+ end if;
84
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
85
+ grant execute on function cms_schedule_page_publish(uuid, bigint, timestamptz, uuid) to service_role;
86
+ end if;
87
+ end
88
+ $fn_lockdown$;