@orion-studios/cms 0.5.3 → 0.5.5

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.
@@ -348,7 +348,6 @@ function FormRenderer({
348
348
  return;
349
349
  }
350
350
  setState("success");
351
- funnel("submit");
352
351
  onSuccess?.();
353
352
  } catch {
354
353
  setState("error");
@@ -241,19 +241,19 @@ function createMemoryRateLimitStore(options) {
241
241
  const windowMs = options?.windowMs ?? 6e4;
242
242
  const hits = /* @__PURE__ */ new Map();
243
243
  return {
244
- isLimited(key, now) {
245
- const windowStart = now - windowMs;
246
- const entries = (hits.get(key) || []).filter((timestamp) => timestamp > windowStart);
247
- entries.push(now);
248
- hits.set(key, entries);
244
+ isLimited(key, now, cost = 1) {
245
+ const normalizedCost = Number.isSafeInteger(cost) && cost > 0 ? cost : max + 1;
246
+ const existing = hits.get(key);
247
+ const entry = !existing || now - existing.windowStart >= windowMs ? { windowStart: now, count: normalizedCost } : { ...existing, count: existing.count + normalizedCost };
248
+ hits.set(key, entry);
249
249
  if (hits.size > 1e4) {
250
- for (const [entryKey, timestamps] of hits) {
251
- if (timestamps.every((timestamp) => timestamp <= windowStart)) {
250
+ for (const [entryKey, candidate] of hits) {
251
+ if (now - candidate.windowStart >= windowMs) {
252
252
  hits.delete(entryKey);
253
253
  }
254
254
  }
255
255
  }
256
- return entries.length > max;
256
+ return entry.count > max;
257
257
  }
258
258
  };
259
259
  }
@@ -1,4 +1,4 @@
1
- export { F as FormConfig, a as FormFieldConfig, b as FormNotifyConfig, H as HONEYPOT_FIELD_NAME, P as ProcessSubmissionArgs, c as ProcessSubmissionResult, R as RateLimitStore, d as createMemoryRateLimitStore, p as processSubmission } from '../submission-BKdBedOe.js';
1
+ export { F as FormConfig, a as FormFieldConfig, b as FormNotifyConfig, H as HONEYPOT_FIELD_NAME, P as ProcessSubmissionArgs, c as ProcessSubmissionResult, R as RateLimitStore, d as createMemoryRateLimitStore, p as processSubmission } from '../submission-D_x2qNJl.js';
2
2
 
3
3
  /**
4
4
  * Shared, isomorphic form validation and normalization.
@@ -16,7 +16,7 @@ import {
16
16
  validatePhoneUS,
17
17
  validateRequired,
18
18
  validateUrl
19
- } from "../chunk-VPUODCNH.js";
19
+ } from "../chunk-LQTXMLKZ.js";
20
20
  export {
21
21
  FORM_FIELD_TYPES,
22
22
  HONEYPOT_FIELD_NAME,
@@ -2,7 +2,7 @@
2
2
  "use client";
3
3
  import {
4
4
  FormRenderer
5
- } from "../chunk-WQDHEQDE.js";
5
+ } from "../chunk-2BCEB26C.js";
6
6
  export {
7
7
  FormRenderer
8
8
  };
@@ -1,6 +1,6 @@
1
1
  import { SupabaseClient } from '@supabase/supabase-js';
2
2
  import { BlockRegistry, PageLayout } from '../blocks/index.js';
3
- import { F as FormConfig, R as RateLimitStore } from '../submission-BKdBedOe.js';
3
+ import { F as FormConfig, R as RateLimitStore } from '../submission-D_x2qNJl.js';
4
4
  export { CONTENT_CACHE_TAG } from '../content/index.js';
5
5
  import 'react';
6
6
  import 'zod';
@@ -69,6 +69,16 @@ type CmsRoutesOptions = {
69
69
  maxUploadBytes?: number;
70
70
  /** Days of raw analytics events to keep (default 90). */
71
71
  analyticsRetentionDays?: number;
72
+ /** Optional stricter public analytics budgets. Values cannot raise defaults. */
73
+ analyticsLimits?: Partial<{
74
+ requestsPerMinute: number;
75
+ siteRequestsPerMinute: number;
76
+ siteRequestsPerDay: number;
77
+ clientEventsPerMinute: number;
78
+ clientEventsPerDay: number;
79
+ siteEventsPerMinute: number;
80
+ siteEventsPerDay: number;
81
+ }>;
72
82
  /**
73
83
  * Dev-only: uploads are stored as data URLs instead of Supabase Storage so
74
84
  * the memory backend can serve them. Never enable in production.
@@ -92,6 +102,7 @@ declare function createDurableRateLimitStore(getClient: () => SupabaseClient, op
92
102
  max?: number;
93
103
  windowMs?: number;
94
104
  bucket?: string;
105
+ failClosed?: boolean;
95
106
  }): RateLimitStore;
96
107
  declare function createCmsRoutes(options: CmsRoutesOptions): {
97
108
  GET: (request: Request, context: RouteContext) => Promise<Response>;
@@ -123,8 +134,9 @@ declare function getPreviewPage(client: SupabaseClient, token: string, secret: s
123
134
  * out. Isomorphic and dependency-free so the same code serves the Supabase
124
135
  * and memory backends and is trivially testable.
125
136
  *
126
- * Conversion = a call tap, a form submission, or an email click. A session
127
- * "converted" when it contains at least one conversion event.
137
+ * Call and email taps are useful interaction counts, but browser events are
138
+ * forgeable. A conversion requires a server-verified event, currently a form
139
+ * submission recorded after the authoritative lead write succeeds.
128
140
  */
129
141
  type StoredEvent = {
130
142
  id?: number | string;
@@ -139,6 +151,7 @@ type StoredEvent = {
139
151
  region: string;
140
152
  city: string;
141
153
  meta: Record<string, unknown> | null;
154
+ server_verified?: boolean;
142
155
  created_at: string;
143
156
  };
144
157
  type AnalyticsKpis = {
@@ -1,11 +1,11 @@
1
- import {
2
- CONTENT_CACHE_TAG
3
- } from "../chunk-AYV6KYDP.js";
4
1
  import {
5
2
  createMemoryRateLimitStore,
6
3
  isOriginAllowed,
7
4
  processSubmission
8
- } from "../chunk-VPUODCNH.js";
5
+ } from "../chunk-LQTXMLKZ.js";
6
+ import {
7
+ CONTENT_CACHE_TAG
8
+ } from "../chunk-AYV6KYDP.js";
9
9
 
10
10
  // src/server/routes.ts
11
11
  import { createHash as createHash2 } from "crypto";
@@ -38,7 +38,7 @@ function normalizeSource(utmSource, referrer) {
38
38
  if (host.includes("twitter.") || host === "t.co" || host.includes("x.com")) return "X (Twitter)";
39
39
  return host;
40
40
  }
41
- var isConversion = (event) => event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit");
41
+ var isConversion = (event) => event.server_verified === true && (event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit"));
42
42
  var top = (map, limit, by) => [...map.entries()].sort((a, b) => by(b[1]) - by(a[1])).slice(0, limit);
43
43
  function computeKpis(events) {
44
44
  const sessions = /* @__PURE__ */ new Set();
@@ -63,10 +63,12 @@ function computeKpis(events) {
63
63
  if (event.type === "click" && event.name === "call") calls += 1;
64
64
  if (event.type === "click" && event.name === "email") emails += 1;
65
65
  if (event.type === "click" && event.name === "portal") portalClicks += 1;
66
- if (event.type === "form" && event.name.endsWith(":submit")) formSubmits += 1;
66
+ if (event.server_verified === true && event.type === "form" && event.name.endsWith(":submit")) {
67
+ formSubmits += 1;
68
+ }
67
69
  if (isConversion(event) && event.session_key) converting.add(event.session_key);
68
70
  }
69
- const conversions = calls + formSubmits + emails;
71
+ const conversions = events.filter(isConversion).length;
70
72
  const returningVisitors = [...visitorSessions.values()].filter((keys) => keys.size > 1).length;
71
73
  return {
72
74
  visitors: visitors.size,
@@ -173,7 +175,7 @@ function aggregateAnalytics(events, previousEvents, range) {
173
175
  const entry = forms.get(slug) || { views: 0, starts: 0, submits: 0 };
174
176
  if (stage === "view") entry.views += 1;
175
177
  if (stage === "start") entry.starts += 1;
176
- if (stage === "submit") entry.submits += 1;
178
+ if (stage === "submit" && event.server_verified === true) entry.submits += 1;
177
179
  forms.set(slug, entry);
178
180
  }
179
181
  const notFound = /* @__PURE__ */ new Map();
@@ -389,7 +391,7 @@ var MIN_ROLE = {
389
391
  "pages.delete": "admin",
390
392
  "pages.restore": "editor",
391
393
  "globals.read": "content",
392
- "globals.write": "content",
394
+ "globals.write": "editor",
393
395
  "media.read": "content",
394
396
  "media.upload": "content",
395
397
  "media.update": "content",
@@ -506,6 +508,11 @@ async function resolveUser(request, client = getServiceClient()) {
506
508
 
507
509
  // src/server/sync.ts
508
510
  var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
511
+ var publicFormConfig = (value) => {
512
+ const config = isRecord(value) ? { ...value } : {};
513
+ delete config.notify;
514
+ return config;
515
+ };
509
516
  var mimeByExtension = {
510
517
  avif: "image/avif",
511
518
  gif: "image/gif",
@@ -629,7 +636,7 @@ async function runContentSync(client, registry, input) {
629
636
  {
630
637
  slug: form.slug,
631
638
  title: typeof form.title === "string" ? form.title : form.slug,
632
- config: isRecord(form.config) ? form.config : {},
639
+ config: publicFormConfig(form.config),
633
640
  success_message: typeof form.successMessage === "string" ? form.successMessage : "",
634
641
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
635
642
  },
@@ -663,16 +670,32 @@ var errors = {
663
670
  var isRecord2 = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
664
671
  var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
665
672
  var PATH_PATTERN = /^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/;
673
+ 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;
674
+ var canonicalizeUserId = (value) => USER_ID_PATTERN.test(value) ? value.toLowerCase() : null;
666
675
  var DEFAULT_MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
667
- var ALLOWED_UPLOAD_TYPES = /* @__PURE__ */ new Set([
668
- "image/jpeg",
669
- "image/png",
670
- "image/webp",
671
- "image/gif",
672
- "image/avif",
673
- "image/svg+xml",
674
- "application/pdf"
675
- ]);
676
+ var UPLOAD_RULES = {
677
+ "image/jpeg": {
678
+ extensions: ["jpg", "jpeg"],
679
+ decodedFormat: "jpeg"
680
+ },
681
+ "image/png": {
682
+ extensions: ["png"],
683
+ decodedFormat: "png"
684
+ },
685
+ "image/webp": {
686
+ extensions: ["webp"],
687
+ decodedFormat: "webp"
688
+ },
689
+ "image/gif": {
690
+ extensions: ["gif"],
691
+ decodedFormat: "gif"
692
+ },
693
+ "image/avif": {
694
+ extensions: ["avif"],
695
+ decodedFormat: "heif"
696
+ }
697
+ };
698
+ var uploadExtension = (filename) => filename.split(".").pop()?.toLowerCase() || "";
676
699
  async function readJson(request) {
677
700
  try {
678
701
  const parsed = await request.json();
@@ -733,22 +756,36 @@ async function readJsonLimited(request, limits) {
733
756
  return parsed;
734
757
  }
735
758
  var hashedClientKey = (ip) => createHash2("sha256").update(ip).digest("hex").slice(0, 24);
759
+ var requestPagePath = (request) => {
760
+ const referrer = request.headers.get("referer");
761
+ if (!referrer) return "";
762
+ try {
763
+ const page = new URL(referrer);
764
+ if (page.origin !== new URL(request.url).origin) return "";
765
+ return PATH_PATTERN.test(page.pathname) ? page.pathname : "";
766
+ } catch {
767
+ return "";
768
+ }
769
+ };
736
770
  function createDurableRateLimitStore(getClient, options) {
737
771
  const max = options?.max ?? 5;
738
772
  const windowSeconds = Math.max(1, Math.round((options?.windowMs ?? 6e4) / 1e3));
739
773
  const bucket = options?.bucket ?? "default";
774
+ const failClosed = options?.failClosed === true;
740
775
  return {
741
- async isLimited(key) {
776
+ async isLimited(key, _now, cost = 1) {
742
777
  try {
743
- const { data, error } = await getClient().rpc("cms_rate_limit_consume", {
778
+ const args = {
744
779
  p_key: `${bucket}:${key}`,
745
780
  p_max: max,
746
781
  p_window_seconds: windowSeconds
747
- });
748
- if (error) return false;
749
- return data === false;
782
+ };
783
+ if (cost !== 1) args.p_cost = cost;
784
+ const { data, error } = await getClient().rpc("cms_rate_limit_consume", args);
785
+ if (error) return failClosed;
786
+ return failClosed ? data !== true : data === false;
750
787
  } catch {
751
- return false;
788
+ return failClosed;
752
789
  }
753
790
  }
754
791
  };
@@ -764,7 +801,48 @@ function createCmsRoutes(options) {
764
801
  if (hostedMemoryMode) return "";
765
802
  return options.previewSecret || process.env.SUPABASE_SERVICE_ROLE_KEY || (options.memoryMode ? "orion-memory-preview-secret" : "");
766
803
  };
767
- const eventsRateLimit = options.memoryMode ? createMemoryRateLimitStore({ max: 120, windowMs: 6e4 }) : createDurableRateLimitStore(db, { max: 120, windowMs: 6e4, bucket: "events" });
804
+ const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
805
+ const boundedAnalyticsMax = (name, fallback) => {
806
+ const candidate = options.analyticsLimits?.[name];
807
+ return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate > 0 ? Math.min(candidate, fallback) : fallback;
808
+ };
809
+ const analyticsLimits = {
810
+ siteRequestsPerMinute: analyticsLimit(
811
+ boundedAnalyticsMax("siteRequestsPerMinute", 300),
812
+ 6e4,
813
+ "events-site-request-minute"
814
+ ),
815
+ siteRequestsPerDay: analyticsLimit(
816
+ boundedAnalyticsMax("siteRequestsPerDay", 2e4),
817
+ 864e5,
818
+ "events-site-request-day"
819
+ ),
820
+ requestsPerMinute: analyticsLimit(
821
+ boundedAnalyticsMax("requestsPerMinute", 60),
822
+ 6e4,
823
+ "events-request-minute"
824
+ ),
825
+ clientEventsPerMinute: analyticsLimit(
826
+ boundedAnalyticsMax("clientEventsPerMinute", 120),
827
+ 6e4,
828
+ "events-client-minute"
829
+ ),
830
+ clientEventsPerDay: analyticsLimit(
831
+ boundedAnalyticsMax("clientEventsPerDay", 1e3),
832
+ 864e5,
833
+ "events-client-day"
834
+ ),
835
+ siteEventsPerMinute: analyticsLimit(
836
+ boundedAnalyticsMax("siteEventsPerMinute", 2e3),
837
+ 6e4,
838
+ "events-site-minute"
839
+ ),
840
+ siteEventsPerDay: analyticsLimit(
841
+ boundedAnalyticsMax("siteEventsPerDay", 5e4),
842
+ 864e5,
843
+ "events-site-day"
844
+ )
845
+ };
768
846
  const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
769
847
  const requestSessionKey = (request) => {
770
848
  const secret = previewSecret() || "orion-analytics";
@@ -1156,15 +1234,56 @@ function createCmsRoutes(options) {
1156
1234
  if (error) return errors.badRequest(error.message);
1157
1235
  return json({ media: data });
1158
1236
  };
1159
- const validateUpload = (file) => {
1237
+ const prepareUpload = async (file, target) => {
1160
1238
  if (file.size > maxUploadBytes) {
1161
1239
  return `File is too large (max ${Math.round(maxUploadBytes / 1024 / 1024)} MB).`;
1162
1240
  }
1163
1241
  const type = file.type || "";
1164
- if (!ALLOWED_UPLOAD_TYPES.has(type)) {
1165
- return "Unsupported file type. Allowed: images (JPEG, PNG, WebP, GIF, AVIF, SVG) and PDF.";
1242
+ const rule = UPLOAD_RULES[type];
1243
+ if (!rule) {
1244
+ return "Unsupported file type. Allowed: JPEG, PNG, WebP, GIF, and AVIF images.";
1245
+ }
1246
+ const extension = uploadExtension(file.name);
1247
+ if (!rule.extensions.includes(extension)) {
1248
+ return "File extension does not match its declared type.";
1249
+ }
1250
+ if (target) {
1251
+ const knownTargetType = Boolean(UPLOAD_RULES[target.mimeType]);
1252
+ const pathMatches = target.path.startsWith("data:") || rule.extensions.includes(uploadExtension(target.path));
1253
+ if (knownTargetType && target.mimeType !== type || !knownTargetType && !pathMatches) {
1254
+ return "Replacement files must use the same format as the stored file.";
1255
+ }
1256
+ }
1257
+ try {
1258
+ const { default: sharp } = await import("sharp");
1259
+ const input = Buffer.from(await file.arrayBuffer());
1260
+ const image = sharp(input, {
1261
+ animated: type === "image/gif",
1262
+ failOn: "error",
1263
+ limitInputPixels: 4e7
1264
+ });
1265
+ const metadata = await image.metadata();
1266
+ const width = metadata.width || 0;
1267
+ const height = metadata.height || 0;
1268
+ const pages = metadata.pages || 1;
1269
+ if (metadata.format !== rule.decodedFormat || width < 1 || height < 1 || width * height * pages > 4e7) {
1270
+ return "File contents do not match its declared type.";
1271
+ }
1272
+ const normalized = type === "image/jpeg" ? await image.rotate().jpeg({ quality: 90 }).toBuffer() : type === "image/png" ? await image.rotate().png().toBuffer() : type === "image/webp" ? await image.rotate().webp({ quality: 90 }).toBuffer() : type === "image/gif" ? await image.rotate().gif().toBuffer() : await image.rotate().avif({ quality: 60 }).toBuffer();
1273
+ if (normalized.byteLength > maxUploadBytes) {
1274
+ return `Normalized file is too large (max ${Math.round(maxUploadBytes / 1024 / 1024)} MB).`;
1275
+ }
1276
+ return {
1277
+ file: new File([Uint8Array.from(normalized)], file.name, {
1278
+ type,
1279
+ lastModified: file.lastModified
1280
+ }),
1281
+ width,
1282
+ height
1283
+ };
1284
+ } catch {
1285
+ return "File contents do not match its declared type.";
1166
1286
  }
1167
- return null;
1168
1287
  };
1169
1288
  const uploadMedia = async (request) => {
1170
1289
  const auth = await guard(request, "media.upload");
@@ -1172,35 +1291,32 @@ function createCmsRoutes(options) {
1172
1291
  const form = await request.formData().catch(() => null);
1173
1292
  const file = form?.get("file");
1174
1293
  if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
1175
- const invalid = validateUpload(file);
1176
- if (invalid) return errors.badRequest(invalid);
1294
+ const prepared = await prepareUpload(file);
1295
+ if (typeof prepared === "string") return errors.badRequest(prepared);
1296
+ const normalizedFile = prepared.file;
1177
1297
  const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
1178
1298
  let storagePath = `${Date.now().toString(36)}-${safeName}`;
1179
1299
  if (options.memoryMode) {
1180
- const buffer = Buffer.from(await file.arrayBuffer());
1181
- storagePath = `data:${file.type || "application/octet-stream"};base64,${buffer.toString("base64")}`;
1182
- } else {
1183
- const { error: storageError } = await db().storage.from("media").upload(storagePath, file, { contentType: file.type || "application/octet-stream" });
1184
- if (storageError) return errors.badRequest(storageError.message);
1300
+ const buffer = Buffer.from(await normalizedFile.arrayBuffer());
1301
+ storagePath = `data:${normalizedFile.type};base64,${buffer.toString("base64")}`;
1185
1302
  }
1186
- const uploadedToStorage = !options.memoryMode;
1187
- const width = Number(form.get("width")) || null;
1188
- const height = Number(form.get("height")) || null;
1189
1303
  const { data, error } = await db().from("cms_media").insert({
1190
1304
  storage_path: storagePath,
1191
1305
  filename: safeName,
1192
1306
  alt: String(form.get("alt") || ""),
1193
1307
  caption: String(form.get("caption") || ""),
1194
- mime_type: file.type || "",
1195
- width,
1196
- height,
1197
- filesize: file.size
1308
+ mime_type: normalizedFile.type,
1309
+ width: prepared.width,
1310
+ height: prepared.height,
1311
+ filesize: normalizedFile.size
1198
1312
  }).select("*").single();
1199
- if (error) {
1200
- if (uploadedToStorage) {
1201
- await db().storage.from("media").remove([storagePath]).catch(() => void 0);
1313
+ if (error) return errors.badRequest(error.message);
1314
+ if (!options.memoryMode) {
1315
+ const { error: storageError } = await db().storage.from("media").upload(storagePath, normalizedFile, { contentType: normalizedFile.type });
1316
+ if (storageError) {
1317
+ await db().from("cms_media").delete().eq("id", data.id);
1318
+ return errors.badRequest(storageError.message);
1202
1319
  }
1203
- return errors.badRequest(error.message);
1204
1320
  }
1205
1321
  await logActivity(auth.user, "media.upload", safeName);
1206
1322
  return json({ media: data }, 201);
@@ -1246,27 +1362,29 @@ function createCmsRoutes(options) {
1246
1362
  const form = await request.formData().catch(() => null);
1247
1363
  const file = form?.get("file");
1248
1364
  if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
1249
- const invalid = validateUpload(file);
1250
- if (invalid) return errors.badRequest(invalid);
1365
+ const prepared = await prepareUpload(file, {
1366
+ path: String(doc.storage_path),
1367
+ mimeType: String(doc.mime_type || "")
1368
+ });
1369
+ if (typeof prepared === "string") return errors.badRequest(prepared);
1370
+ const normalizedFile = prepared.file;
1251
1371
  let storagePath = String(doc.storage_path);
1252
1372
  if (options.memoryMode) {
1253
- const buffer = Buffer.from(await file.arrayBuffer());
1254
- storagePath = `data:${file.type || "application/octet-stream"};base64,${buffer.toString("base64")}`;
1373
+ const buffer = Buffer.from(await normalizedFile.arrayBuffer());
1374
+ storagePath = `data:${normalizedFile.type};base64,${buffer.toString("base64")}`;
1255
1375
  } else {
1256
- const { error: storageError } = await db().storage.from("media").upload(storagePath, file, {
1257
- contentType: file.type || "application/octet-stream",
1376
+ const { error: storageError } = await db().storage.from("media").upload(storagePath, normalizedFile, {
1377
+ contentType: normalizedFile.type,
1258
1378
  upsert: true
1259
1379
  });
1260
1380
  if (storageError) return errors.badRequest(storageError.message);
1261
1381
  }
1262
- const width = Number(form.get("width")) || null;
1263
- const height = Number(form.get("height")) || null;
1264
1382
  const { data, error } = await db().from("cms_media").update({
1265
1383
  storage_path: storagePath,
1266
- mime_type: file.type || "",
1267
- filesize: file.size,
1268
- width,
1269
- height,
1384
+ mime_type: normalizedFile.type,
1385
+ filesize: normalizedFile.size,
1386
+ width: prepared.width,
1387
+ height: prepared.height,
1270
1388
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1271
1389
  }).eq("id", id).select("*").single();
1272
1390
  if (error) return errors.badRequest(error.message);
@@ -1284,11 +1402,17 @@ function createCmsRoutes(options) {
1284
1402
  if (usage.length > 0 && !force) {
1285
1403
  return errors.conflict("This file is used on pages.", { usage });
1286
1404
  }
1405
+ const storagePath = String(doc.storage_path);
1406
+ if (!storagePath.startsWith("data:") && !storagePath.startsWith("/")) {
1407
+ try {
1408
+ const { error: storageError } = await db().storage.from("media").remove([storagePath]);
1409
+ if (storageError) return errors.badRequest(storageError.message);
1410
+ } catch (error2) {
1411
+ return errors.badRequest(error2 instanceof Error ? error2.message : "Unable to remove stored file.");
1412
+ }
1413
+ }
1287
1414
  const { error } = await db().from("cms_media").delete().eq("id", id);
1288
1415
  if (error) return errors.badRequest(error.message);
1289
- if (!String(doc.storage_path).startsWith("data:")) {
1290
- await db().storage.from("media").remove([doc.storage_path]).catch(() => void 0);
1291
- }
1292
1416
  await logActivity(auth.user, "media.delete", String(doc.filename));
1293
1417
  return json({ success: true });
1294
1418
  };
@@ -1518,6 +1642,23 @@ function createCmsRoutes(options) {
1518
1642
  session_key: requestSessionKey(request)
1519
1643
  }).select("id").single();
1520
1644
  if (error) return errors.badRequest(error.message);
1645
+ try {
1646
+ const userAgent = request.headers.get("user-agent") || "";
1647
+ await db().from("cms_events").insert({
1648
+ session_key: requestSessionKey(request),
1649
+ visitor_key: "",
1650
+ type: "form",
1651
+ name: `${form.slug}:submit`,
1652
+ path: requestPagePath(request),
1653
+ referrer: "",
1654
+ utm: {},
1655
+ device: deviceFrom(userAgent),
1656
+ ...geoFrom(request),
1657
+ meta: {},
1658
+ server_verified: true
1659
+ });
1660
+ } catch {
1661
+ }
1521
1662
  if (sendEmail) {
1522
1663
  await notifySubmission({
1523
1664
  sendEmail,
@@ -1569,11 +1710,17 @@ function createCmsRoutes(options) {
1569
1710
  if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
1570
1711
  const userAgent = request.headers.get("user-agent") || "";
1571
1712
  if (isBotRequest(userAgent)) return json({ success: true });
1713
+ const now = Date.now();
1714
+ if (await analyticsLimits.siteRequestsPerMinute.isLimited("all", now) || await analyticsLimits.siteRequestsPerDay.isLimited("all", now)) {
1715
+ return json({ success: true });
1716
+ }
1572
1717
  const key = hashedClientKey(clientKey(request));
1573
- if (await eventsRateLimit.isLimited(key, Date.now())) return json({ success: true });
1718
+ if (await analyticsLimits.requestsPerMinute.isLimited(key, now)) {
1719
+ return json({ success: true });
1720
+ }
1574
1721
  const body = await readJsonLimited(request, { maxBytes: 128 * 1024, maxDepth: 8 });
1575
1722
  if (!body) return json({ success: true });
1576
- const { rows } = parseEventBatch(body, {
1723
+ const parsed = parseEventBatch(body, {
1577
1724
  sessionKey: requestSessionKey(request),
1578
1725
  visitorKey: visitorKeyFor(
1579
1726
  body?.visitorId,
@@ -1582,9 +1729,25 @@ function createCmsRoutes(options) {
1582
1729
  device: deviceFrom(userAgent),
1583
1730
  ...geoFrom(request)
1584
1731
  });
1732
+ const rows = parsed.rows.filter(
1733
+ (row) => !(row.type === "form" && row.name.endsWith(":submit"))
1734
+ );
1585
1735
  if (rows.length > 0) {
1736
+ const limitChecks = [
1737
+ [analyticsLimits.clientEventsPerMinute, key],
1738
+ [analyticsLimits.clientEventsPerDay, key],
1739
+ [analyticsLimits.siteEventsPerMinute, "all"],
1740
+ [analyticsLimits.siteEventsPerDay, "all"]
1741
+ ];
1742
+ for (const [limit, limitKey] of limitChecks) {
1743
+ if (await limit.isLimited(limitKey, now, rows.length)) {
1744
+ return json({ success: true });
1745
+ }
1746
+ }
1586
1747
  try {
1587
- await db().from("cms_events").insert(rows);
1748
+ await db().from("cms_events").insert(
1749
+ rows.map((row) => ({ ...row, server_verified: false }))
1750
+ );
1588
1751
  } catch {
1589
1752
  }
1590
1753
  }
@@ -1594,7 +1757,7 @@ function createCmsRoutes(options) {
1594
1757
  const all = [];
1595
1758
  let cursor = 0;
1596
1759
  for (let page = 0; page < 60; page += 1) {
1597
- const { data, error } = await db().from("cms_events").select("id, session_key, type, name, path, referrer, utm, device, region, city, meta, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
1760
+ const { data, error } = await db().from("cms_events").select("id, session_key, type, name, path, referrer, utm, device, region, city, meta, server_verified, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
1598
1761
  if (error || !data || data.length === 0) break;
1599
1762
  all.push(...data);
1600
1763
  cursor = Number(data[data.length - 1].id);
@@ -1621,8 +1784,10 @@ function createCmsRoutes(options) {
1621
1784
  };
1622
1785
  const pruneEvents = async () => {
1623
1786
  const cutoff = new Date(Date.now() - analyticsRetentionDays * 864e5).toISOString();
1787
+ const limitCutoff = new Date(Date.now() - 2 * 864e5).toISOString();
1624
1788
  try {
1625
1789
  await db().from("cms_events").delete().lt("created_at", cutoff);
1790
+ await db().rpc("cms_prune_rate_limits", { p_before: limitCutoff });
1626
1791
  } catch {
1627
1792
  }
1628
1793
  };
@@ -1706,23 +1871,30 @@ function createCmsRoutes(options) {
1706
1871
  const listUsers = async (request) => {
1707
1872
  const auth = await guard(request, "users.manage");
1708
1873
  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
1874
  const { data: profiles, error: profileError } = await db().from("cms_profiles").select("user_id, role, name");
1712
1875
  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 {
1876
+ const profileById = /* @__PURE__ */ new Map();
1877
+ for (const profile of profiles || []) {
1878
+ const userId = typeof profile.user_id === "string" ? canonicalizeUserId(profile.user_id) : null;
1879
+ if (!userId || !isCmsRole(profile.role)) continue;
1880
+ profileById.set(userId, {
1881
+ role: profile.role,
1882
+ name: typeof profile.name === "string" ? profile.name : ""
1883
+ });
1884
+ }
1885
+ const { data: authData, error: authError } = await db().auth.admin.listUsers({ perPage: 1e3 });
1886
+ if (authError) return errors.badRequest(authError.message);
1887
+ const users = authData.users.flatMap((user) => {
1888
+ const canonicalUserId = canonicalizeUserId(user.id);
1889
+ const profile = canonicalUserId ? profileById.get(canonicalUserId) : void 0;
1890
+ return profile ? [{
1719
1891
  id: user.id,
1720
1892
  email: user.email ?? null,
1721
- name: profile?.name || "",
1722
- role: profile?.role ?? null,
1893
+ name: profile.name,
1894
+ role: profile.role,
1723
1895
  created_at: user.created_at ?? null,
1724
1896
  last_sign_in_at: user.last_sign_in_at ?? null
1725
- };
1897
+ }] : [];
1726
1898
  });
1727
1899
  return json({ users, assignableRoles: assignableRoles(auth.user.role) });
1728
1900
  };
@@ -1757,20 +1929,31 @@ function createCmsRoutes(options) {
1757
1929
  await logActivity(auth.user, "user.create", email);
1758
1930
  return json({ user: { id: userId, email, name, role } }, 201);
1759
1931
  };
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;
1932
+ const loadMembership = async (userId) => {
1933
+ const { data, error } = await db().from("cms_profiles").select("role").eq("user_id", userId).maybeSingle();
1934
+ if (error) return { status: "query-error", message: error.message };
1935
+ if (!data) return { status: "missing" };
1936
+ if (!isCmsRole(data.role)) return { status: "invalid-role" };
1937
+ return { status: "valid", role: data.role };
1938
+ };
1939
+ const membershipError = (membership) => {
1940
+ if (membership.status === "missing") return errors.notFound();
1941
+ if (membership.status === "invalid-role") return errors.badRequest("Invalid CMS profile role.");
1942
+ return errors.badRequest(membership.message);
1763
1943
  };
1764
1944
  const updateUser = async (request, userId) => {
1765
1945
  const auth = await guard(request, "users.manage");
1766
1946
  if (auth instanceof Response) return auth;
1947
+ const canonicalUserId = canonicalizeUserId(userId);
1948
+ if (!canonicalUserId) return errors.badRequest("Invalid user ID.");
1767
1949
  const body = await readJson(request);
1768
1950
  if (!body) return errors.badRequest("Invalid body.");
1769
- const current = await targetRole(userId);
1770
- if (current && !outranksOrEqual(auth.user.role, current)) return errors.forbidden();
1951
+ const membership = await loadMembership(canonicalUserId);
1952
+ if (membership.status !== "valid") return membershipError(membership);
1953
+ if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
1771
1954
  if (body.role !== void 0) {
1772
1955
  if (!isCmsRole(body.role)) return errors.badRequest("Invalid role.");
1773
- if (userId === auth.user.id && body.role !== auth.user.role) {
1956
+ if (canonicalUserId === auth.user.id && body.role !== auth.user.role) {
1774
1957
  return errors.badRequest("You can't change your own role.");
1775
1958
  }
1776
1959
  if (!outranksOrEqual(auth.user.role, body.role)) {
@@ -1779,32 +1962,37 @@ function createCmsRoutes(options) {
1779
1962
  }
1780
1963
  if (typeof body.password === "string") {
1781
1964
  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, {
1965
+ const { error: passwordError } = await db().auth.admin.updateUserById(canonicalUserId, {
1783
1966
  password: body.password
1784
1967
  });
1785
1968
  if (passwordError) return errors.badRequest(passwordError.message);
1786
1969
  }
1787
1970
  if (body.role !== void 0 || typeof body.name === "string") {
1788
- const patch = { user_id: userId };
1971
+ const patch = {};
1789
1972
  if (body.role !== void 0) patch.role = body.role;
1790
1973
  if (typeof body.name === "string") patch.name = body.name.trim();
1791
- const { error: profileError } = await db().from("cms_profiles").upsert(patch, { onConflict: "user_id" });
1974
+ const { error: profileError } = await db().from("cms_profiles").update(patch).eq("user_id", canonicalUserId);
1792
1975
  if (profileError) return errors.badRequest(profileError.message);
1793
1976
  }
1794
- await logActivity(auth.user, "user.update", userId);
1977
+ await logActivity(auth.user, "user.update", canonicalUserId);
1795
1978
  return json({ success: true });
1796
1979
  };
1797
1980
  const deleteUser = async (request, userId) => {
1798
1981
  const auth = await guard(request, "users.manage");
1799
1982
  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);
1983
+ const canonicalUserId = canonicalizeUserId(userId);
1984
+ if (!canonicalUserId) return errors.badRequest("Invalid user ID.");
1985
+ const membership = await loadMembership(canonicalUserId);
1986
+ if (membership.status !== "valid") return membershipError(membership);
1987
+ if (canonicalUserId === auth.user.id) {
1988
+ return errors.badRequest("You can't remove your own account.");
1989
+ }
1990
+ if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
1991
+ const { error: authError } = await db().auth.admin.deleteUser(canonicalUserId);
1804
1992
  if (authError) return errors.badRequest(authError.message);
1805
- const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", userId);
1993
+ const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", canonicalUserId);
1806
1994
  if (profileError) return errors.badRequest(profileError.message);
1807
- await logActivity(auth.user, "user.delete", userId);
1995
+ await logActivity(auth.user, "user.delete", canonicalUserId);
1808
1996
  return json({ success: true });
1809
1997
  };
1810
1998
  const runSync = async (request) => {
@@ -2237,7 +2425,11 @@ function runRpc(store, fn, args = {}) {
2237
2425
  const limits = store.table("cms_rate_limits");
2238
2426
  const key = String(args.p_key);
2239
2427
  const max = Number(args.p_max);
2428
+ const cost = args.p_cost === void 0 ? 1 : Number(args.p_cost);
2240
2429
  const windowMs = Number(args.p_window_seconds) * 1e3;
2430
+ if (!Number.isSafeInteger(cost) || cost < 1 || cost > max) {
2431
+ return { data: false, error: null };
2432
+ }
2241
2433
  const nowMs = Date.now();
2242
2434
  let row = limits.find((r) => r.key === key);
2243
2435
  if (!row) {
@@ -2246,12 +2438,22 @@ function runRpc(store, fn, args = {}) {
2246
2438
  }
2247
2439
  if (nowMs - Number(row.window_start) >= windowMs) {
2248
2440
  row.window_start = nowMs;
2249
- row.count = 1;
2441
+ row.count = cost;
2250
2442
  } else {
2251
- row.count = Number(row.count) + 1;
2443
+ row.count = Number(row.count) + cost;
2252
2444
  }
2253
2445
  return { data: Number(row.count) <= max, error: null };
2254
2446
  }
2447
+ case "cms_prune_rate_limits": {
2448
+ const before = Date.parse(String(args.p_before));
2449
+ const limits = store.table("cms_rate_limits");
2450
+ const kept = limits.filter((row) => {
2451
+ const started = typeof row.window_start === "number" ? row.window_start : Date.parse(String(row.window_start));
2452
+ return !Number.isFinite(before) || !Number.isFinite(started) || started >= before;
2453
+ });
2454
+ store.setTable("cms_rate_limits", kept);
2455
+ return { data: limits.length - kept.length, error: null };
2456
+ }
2255
2457
  case "cms_publish_due_pages": {
2256
2458
  const nowMs = Date.now();
2257
2459
  const published = [];
@@ -3,7 +3,7 @@
3
3
  import {
4
4
  FORM_FIELD_TYPES,
5
5
  FormRenderer
6
- } from "../chunk-WQDHEQDE.js";
6
+ } from "../chunk-2BCEB26C.js";
7
7
 
8
8
  // src/studio/Studio.tsx
9
9
  import { useCallback as useCallback5, useEffect as useEffect11, useMemo as useMemo5, useState as useState12 } from "react";
@@ -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"),
@@ -3378,7 +3379,7 @@ function MediaView({
3378
3379
  /* @__PURE__ */ jsx11(
3379
3380
  "input",
3380
3381
  {
3381
- accept: "image/*,application/pdf",
3382
+ accept: "image/jpeg,image/png,image/webp,image/gif,image/avif",
3382
3383
  hidden: true,
3383
3384
  onChange: async (event) => {
3384
3385
  const file = event.currentTarget.files?.[0];
@@ -3492,12 +3493,12 @@ function MediaDetail({
3492
3493
  children: saving ? "Saving\u2026" : "Save details"
3493
3494
  }
3494
3495
  ),
3495
- canReplace ? /* @__PURE__ */ jsxs11("label", { className: "ost-btn", title: "The new file keeps every existing reference. CDN caching can take up to an hour to show the new version everywhere.", children: [
3496
+ canReplace && media.mime_type.startsWith("image/") ? /* @__PURE__ */ jsxs11("label", { className: "ost-btn", title: "The new file keeps every existing reference. CDN caching can take up to an hour to show the new version everywhere.", children: [
3496
3497
  replacing ? "Replacing\u2026" : "Replace file",
3497
3498
  /* @__PURE__ */ jsx11(
3498
3499
  "input",
3499
3500
  {
3500
- accept: media.mime_type.startsWith("image/") ? "image/*" : "application/pdf",
3501
+ accept: "image/jpeg,image/png,image/webp,image/gif,image/avif",
3501
3502
  hidden: true,
3502
3503
  onChange: async (event) => {
3503
3504
  const file = event.currentTarget.files?.[0];
@@ -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,
@@ -33,7 +33,7 @@ type FormConfig = {
33
33
  notify?: FormNotifyConfig;
34
34
  };
35
35
  type RateLimitStore = {
36
- isLimited(key: string, now: number): boolean | Promise<boolean>;
36
+ isLimited(key: string, now: number, cost?: number): boolean | Promise<boolean>;
37
37
  };
38
38
  declare function createMemoryRateLimitStore(options?: {
39
39
  max?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orion-studios/cms",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Orion CMS v2 core engine \u2014 JSONB content model on Supabase primitives",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,6 +56,7 @@
56
56
  "dependencies": {
57
57
  "@supabase/ssr": "^0.7.0",
58
58
  "@supabase/supabase-js": "^2.58.0",
59
+ "sharp": "^0.35.3",
59
60
  "zod": "^3.25.0"
60
61
  },
61
62
  "peerDependencies": {
package/sql/bootstrap.sql CHANGED
@@ -244,7 +244,10 @@ create table if not exists cms_forms (
244
244
  -- public form renderer needs the field list), notify addresses are not.
245
245
  alter table cms_forms add column if not exists notify jsonb not null default '{}'::jsonb;
246
246
  update cms_forms
247
- set notify = coalesce(config->'notify', '{}'::jsonb),
247
+ set notify = case
248
+ when jsonb_typeof(notify) = 'object' and notify <> '{}'::jsonb then notify
249
+ else coalesce(config->'notify', '{}'::jsonb)
250
+ end,
248
251
  config = config - 'notify'
249
252
  where config ? 'notify';
250
253
 
@@ -301,8 +304,10 @@ create table if not exists cms_events (
301
304
  region text not null default '',
302
305
  city text not null default '',
303
306
  meta jsonb not null default '{}'::jsonb,
307
+ server_verified boolean not null default false,
304
308
  created_at timestamptz not null default now()
305
309
  );
310
+ alter table cms_events add column if not exists server_verified boolean not null default false;
306
311
  create index if not exists cms_events_day_idx on cms_events (created_at desc);
307
312
  create index if not exists cms_events_session_idx on cms_events (session_key, created_at);
308
313
  create index if not exists cms_events_visitor_idx on cms_events (visitor_key, created_at)
@@ -700,7 +705,8 @@ $$;
700
705
  create or replace function cms_rate_limit_consume(
701
706
  p_key text,
702
707
  p_max integer,
703
- p_window_seconds integer
708
+ p_window_seconds integer,
709
+ p_cost integer
704
710
  ) returns boolean
705
711
  language plpgsql
706
712
  security definer
@@ -709,13 +715,23 @@ as $$
709
715
  declare
710
716
  v_count integer;
711
717
  begin
718
+ if p_key is null or length(p_key) < 1 or length(p_key) > 256
719
+ or p_max < 1 or p_max > 1000000
720
+ or p_window_seconds < 1 or p_window_seconds > 604800
721
+ or p_cost < 1 or p_cost > p_max then
722
+ return false;
723
+ end if;
724
+
712
725
  insert into cms_rate_limits (key, window_start, count)
713
- values (p_key, now(), 1)
726
+ values (p_key, now(), p_cost)
714
727
  on conflict (key) do update
715
728
  set count = case
716
729
  when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
717
- then 1
718
- else cms_rate_limits.count + 1
730
+ then p_cost
731
+ else least(
732
+ (p_max + 1)::bigint,
733
+ cms_rate_limits.count::bigint + p_cost::bigint
734
+ )::integer
719
735
  end,
720
736
  window_start = case
721
737
  when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
@@ -728,6 +744,35 @@ begin
728
744
  end;
729
745
  $$;
730
746
 
747
+ create or replace function cms_rate_limit_consume(
748
+ p_key text,
749
+ p_max integer,
750
+ p_window_seconds integer
751
+ ) returns boolean
752
+ language sql
753
+ security definer
754
+ set search_path = public
755
+ as $$
756
+ select cms_rate_limit_consume(p_key, p_max, p_window_seconds, 1);
757
+ $$;
758
+
759
+ create or replace function cms_prune_rate_limits(
760
+ p_before timestamptz
761
+ ) returns bigint
762
+ language plpgsql
763
+ security definer
764
+ set search_path = public
765
+ as $$
766
+ declare
767
+ v_deleted bigint;
768
+ begin
769
+ delete from cms_rate_limits
770
+ where window_start < p_before;
771
+ get diagnostics v_deleted = row_count;
772
+ return v_deleted;
773
+ end;
774
+ $$;
775
+
731
776
  create or replace function cms_update_global(
732
777
  p_key text,
733
778
  p_data jsonb,
@@ -789,8 +834,6 @@ create policy cms_globals_public_read on cms_globals
789
834
  for select using (true);
790
835
 
791
836
  drop policy if exists cms_media_public_read on cms_media;
792
- create policy cms_media_public_read on cms_media
793
- for select using (true);
794
837
 
795
838
  drop policy if exists cms_forms_public_read on cms_forms;
796
839
  create policy cms_forms_public_read on cms_forms
@@ -830,6 +873,8 @@ declare
830
873
  'cms_sync_page(text, text, text, jsonb, jsonb)',
831
874
  'cms_publish_due_pages()',
832
875
  'cms_rate_limit_consume(text, integer, integer)',
876
+ 'cms_rate_limit_consume(text, integer, integer, integer)',
877
+ 'cms_prune_rate_limits(timestamptz)',
833
878
  'cms_update_global(text, jsonb, uuid)'
834
879
  ];
835
880
  begin
@@ -853,6 +898,9 @@ end $fn_lockdown$;
853
898
  -- site-specific role, the PostgREST roles get nothing — so grant explicitly.
854
899
  -- Wrapped per-role so bootstrap also works on plain Postgres (dev/tests).
855
900
  revoke update, delete on cms_page_versions from public;
901
+ revoke select on cms_forms from public;
902
+ revoke select (notify) on cms_forms from public;
903
+ revoke select on cms_media from public;
856
904
  do $grants$
857
905
  begin
858
906
  if exists (select 1 from pg_roles where rolname = 'service_role') then
@@ -868,22 +916,26 @@ begin
868
916
  -- cms_pages and cms_forms get column-level read grants. Anonymous readers
869
917
  -- see published page state only and never form notification addresses.
870
918
  if exists (select 1 from pg_roles where rolname = 'anon') then
871
- grant select on cms_globals, cms_media, cms_redirects to anon;
919
+ grant select on cms_globals, cms_redirects to anon;
920
+ revoke select on cms_media from anon;
872
921
  revoke select on cms_pages from anon;
873
922
  grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
874
923
  on cms_pages to anon;
875
924
  revoke all on cms_page_versions from anon;
876
925
  revoke select on cms_forms from anon;
926
+ revoke select (notify) on cms_forms from anon;
877
927
  grant select (id, slug, title, config, success_message, created_at, updated_at)
878
928
  on cms_forms to anon;
879
929
  end if;
880
930
  if exists (select 1 from pg_roles where rolname = 'authenticated') then
881
- grant select on cms_globals, cms_media, cms_profiles, cms_redirects to authenticated;
931
+ grant select on cms_globals, cms_profiles, cms_redirects to authenticated;
932
+ revoke select on cms_media from authenticated;
882
933
  revoke select on cms_pages from authenticated;
883
934
  grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
884
935
  on cms_pages to authenticated;
885
936
  revoke all on cms_page_versions from authenticated;
886
937
  revoke select on cms_forms from authenticated;
938
+ revoke select (notify) on cms_forms from authenticated;
887
939
  grant select (id, slug, title, config, success_message, created_at, updated_at)
888
940
  on cms_forms to authenticated;
889
941
  end if;
@@ -0,0 +1,41 @@
1
+ -- SEC-014: move legacy notification settings out of public form config.
2
+
3
+ alter table cms_forms
4
+ add column if not exists notify jsonb not null default '{}'::jsonb;
5
+
6
+ -- Current private settings remain authoritative when both representations
7
+ -- exist. Older rows without private settings inherit the legacy value.
8
+ update cms_forms
9
+ set notify = case
10
+ when jsonb_typeof(notify) = 'object' and notify <> '{}'::jsonb then notify
11
+ else coalesce(config->'notify', '{}'::jsonb)
12
+ end,
13
+ config = config - 'notify'
14
+ where config ? 'notify';
15
+
16
+ -- RLS limits rows, while column grants keep notification settings out of the
17
+ -- public form definition returned by the Supabase Data API.
18
+ revoke select on cms_forms from public;
19
+ revoke select (notify) on cms_forms from public;
20
+
21
+ do $form_grants$
22
+ begin
23
+ if exists (select 1 from pg_roles where rolname = 'anon') then
24
+ revoke select on cms_forms from anon;
25
+ revoke select (notify) on cms_forms from anon;
26
+ grant select (id, slug, title, config, success_message, created_at, updated_at)
27
+ on cms_forms to anon;
28
+ end if;
29
+
30
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
31
+ revoke select on cms_forms from authenticated;
32
+ revoke select (notify) on cms_forms from authenticated;
33
+ grant select (id, slug, title, config, success_message, created_at, updated_at)
34
+ on cms_forms to authenticated;
35
+ end if;
36
+
37
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
38
+ grant select, insert, update, delete on cms_forms to service_role;
39
+ end if;
40
+ end
41
+ $form_grants$;
@@ -0,0 +1,101 @@
1
+ -- Bound public analytics storage by accepted event count and separate
2
+ -- server-verified conversions from forgeable browser observations.
3
+
4
+ alter table cms_events
5
+ add column if not exists server_verified boolean not null default false;
6
+
7
+ create or replace function cms_rate_limit_consume(
8
+ p_key text,
9
+ p_max integer,
10
+ p_window_seconds integer,
11
+ p_cost integer
12
+ ) returns boolean
13
+ language plpgsql
14
+ security definer
15
+ set search_path = public
16
+ as $$
17
+ declare
18
+ v_count integer;
19
+ begin
20
+ if p_key is null or length(p_key) < 1 or length(p_key) > 256
21
+ or p_max < 1 or p_max > 1000000
22
+ or p_window_seconds < 1 or p_window_seconds > 604800
23
+ or p_cost < 1 or p_cost > p_max then
24
+ return false;
25
+ end if;
26
+
27
+ insert into cms_rate_limits (key, window_start, count)
28
+ values (p_key, now(), p_cost)
29
+ on conflict (key) do update
30
+ set count = case
31
+ when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
32
+ then p_cost
33
+ else least(
34
+ (p_max + 1)::bigint,
35
+ cms_rate_limits.count::bigint + p_cost::bigint
36
+ )::integer
37
+ end,
38
+ window_start = case
39
+ when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
40
+ then now()
41
+ else cms_rate_limits.window_start
42
+ end
43
+ returning count into v_count;
44
+
45
+ return v_count <= p_max;
46
+ end;
47
+ $$;
48
+
49
+ -- Preserve the existing form-submission caller while new analytics callers
50
+ -- use the weighted overload.
51
+ create or replace function cms_rate_limit_consume(
52
+ p_key text,
53
+ p_max integer,
54
+ p_window_seconds integer
55
+ ) returns boolean
56
+ language sql
57
+ security definer
58
+ set search_path = public
59
+ as $$
60
+ select cms_rate_limit_consume(p_key, p_max, p_window_seconds, 1);
61
+ $$;
62
+
63
+ create or replace function cms_prune_rate_limits(
64
+ p_before timestamptz
65
+ ) returns bigint
66
+ language plpgsql
67
+ security definer
68
+ set search_path = public
69
+ as $$
70
+ declare
71
+ v_deleted bigint;
72
+ begin
73
+ delete from cms_rate_limits
74
+ where window_start < p_before;
75
+ get diagnostics v_deleted = row_count;
76
+ return v_deleted;
77
+ end;
78
+ $$;
79
+
80
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from public;
81
+ revoke execute on function cms_rate_limit_consume(text, integer, integer, integer) from public;
82
+ revoke execute on function cms_prune_rate_limits(timestamptz) from public;
83
+
84
+ do $lockdown$
85
+ begin
86
+ if exists (select 1 from pg_roles where rolname = 'anon') then
87
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from anon;
88
+ revoke execute on function cms_rate_limit_consume(text, integer, integer, integer) from anon;
89
+ revoke execute on function cms_prune_rate_limits(timestamptz) from anon;
90
+ end if;
91
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
92
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from authenticated;
93
+ revoke execute on function cms_rate_limit_consume(text, integer, integer, integer) from authenticated;
94
+ revoke execute on function cms_prune_rate_limits(timestamptz) from authenticated;
95
+ end if;
96
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
97
+ grant execute on function cms_rate_limit_consume(text, integer, integer) to service_role;
98
+ grant execute on function cms_rate_limit_consume(text, integer, integer, integer) to service_role;
99
+ grant execute on function cms_prune_rate_limits(timestamptz) to service_role;
100
+ end if;
101
+ end $lockdown$;
@@ -0,0 +1,21 @@
1
+ -- SEC-016: keep unused and draft media metadata out of public enumeration.
2
+ -- Published layouts already carry the storage paths needed by public pages.
3
+
4
+ drop policy if exists cms_media_public_read on cms_media;
5
+ revoke select on cms_media from public;
6
+
7
+ do $media_grants$
8
+ begin
9
+ if exists (select 1 from pg_roles where rolname = 'anon') then
10
+ revoke select on cms_media from anon;
11
+ end if;
12
+
13
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
14
+ revoke select on cms_media from authenticated;
15
+ end if;
16
+
17
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
18
+ grant select, insert, update, delete on cms_media to service_role;
19
+ end if;
20
+ end
21
+ $media_grants$;