@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.
@@ -208,6 +208,20 @@ var studioAccess = (role) => ({
208
208
  import { createClient } from "@supabase/supabase-js";
209
209
  import { useCallback, useEffect, useMemo, useState as useState2 } from "react";
210
210
 
211
+ // src/supabase-keys.ts
212
+ var isServerSupabaseKey = (key) => {
213
+ if (key.startsWith("sb_secret_")) return true;
214
+ const payload = key.split(".")[1];
215
+ if (!payload) return false;
216
+ try {
217
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
218
+ const decoded = JSON.parse(atob(normalized));
219
+ return decoded.role === "service_role";
220
+ } catch {
221
+ return false;
222
+ }
223
+ };
224
+
211
225
  // src/studio/PasswordInput.tsx
212
226
  import { useState } from "react";
213
227
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -260,18 +274,24 @@ function PasswordInput({ className, ...props }) {
260
274
  // src/studio/auth.tsx
261
275
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
262
276
  var browserClient = null;
277
+ var publicSupabaseKey = () => process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
263
278
  function getBrowserSupabase() {
264
279
  if (!browserClient) {
265
280
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
266
- const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
267
- if (!url || !anonKey) {
268
- throw new Error("Orion Studio: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY must be set.");
281
+ const publishableKey = publicSupabaseKey();
282
+ if (!url || !publishableKey) {
283
+ throw new Error(
284
+ "Orion Studio: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY must be set. NEXT_PUBLIC_SUPABASE_ANON_KEY remains supported for legacy projects."
285
+ );
286
+ }
287
+ if (isServerSupabaseKey(publishableKey)) {
288
+ throw new Error("Orion Studio: a Supabase server key cannot be used in a NEXT_PUBLIC variable.");
269
289
  }
270
- browserClient = createClient(url, anonKey);
290
+ browserClient = createClient(url, publishableKey);
271
291
  }
272
292
  return browserClient;
273
293
  }
274
- var isStudioMemoryMode = () => (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) && process.env.NODE_ENV !== "production";
294
+ var isStudioMemoryMode = () => (!process.env.NEXT_PUBLIC_SUPABASE_URL || !publicSupabaseKey()) && process.env.NODE_ENV !== "production";
275
295
  var MEMORY_DEV_TOKEN = "orion-dev-token";
276
296
  var memorySession = {
277
297
  access_token: MEMORY_DEV_TOKEN,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orion-studios/cms",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
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
@@ -213,8 +213,13 @@ create table if not exists cms_globals (
213
213
  key text primary key,
214
214
  label text not null default '',
215
215
  data jsonb not null default '{}'::jsonb,
216
+ builder_owned boolean not null default false,
216
217
  updated_at timestamptz not null default now()
217
218
  );
219
+ alter table cms_globals add column if not exists builder_owned boolean;
220
+ update cms_globals set builder_owned = true where builder_owned is null;
221
+ alter table cms_globals alter column builder_owned set not null;
222
+ alter table cms_globals alter column builder_owned set default false;
218
223
 
219
224
  create table if not exists cms_media (
220
225
  id uuid primary key default gen_random_uuid(),
@@ -226,9 +231,14 @@ create table if not exists cms_media (
226
231
  width integer,
227
232
  height integer,
228
233
  filesize integer,
234
+ builder_owned boolean not null default false,
229
235
  created_at timestamptz not null default now(),
230
236
  updated_at timestamptz not null default now()
231
237
  );
238
+ alter table cms_media add column if not exists builder_owned boolean;
239
+ update cms_media set builder_owned = true where builder_owned is null;
240
+ alter table cms_media alter column builder_owned set not null;
241
+ alter table cms_media alter column builder_owned set default false;
232
242
 
233
243
  create table if not exists cms_forms (
234
244
  id uuid primary key default gen_random_uuid(),
@@ -236,9 +246,14 @@ create table if not exists cms_forms (
236
246
  title text not null default '',
237
247
  config jsonb not null default '{}'::jsonb, -- steps/fields, per forms module contract
238
248
  success_message text not null default '',
249
+ builder_owned boolean not null default false,
239
250
  created_at timestamptz not null default now(),
240
251
  updated_at timestamptz not null default now()
241
252
  );
253
+ alter table cms_forms add column if not exists builder_owned boolean;
254
+ update cms_forms set builder_owned = true where builder_owned is null;
255
+ alter table cms_forms alter column builder_owned set not null;
256
+ alter table cms_forms alter column builder_owned set default false;
242
257
 
243
258
  -- Notification settings live OUTSIDE config: config is anon-readable (the
244
259
  -- public form renderer needs the field list), notify addresses are not.
@@ -251,6 +266,43 @@ update cms_forms
251
266
  config = config - 'notify'
252
267
  where config ? 'notify';
253
268
 
269
+ create table if not exists cms_preview_grants (
270
+ id uuid primary key,
271
+ page_id uuid not null references cms_pages (id) on delete cascade,
272
+ user_id uuid not null,
273
+ project_ref text not null check (project_ref ~ '^[a-z0-9][a-z0-9-]{0,63}$'),
274
+ key_id text not null check (key_id ~ '^[A-Za-z0-9._-]{1,64}$'),
275
+ expires_at timestamptz not null,
276
+ revoked_at timestamptz,
277
+ last_used_at timestamptz,
278
+ use_count integer not null default 0 check (use_count >= 0),
279
+ max_uses integer not null check (max_uses between 1 and 32),
280
+ created_at timestamptz not null default now()
281
+ );
282
+ create index if not exists cms_preview_grants_page_active_idx
283
+ on cms_preview_grants (page_id, expires_at)
284
+ where revoked_at is null;
285
+ create index if not exists cms_preview_grants_user_active_idx
286
+ on cms_preview_grants (user_id, expires_at)
287
+ where revoked_at is null;
288
+
289
+ create table if not exists cms_target_identity (
290
+ singleton boolean primary key default true check (singleton),
291
+ project_ref text not null check (project_ref ~ '^[a-z0-9][a-z0-9-]{0,63}$'),
292
+ installed_at timestamptz not null default now(),
293
+ updated_at timestamptz not null default now()
294
+ );
295
+
296
+ create table if not exists cms_sync_runs (
297
+ manifest_hash text primary key check (manifest_hash ~ '^[a-f0-9]{64}$'),
298
+ target_receipt_hash text not null check (target_receipt_hash ~ '^[a-f0-9]{64}$'),
299
+ status text not null check (status in ('active', 'complete', 'failed')),
300
+ result jsonb,
301
+ error text,
302
+ started_at timestamptz not null default now(),
303
+ updated_at timestamptz not null default now()
304
+ );
305
+
254
306
  create table if not exists cms_form_submissions (
255
307
  id bigint generated always as identity primary key,
256
308
  form_id uuid not null references cms_forms (id) on delete cascade,
@@ -284,8 +336,13 @@ create table if not exists cms_redirects (
284
336
  from_path text not null unique,
285
337
  to_path text not null,
286
338
  permanent boolean not null default true,
339
+ builder_owned boolean not null default false,
287
340
  created_at timestamptz not null default now()
288
341
  );
342
+ alter table cms_redirects add column if not exists builder_owned boolean;
343
+ update cms_redirects set builder_owned = true where builder_owned is null;
344
+ alter table cms_redirects alter column builder_owned set not null;
345
+ alter table cms_redirects alter column builder_owned set default false;
289
346
 
290
347
  -- First-party analytics events. Append-only; pruned after the retention
291
348
  -- window by the cron route. session_key rotates daily. visitor_key is an
@@ -780,15 +837,16 @@ create or replace function cms_update_global(
780
837
  ) returns cms_globals
781
838
  language plpgsql
782
839
  security definer
783
- set search_path = public
840
+ set search_path = public, pg_catalog
784
841
  as $$
785
842
  declare
786
843
  result cms_globals;
787
844
  begin
788
- insert into cms_globals (key, data, updated_at)
789
- values (p_key, p_data, now())
845
+ insert into cms_globals (key, data, builder_owned, updated_at)
846
+ values (p_key, p_data, true, now())
790
847
  on conflict (key) do update
791
848
  set data = excluded.data,
849
+ builder_owned = true,
792
850
  updated_at = now()
793
851
  returning * into result;
794
852
 
@@ -799,6 +857,223 @@ begin
799
857
  end;
800
858
  $$;
801
859
 
860
+ create or replace function cms_consume_preview_grant(
861
+ p_grant_id uuid,
862
+ p_page_id uuid,
863
+ p_user_id uuid,
864
+ p_project_ref text,
865
+ p_key_id text
866
+ ) returns boolean
867
+ language plpgsql
868
+ security definer
869
+ set search_path = public, pg_catalog
870
+ as $$
871
+ declare
872
+ consumed_id uuid;
873
+ begin
874
+ update cms_preview_grants
875
+ set use_count = use_count + 1,
876
+ last_used_at = now()
877
+ where id = p_grant_id
878
+ and page_id = p_page_id
879
+ and user_id = p_user_id
880
+ and project_ref = p_project_ref
881
+ and key_id = p_key_id
882
+ and revoked_at is null
883
+ and expires_at > now()
884
+ and use_count < max_uses
885
+ returning id into consumed_id;
886
+
887
+ return consumed_id is not null;
888
+ end;
889
+ $$;
890
+
891
+ create or replace function cms_sync_global(
892
+ p_key text,
893
+ p_data jsonb
894
+ ) returns boolean
895
+ language plpgsql
896
+ security definer
897
+ set search_path = public, pg_catalog
898
+ as $$
899
+ declare
900
+ changed_key text;
901
+ begin
902
+ insert into cms_globals (key, data, builder_owned, updated_at)
903
+ values (p_key, p_data, false, now())
904
+ on conflict (key) do update
905
+ set data = excluded.data,
906
+ updated_at = now()
907
+ where not cms_globals.builder_owned
908
+ returning key into changed_key;
909
+
910
+ return changed_key is not null;
911
+ end;
912
+ $$;
913
+
914
+ create or replace function cms_sync_form(
915
+ p_slug text,
916
+ p_title text,
917
+ p_config jsonb,
918
+ p_success_message text
919
+ ) returns boolean
920
+ language plpgsql
921
+ security definer
922
+ set search_path = public, pg_catalog
923
+ as $$
924
+ declare
925
+ changed_slug text;
926
+ begin
927
+ insert into cms_forms (
928
+ slug, title, config, success_message, builder_owned, updated_at
929
+ ) values (
930
+ p_slug, p_title, p_config - 'notify', p_success_message, false, now()
931
+ )
932
+ on conflict (slug) do update
933
+ set title = excluded.title,
934
+ config = excluded.config,
935
+ success_message = excluded.success_message,
936
+ updated_at = now()
937
+ where not cms_forms.builder_owned
938
+ returning slug into changed_slug;
939
+
940
+ return changed_slug is not null;
941
+ end;
942
+ $$;
943
+
944
+ create or replace function cms_sync_media(
945
+ p_storage_path text,
946
+ p_filename text,
947
+ p_alt text,
948
+ p_caption text,
949
+ p_mime_type text,
950
+ p_width integer,
951
+ p_height integer,
952
+ p_filesize bigint
953
+ ) returns boolean
954
+ language plpgsql
955
+ security definer
956
+ set search_path = public, pg_catalog
957
+ as $$
958
+ declare
959
+ changed_path text;
960
+ begin
961
+ insert into cms_media (
962
+ storage_path, filename, alt, caption, mime_type, width, height, filesize, builder_owned, updated_at
963
+ ) values (
964
+ p_storage_path, p_filename, p_alt, p_caption, p_mime_type,
965
+ p_width, p_height, p_filesize, false, now()
966
+ )
967
+ on conflict (storage_path) do update
968
+ set filename = excluded.filename,
969
+ alt = excluded.alt,
970
+ caption = excluded.caption,
971
+ mime_type = excluded.mime_type,
972
+ width = excluded.width,
973
+ height = excluded.height,
974
+ filesize = excluded.filesize,
975
+ updated_at = now()
976
+ where not cms_media.builder_owned
977
+ returning storage_path into changed_path;
978
+ return changed_path is not null;
979
+ end;
980
+ $$;
981
+
982
+ create or replace function cms_sync_redirect(
983
+ p_from_path text,
984
+ p_to_path text,
985
+ p_permanent boolean
986
+ ) returns boolean
987
+ language plpgsql
988
+ security definer
989
+ set search_path = public, pg_catalog
990
+ as $$
991
+ declare
992
+ changed_path text;
993
+ begin
994
+ insert into cms_redirects (from_path, to_path, permanent, builder_owned)
995
+ values (p_from_path, p_to_path, p_permanent, false)
996
+ on conflict (from_path) do update
997
+ set to_path = excluded.to_path,
998
+ permanent = excluded.permanent
999
+ where not cms_redirects.builder_owned
1000
+ returning from_path into changed_path;
1001
+ return changed_path is not null;
1002
+ end;
1003
+ $$;
1004
+
1005
+ create or replace function cms_begin_sync(
1006
+ p_manifest_hash text,
1007
+ p_target_receipt_hash text
1008
+ ) returns jsonb
1009
+ language plpgsql
1010
+ security definer
1011
+ set search_path = public, pg_catalog
1012
+ as $$
1013
+ declare
1014
+ active_run cms_sync_runs;
1015
+ requested_run cms_sync_runs;
1016
+ begin
1017
+ if p_manifest_hash !~ '^[a-f0-9]{64}$' or p_target_receipt_hash !~ '^[a-f0-9]{64}$' then
1018
+ raise exception 'Invalid sync receipt.';
1019
+ end if;
1020
+ -- Serialize lease decisions across every manifest. Row locks cannot protect
1021
+ -- the empty set when two new manifests start at the same time.
1022
+ perform pg_advisory_xact_lock(hashtextextended('orion_cms_content_sync', 0));
1023
+ select * into requested_run from cms_sync_runs where manifest_hash = p_manifest_hash for update;
1024
+ if requested_run.status = 'complete' then
1025
+ return jsonb_build_object('status', 'complete', 'result', requested_run.result);
1026
+ end if;
1027
+ if requested_run.status = 'active' and requested_run.updated_at > now() - interval '15 minutes' then
1028
+ return jsonb_build_object('status', 'busy');
1029
+ end if;
1030
+ select * into active_run
1031
+ from cms_sync_runs
1032
+ where status = 'active' and manifest_hash <> p_manifest_hash
1033
+ order by updated_at desc
1034
+ limit 1
1035
+ for update;
1036
+ if active_run.manifest_hash is not null and active_run.updated_at > now() - interval '15 minutes' then
1037
+ return jsonb_build_object('status', 'busy');
1038
+ end if;
1039
+ if active_run.manifest_hash is not null then
1040
+ update cms_sync_runs
1041
+ set status = 'failed', error = 'Sync lease expired.', updated_at = now()
1042
+ where manifest_hash = active_run.manifest_hash;
1043
+ end if;
1044
+ insert into cms_sync_runs (manifest_hash, target_receipt_hash, status)
1045
+ values (p_manifest_hash, p_target_receipt_hash, 'active')
1046
+ on conflict (manifest_hash) do update
1047
+ set target_receipt_hash = excluded.target_receipt_hash,
1048
+ status = 'active', result = null, error = null, updated_at = now();
1049
+ return jsonb_build_object('status', case when requested_run.manifest_hash is null then 'started' else 'resume' end);
1050
+ end;
1051
+ $$;
1052
+
1053
+ create or replace function cms_finish_sync(
1054
+ p_manifest_hash text,
1055
+ p_status text,
1056
+ p_result jsonb default null,
1057
+ p_error text default null
1058
+ ) returns boolean
1059
+ language plpgsql
1060
+ security definer
1061
+ set search_path = public, pg_catalog
1062
+ as $$
1063
+ begin
1064
+ if p_status not in ('complete', 'failed') then
1065
+ raise exception 'Invalid sync completion status.';
1066
+ end if;
1067
+ update cms_sync_runs
1068
+ set status = p_status,
1069
+ result = case when p_status = 'complete' then p_result else null end,
1070
+ error = case when p_status = 'failed' then left(coalesce(p_error, 'Sync failed.'), 500) else null end,
1071
+ updated_at = now()
1072
+ where manifest_hash = p_manifest_hash and status = 'active';
1073
+ return found;
1074
+ end;
1075
+ $$;
1076
+
802
1077
  -- 3) Row-level security -------------------------------------------------------
803
1078
  -- Defense-in-depth. The app's API layer (service role) is the primary
804
1079
  -- enforcement point; anonymous access is read-only and published-only.
@@ -816,6 +1091,11 @@ alter table cms_redirects enable row level security;
816
1091
  alter table cms_activity enable row level security;
817
1092
  alter table cms_events enable row level security;
818
1093
  alter table cms_rate_limits enable row level security;
1094
+ alter table cms_preview_grants enable row level security;
1095
+ alter table cms_target_identity enable row level security;
1096
+ alter table cms_sync_runs enable row level security;
1097
+ alter table cms_preview_grants enable row level security;
1098
+ alter table cms_target_identity enable row level security;
819
1099
  -- cms_events: no anon policies or grants — ingest and reads go through the
820
1100
  -- API layer only.
821
1101
 
@@ -848,7 +1128,7 @@ begin
848
1128
  drop policy if exists cms_profiles_self_read on cms_profiles;
849
1129
  execute 'create policy cms_profiles_self_read on cms_profiles '
850
1130
  || 'for select using (auth.uid() = user_id)';
851
- exception when insufficient_privilege then
1131
+ exception when insufficient_privilege or invalid_schema_name or undefined_function then
852
1132
  raise notice 'Skipped cms_profiles_self_read policy (no auth schema access).';
853
1133
  end $profiles_policy$;
854
1134
 
@@ -861,6 +1141,8 @@ end $profiles_policy$;
861
1141
  -- default and Supabase exposes public-schema functions as RPC, so without an
862
1142
  -- explicit revoke the anon key could call them and bypass RLS + the API
863
1143
  -- permission matrix. Only the API layer (service role) may execute them.
1144
+ alter default privileges revoke execute on functions from public;
1145
+
864
1146
  do $fn_lockdown$
865
1147
  declare
866
1148
  fn text;
@@ -875,10 +1157,28 @@ declare
875
1157
  'cms_rate_limit_consume(text, integer, integer)',
876
1158
  'cms_rate_limit_consume(text, integer, integer, integer)',
877
1159
  'cms_prune_rate_limits(timestamptz)',
878
- 'cms_update_global(text, jsonb, uuid)'
1160
+ 'cms_update_global(text, jsonb, uuid)',
1161
+ 'cms_consume_preview_grant(uuid, uuid, uuid, text, text)',
1162
+ 'cms_sync_global(text, jsonb)',
1163
+ 'cms_sync_form(text, text, jsonb, text)',
1164
+ 'cms_sync_media(text, text, text, text, text, integer, integer, bigint)',
1165
+ 'cms_sync_redirect(text, text, boolean)',
1166
+ 'cms_begin_sync(text, text)',
1167
+ 'cms_finish_sync(text, text, jsonb, text)'
879
1168
  ];
880
1169
  begin
1170
+ if exists (select 1 from pg_roles where rolname = 'anon') then
1171
+ execute 'alter default privileges in schema public revoke all on tables from anon';
1172
+ execute 'alter default privileges in schema public revoke all on sequences from anon';
1173
+ execute 'alter default privileges in schema public revoke execute on functions from anon';
1174
+ end if;
1175
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
1176
+ execute 'alter default privileges in schema public revoke all on tables from authenticated';
1177
+ execute 'alter default privileges in schema public revoke all on sequences from authenticated';
1178
+ execute 'alter default privileges in schema public revoke execute on functions from authenticated';
1179
+ end if;
881
1180
  foreach fn in array fns loop
1181
+ execute format('alter function %s set search_path to public, pg_catalog', fn);
882
1182
  execute format('revoke execute on function %s from public', fn);
883
1183
  if exists (select 1 from pg_roles where rolname = 'anon') then
884
1184
  execute format('revoke execute on function %s from anon', fn);
@@ -901,14 +1201,19 @@ revoke update, delete on cms_page_versions from public;
901
1201
  revoke select on cms_forms from public;
902
1202
  revoke select (notify) on cms_forms from public;
903
1203
  revoke select on cms_media from public;
1204
+ revoke all on cms_preview_grants from public;
1205
+ revoke all on cms_target_identity from public;
1206
+ revoke all on cms_sync_runs from public;
904
1207
  do $grants$
905
1208
  begin
906
1209
  if exists (select 1 from pg_roles where rolname = 'service_role') then
907
1210
  grant select, insert, update, delete on
908
1211
  cms_settings, cms_profiles, cms_pages,
909
1212
  cms_globals, cms_media, cms_forms, cms_form_submissions,
910
- cms_global_versions, cms_redirects, cms_activity, cms_events
1213
+ cms_global_versions, cms_redirects, cms_activity, cms_events,
1214
+ cms_preview_grants, cms_sync_runs
911
1215
  to service_role;
1216
+ grant select on cms_target_identity to service_role;
912
1217
  revoke update, delete on cms_page_versions from service_role;
913
1218
  grant select, insert on cms_page_versions to service_role;
914
1219
  end if;
@@ -926,6 +1231,7 @@ begin
926
1231
  revoke select (notify) on cms_forms from anon;
927
1232
  grant select (id, slug, title, config, success_message, created_at, updated_at)
928
1233
  on cms_forms to anon;
1234
+ revoke all on cms_preview_grants, cms_target_identity, cms_sync_runs from anon;
929
1235
  end if;
930
1236
  if exists (select 1 from pg_roles where rolname = 'authenticated') then
931
1237
  grant select on cms_globals, cms_profiles, cms_redirects to authenticated;
@@ -938,5 +1244,6 @@ begin
938
1244
  revoke select (notify) on cms_forms from authenticated;
939
1245
  grant select (id, slug, title, config, success_message, created_at, updated_at)
940
1246
  on cms_forms to authenticated;
1247
+ revoke all on cms_preview_grants, cms_target_identity, cms_sync_runs from authenticated;
941
1248
  end if;
942
1249
  end $grants$;