@orion-studios/cms 0.5.0

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.
@@ -0,0 +1,2280 @@
1
+ import {
2
+ CONTENT_CACHE_TAG
3
+ } from "../chunk-HVJCF2IZ.js";
4
+ import {
5
+ createMemoryRateLimitStore,
6
+ isOriginAllowed,
7
+ processSubmission
8
+ } from "../chunk-VPUODCNH.js";
9
+
10
+ // src/server/routes.ts
11
+ import { createHash as createHash2 } from "crypto";
12
+
13
+ // src/analytics/aggregate.ts
14
+ var CONVERSION_NAMES = /* @__PURE__ */ new Set(["call", "email"]);
15
+ function normalizeSource(utmSource, referrer) {
16
+ const tag = utmSource.trim().toLowerCase();
17
+ if (tag) {
18
+ if (["gbp", "gmb", "google-business", "google_business", "business.google.com"].includes(tag)) {
19
+ return "Google Business Profile";
20
+ }
21
+ if (tag === "google") return "Google";
22
+ if (tag === "facebook" || tag === "fb") return "Facebook";
23
+ if (tag === "instagram" || tag === "ig") return "Instagram";
24
+ return utmSource.trim();
25
+ }
26
+ const host = referrer.trim().toLowerCase();
27
+ if (!host) return "direct";
28
+ if (host === "business.google.com") return "Google Business Profile";
29
+ if (host.includes("google.")) return "Google search";
30
+ if (host.includes("bing.")) return "Bing";
31
+ if (host.includes("duckduckgo.")) return "DuckDuckGo";
32
+ if (host.includes("yahoo.")) return "Yahoo";
33
+ if (host.includes("facebook.") || host === "fb.com" || host.startsWith("l.facebook")) return "Facebook";
34
+ if (host.includes("instagram.")) return "Instagram";
35
+ if (host.includes("nextdoor.")) return "Nextdoor";
36
+ if (host.includes("yelp.")) return "Yelp";
37
+ if (host.includes("linkedin.") || host === "lnkd.in") return "LinkedIn";
38
+ if (host.includes("twitter.") || host === "t.co" || host.includes("x.com")) return "X (Twitter)";
39
+ return host;
40
+ }
41
+ var isConversion = (event) => event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit");
42
+ var top = (map, limit, by) => [...map.entries()].sort((a, b) => by(b[1]) - by(a[1])).slice(0, limit);
43
+ function computeKpis(events) {
44
+ const sessions = /* @__PURE__ */ new Set();
45
+ const visitors = /* @__PURE__ */ new Set();
46
+ const visitorSessions = /* @__PURE__ */ new Map();
47
+ const converting = /* @__PURE__ */ new Set();
48
+ let pageviews = 0;
49
+ let calls = 0;
50
+ let formSubmits = 0;
51
+ let emails = 0;
52
+ let portalClicks = 0;
53
+ for (const event of events) {
54
+ if (event.session_key) sessions.add(event.session_key);
55
+ const identity = event.visitor_key || event.session_key;
56
+ if (identity) visitors.add(identity);
57
+ if (event.visitor_key && event.session_key) {
58
+ const keys = visitorSessions.get(event.visitor_key) || /* @__PURE__ */ new Set();
59
+ keys.add(event.session_key);
60
+ visitorSessions.set(event.visitor_key, keys);
61
+ }
62
+ if (event.type === "pageview") pageviews += 1;
63
+ if (event.type === "click" && event.name === "call") calls += 1;
64
+ if (event.type === "click" && event.name === "email") emails += 1;
65
+ if (event.type === "click" && event.name === "portal") portalClicks += 1;
66
+ if (event.type === "form" && event.name.endsWith(":submit")) formSubmits += 1;
67
+ if (isConversion(event) && event.session_key) converting.add(event.session_key);
68
+ }
69
+ const conversions = calls + formSubmits + emails;
70
+ const returningVisitors = [...visitorSessions.values()].filter((keys) => keys.size > 1).length;
71
+ return {
72
+ visitors: visitors.size,
73
+ identifiedVisitors: visitorSessions.size,
74
+ returningVisitors,
75
+ sessions: sessions.size,
76
+ pageviews,
77
+ pagesPerVisitor: visitors.size > 0 ? pageviews / visitors.size : 0,
78
+ calls,
79
+ formSubmits,
80
+ emails,
81
+ portalClicks,
82
+ conversions,
83
+ conversionRate: sessions.size > 0 ? converting.size / sessions.size : 0
84
+ };
85
+ }
86
+ function aggregateAnalytics(events, previousEvents, range) {
87
+ const kpis = computeKpis(events);
88
+ const previous = computeKpis(previousEvents);
89
+ const byDay = /* @__PURE__ */ new Map();
90
+ const dayOf = (iso) => iso.slice(0, 10);
91
+ for (const event of events) {
92
+ const day = dayOf(event.created_at);
93
+ const entry = byDay.get(day) || { visitors: /* @__PURE__ */ new Set(), conversions: 0 };
94
+ const identity = event.visitor_key || event.session_key;
95
+ if (identity) entry.visitors.add(identity);
96
+ if (isConversion(event)) entry.conversions += 1;
97
+ byDay.set(day, entry);
98
+ }
99
+ const trend = [];
100
+ for (let cursor = /* @__PURE__ */ new Date(`${dayOf(range.from)}T00:00:00Z`); cursor.toISOString().slice(0, 10) <= dayOf(range.to); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
101
+ const day = cursor.toISOString().slice(0, 10);
102
+ const entry = byDay.get(day);
103
+ trend.push({ day, visitors: entry?.visitors.size ?? 0, conversions: entry?.conversions ?? 0 });
104
+ if (trend.length > 370) break;
105
+ }
106
+ const bySession = /* @__PURE__ */ new Map();
107
+ for (const event of events) {
108
+ if (!event.session_key) continue;
109
+ const list = bySession.get(event.session_key) || [];
110
+ list.push(event);
111
+ bySession.set(event.session_key, list);
112
+ }
113
+ for (const list of bySession.values()) {
114
+ list.sort((a, b) => a.created_at.localeCompare(b.created_at));
115
+ }
116
+ const pages = /* @__PURE__ */ new Map();
117
+ const pageEntry = (path) => {
118
+ const entry = pages.get(path) || { views: 0, entries: 0, conversions: 0 };
119
+ pages.set(path, entry);
120
+ return entry;
121
+ };
122
+ for (const event of events) {
123
+ if (event.type === "pageview") pageEntry(event.path).views += 1;
124
+ if (isConversion(event)) pageEntry(event.path).conversions += 1;
125
+ }
126
+ for (const list of bySession.values()) {
127
+ const first = list.find((event) => event.type === "pageview");
128
+ if (first) pageEntry(first.path).entries += 1;
129
+ }
130
+ const sources = /* @__PURE__ */ new Map();
131
+ for (const list of bySession.values()) {
132
+ const first = list.find((event) => event.type === "pageview");
133
+ const label = normalizeSource(first?.utm?.source || "", first?.referrer || "");
134
+ const entry = sources.get(label) || { sessions: 0, conversions: 0 };
135
+ entry.sessions += 1;
136
+ if (list.some(isConversion)) entry.conversions += 1;
137
+ sources.set(label, entry);
138
+ }
139
+ const locations = /* @__PURE__ */ new Map();
140
+ const devices = /* @__PURE__ */ new Map();
141
+ for (const list of bySession.values()) {
142
+ const sample = list[0];
143
+ const location = [sample.city, sample.region].filter(Boolean).join(", ");
144
+ if (location) locations.set(location, (locations.get(location) || 0) + 1);
145
+ if (sample.device) devices.set(sample.device, (devices.get(sample.device) || 0) + 1);
146
+ }
147
+ const hours = new Array(24).fill(0);
148
+ for (const event of events) {
149
+ if (event.type !== "pageview") continue;
150
+ const hour = new Date(event.created_at).getUTCHours();
151
+ if (Number.isFinite(hour)) hours[hour] += 1;
152
+ }
153
+ const pathCounts = /* @__PURE__ */ new Map();
154
+ for (const list of bySession.values()) {
155
+ const steps = [];
156
+ for (const event of list) {
157
+ if (event.type !== "pageview") continue;
158
+ if (steps[steps.length - 1] !== event.path) steps.push(event.path);
159
+ if (steps.length >= 5) break;
160
+ }
161
+ if (steps.length < 2) continue;
162
+ const key = steps.join(" \u2192 ");
163
+ const entry = pathCounts.get(key) || { count: 0, converted: 0 };
164
+ entry.count += 1;
165
+ if (list.some(isConversion)) entry.converted += 1;
166
+ pathCounts.set(key, entry);
167
+ }
168
+ const forms = /* @__PURE__ */ new Map();
169
+ for (const event of events) {
170
+ if (event.type !== "form") continue;
171
+ const [slug, stage] = event.name.split(":");
172
+ if (!slug || !stage) continue;
173
+ const entry = forms.get(slug) || { views: 0, starts: 0, submits: 0 };
174
+ if (stage === "view") entry.views += 1;
175
+ if (stage === "start") entry.starts += 1;
176
+ if (stage === "submit") entry.submits += 1;
177
+ forms.set(slug, entry);
178
+ }
179
+ const notFound = /* @__PURE__ */ new Map();
180
+ for (const event of events) {
181
+ if (event.type === "not_found") notFound.set(event.path, (notFound.get(event.path) || 0) + 1);
182
+ }
183
+ return {
184
+ range,
185
+ kpis,
186
+ previous,
187
+ trend,
188
+ pages: top(pages, 25, (page) => page.views).map(([path, data]) => ({ path, ...data })),
189
+ sources: top(sources, 15, (source) => source.sessions).map(([source, data]) => ({ source, ...data })),
190
+ locations: top(locations, 15, (count) => count).map(([location, sessions]) => ({ location, sessions })),
191
+ devices: [...devices.entries()].map(([device, sessions]) => ({ device, sessions })),
192
+ hours,
193
+ paths: top(pathCounts, 8, (path) => path.count).map(([key, data]) => ({
194
+ path: key.split(" \u2192 "),
195
+ ...data
196
+ })),
197
+ forms: [...forms.entries()].map(([form, data]) => ({ form, ...data })),
198
+ notFound: top(notFound, 20, (count) => count).map(([path, count]) => ({ path, count }))
199
+ };
200
+ }
201
+
202
+ // src/analytics/ingest.ts
203
+ import { createHash, createHmac } from "crypto";
204
+ var EVENT_TYPES = ["pageview", "click", "form", "not_found"];
205
+ var MAX_EVENTS_PER_BATCH = 20;
206
+ var MAX_TEXT = 300;
207
+ var MAX_META_JSON = 1e3;
208
+ function sessionKeyFor(ip, userAgent, secret, now = /* @__PURE__ */ new Date()) {
209
+ const day = now.toISOString().slice(0, 10);
210
+ return createHash("sha256").update(`${secret}|${day}|${ip}|${userAgent}`).digest("hex").slice(0, 24);
211
+ }
212
+ function visitorKeyFor(visitorId, secret) {
213
+ if (typeof visitorId !== "string" || !/^[0-9a-f-]{36}$/i.test(visitorId)) return "";
214
+ return createHmac("sha256", secret).update(visitorId).digest("hex").slice(0, 24);
215
+ }
216
+ var BOT_PATTERN = /bot|crawl|spider|slurp|headless|lighthouse|pingdom|pagespeed|facebookexternalhit|preview|scan|monitor|curl|wget|python-requests|axios|go-http/i;
217
+ function isBotRequest(userAgent) {
218
+ if (!userAgent || userAgent.length < 12) return true;
219
+ return BOT_PATTERN.test(userAgent);
220
+ }
221
+ function deviceFrom(userAgent) {
222
+ if (/ipad|tablet/i.test(userAgent)) return "tablet";
223
+ if (/mobi|iphone|android/i.test(userAgent)) return "mobile";
224
+ return "desktop";
225
+ }
226
+ var clip = (value, max = MAX_TEXT) => typeof value === "string" ? value.slice(0, max) : "";
227
+ var referrerHost = (value) => {
228
+ const raw = clip(value);
229
+ if (!raw) return "";
230
+ try {
231
+ return new URL(raw).hostname;
232
+ } catch {
233
+ return "";
234
+ }
235
+ };
236
+ var cleanUtm = (value) => {
237
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
238
+ const out = {};
239
+ for (const key of ["source", "medium", "campaign", "term", "content"]) {
240
+ const entry = value[key];
241
+ if (typeof entry === "string" && entry) out[key] = entry.slice(0, 100);
242
+ }
243
+ return out;
244
+ };
245
+ var cleanMeta = (value) => {
246
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
247
+ const json2 = JSON.stringify(value);
248
+ if (json2.length > MAX_META_JSON) return {};
249
+ return value;
250
+ };
251
+ function parseEventBatch(body, context) {
252
+ const list = Array.isArray(body?.events) ? body.events : [];
253
+ const rows = [];
254
+ let dropped = 0;
255
+ for (const raw of list.slice(0, MAX_EVENTS_PER_BATCH)) {
256
+ if (!raw || typeof raw !== "object") {
257
+ dropped += 1;
258
+ continue;
259
+ }
260
+ const event = raw;
261
+ const type = event.type;
262
+ if (!EVENT_TYPES.includes(type)) {
263
+ dropped += 1;
264
+ continue;
265
+ }
266
+ const path = clip(event.path);
267
+ if (!path.startsWith("/")) {
268
+ dropped += 1;
269
+ continue;
270
+ }
271
+ rows.push({
272
+ session_key: context.sessionKey,
273
+ visitor_key: context.visitorKey || "",
274
+ type,
275
+ name: clip(event.name, 100),
276
+ path,
277
+ referrer: type === "pageview" ? referrerHost(event.referrer) : "",
278
+ utm: type === "pageview" ? cleanUtm(event.utm) : {},
279
+ device: context.device,
280
+ region: context.region,
281
+ city: context.city,
282
+ meta: cleanMeta(event.meta)
283
+ });
284
+ }
285
+ dropped += Math.max(0, list.length - MAX_EVENTS_PER_BATCH);
286
+ return { rows, dropped };
287
+ }
288
+ function geoFrom(request) {
289
+ const decode2 = (value) => {
290
+ if (!value) return "";
291
+ try {
292
+ return decodeURIComponent(value).slice(0, 80);
293
+ } catch {
294
+ return value.slice(0, 80);
295
+ }
296
+ };
297
+ return {
298
+ region: decode2(request.headers.get("x-vercel-ip-country-region")),
299
+ city: decode2(request.headers.get("x-vercel-ip-city"))
300
+ };
301
+ }
302
+
303
+ // src/server/notify.ts
304
+ function createResendSender(options = {}) {
305
+ const apiKey = options.apiKey ?? process.env.RESEND_API_KEY ?? "";
306
+ if (!apiKey) return null;
307
+ const from = options.from || process.env.CMS_EMAIL_FROM || "onboarding@resend.dev";
308
+ return async (message) => {
309
+ const response = await fetch("https://api.resend.com/emails", {
310
+ method: "POST",
311
+ headers: {
312
+ authorization: `Bearer ${apiKey}`,
313
+ "content-type": "application/json"
314
+ },
315
+ body: JSON.stringify({
316
+ from,
317
+ to: message.to,
318
+ subject: message.subject,
319
+ text: message.text,
320
+ ...message.replyTo ? { reply_to: message.replyTo } : {}
321
+ })
322
+ });
323
+ if (!response.ok) {
324
+ const body = await response.text().catch(() => "");
325
+ throw new Error(`Resend ${response.status}: ${body.slice(0, 300)}`);
326
+ }
327
+ };
328
+ }
329
+ var isEmail = (value) => typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
330
+ function formatSubmissionText(data) {
331
+ const lines = [];
332
+ for (const [key, value] of Object.entries(data)) {
333
+ const label = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_.]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
334
+ const rendered = Array.isArray(value) ? value.join(", ") : typeof value === "string" ? value : JSON.stringify(value);
335
+ lines.push(`${label}:
336
+ ${rendered}
337
+ `);
338
+ }
339
+ return lines.join("\n");
340
+ }
341
+ async function notifySubmission(args) {
342
+ const notify = args.config.notify || {};
343
+ const recipients = (notify.emails || []).filter(isEmail);
344
+ const submitterEmail = Object.entries(args.data).find(
345
+ ([key, value]) => key.toLowerCase().includes("email") && isEmail(value)
346
+ )?.[1];
347
+ const subject = (notify.subject || "New {form} submission").replace(
348
+ /\{form\}/g,
349
+ args.formTitle || "form"
350
+ );
351
+ if (recipients.length > 0) {
352
+ try {
353
+ await args.sendEmail({
354
+ to: recipients,
355
+ subject,
356
+ text: formatSubmissionText(args.data),
357
+ ...submitterEmail ? { replyTo: submitterEmail } : {}
358
+ });
359
+ } catch (error) {
360
+ console.error("[orion-cms] submission notification failed:", error);
361
+ }
362
+ }
363
+ if (notify.autoReply && submitterEmail) {
364
+ try {
365
+ await args.sendEmail({
366
+ to: [submitterEmail],
367
+ subject: `We received your request${args.siteName ? ` \u2014 ${args.siteName}` : ""}`,
368
+ text: (args.successMessage || "Thanks \u2014 we received your request and will follow up soon.") + "\n\n\u2014 " + (args.siteName || args.formTitle)
369
+ });
370
+ } catch (error) {
371
+ console.error("[orion-cms] auto-reply failed:", error);
372
+ }
373
+ }
374
+ }
375
+
376
+ // src/server/permissions.ts
377
+ var ROLE_RANK = {
378
+ content: 0,
379
+ editor: 1,
380
+ admin: 2,
381
+ developer: 3
382
+ };
383
+ var MIN_ROLE = {
384
+ "pages.read": "content",
385
+ "pages.saveDraft": "content",
386
+ "pages.changeStructure": "editor",
387
+ "pages.publish": "editor",
388
+ "pages.create": "editor",
389
+ "pages.delete": "admin",
390
+ "pages.restore": "editor",
391
+ "globals.read": "content",
392
+ "globals.write": "content",
393
+ "media.read": "content",
394
+ "media.upload": "content",
395
+ "media.update": "content",
396
+ "media.delete": "editor",
397
+ "forms.read": "content",
398
+ "forms.write": "editor",
399
+ "forms.delete": "admin",
400
+ "submissions.read": "content",
401
+ "submissions.manage": "editor",
402
+ "redirects.manage": "editor",
403
+ "activity.read": "editor",
404
+ "analytics.read": "content",
405
+ "sync.run": "admin",
406
+ "users.manage": "admin"
407
+ };
408
+ function can(user, action) {
409
+ if (!user) return false;
410
+ return ROLE_RANK[user.role] >= ROLE_RANK[MIN_ROLE[action]];
411
+ }
412
+ var CMS_ROLES = ["content", "editor", "admin", "developer"];
413
+ var isCmsRole = (value) => typeof value === "string" && CMS_ROLES.includes(value);
414
+ var outranksOrEqual = (actor, target) => ROLE_RANK[actor] >= ROLE_RANK[target];
415
+ var assignableRoles = (actor) => CMS_ROLES.filter((role) => outranksOrEqual(actor, role));
416
+ function isStructuralChange(previous, next) {
417
+ if (previous.length !== next.length) return true;
418
+ for (let index = 0; index < previous.length; index += 1) {
419
+ if (previous[index].id !== next[index].id) return true;
420
+ if (previous[index].type !== next[index].type) return true;
421
+ }
422
+ return false;
423
+ }
424
+
425
+ // src/server/preview.ts
426
+ import { createHmac as createHmac2, timingSafeEqual } from "crypto";
427
+ var encode = (value) => Buffer.from(value, "utf8").toString("base64url");
428
+ var decode = (value) => Buffer.from(value, "base64url").toString("utf8");
429
+ var sign = (payload, secret) => createHmac2("sha256", secret).update(payload).digest("base64url");
430
+ var PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1e3;
431
+ function createPreviewToken(pageId, secret, ttlMs = PREVIEW_TOKEN_TTL_MS) {
432
+ const payload = encode(`${pageId}|${Date.now() + ttlMs}`);
433
+ return `${payload}.${sign(payload, secret)}`;
434
+ }
435
+ function verifyPreviewToken(token, secret) {
436
+ const [payload, signature] = token.split(".");
437
+ if (!payload || !signature) return null;
438
+ const expected = sign(payload, secret);
439
+ const expectedBuffer = Buffer.from(expected);
440
+ const actualBuffer = Buffer.from(signature);
441
+ if (expectedBuffer.length !== actualBuffer.length) return null;
442
+ if (!timingSafeEqual(expectedBuffer, actualBuffer)) return null;
443
+ try {
444
+ const [pageId, expiresAt] = decode(payload).split("|");
445
+ if (!pageId || Number(expiresAt) < Date.now()) return null;
446
+ return pageId;
447
+ } catch {
448
+ return null;
449
+ }
450
+ }
451
+ async function getPreviewPage(client, token, secret) {
452
+ const pageId = verifyPreviewToken(token, secret);
453
+ if (!pageId) return null;
454
+ const { data } = await client.from("cms_pages").select("id, slug, path, title, seo, draft_layout").eq("id", pageId).maybeSingle();
455
+ if (!data) return null;
456
+ return {
457
+ id: String(data.id),
458
+ slug: String(data.slug),
459
+ path: String(data.path),
460
+ title: String(data.title ?? ""),
461
+ seo: data.seo ?? {},
462
+ layout: data.draft_layout ?? []
463
+ };
464
+ }
465
+
466
+ // src/server/supabase.ts
467
+ import { createClient } from "@supabase/supabase-js";
468
+ function readCmsEnv() {
469
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || "";
470
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY || "";
471
+ if (!supabaseUrl || !serviceRoleKey) {
472
+ throw new Error(
473
+ "Orion CMS: NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (or SUPABASE_SECRET_KEY) must be set."
474
+ );
475
+ }
476
+ return { supabaseUrl, serviceRoleKey };
477
+ }
478
+ var serviceClient = null;
479
+ function getServiceClient(env = readCmsEnv()) {
480
+ if (!serviceClient) {
481
+ serviceClient = createClient(env.supabaseUrl, env.serviceRoleKey, {
482
+ auth: { persistSession: false, autoRefreshToken: false }
483
+ });
484
+ }
485
+ return serviceClient;
486
+ }
487
+ function setServiceClientForTesting(client) {
488
+ serviceClient = client;
489
+ }
490
+ async function resolveUser(request, client = getServiceClient()) {
491
+ const header = request.headers.get("authorization") || "";
492
+ const token = header.startsWith("Bearer ") ? header.slice(7).trim() : "";
493
+ if (!token) return null;
494
+ const { data, error } = await client.auth.getUser(token);
495
+ if (error || !data.user) return null;
496
+ const { data: profile } = await client.from("cms_profiles").select("role, name").eq("user_id", data.user.id).maybeSingle();
497
+ if (!profile) return null;
498
+ return {
499
+ id: data.user.id,
500
+ email: data.user.email ?? null,
501
+ role: profile.role,
502
+ name: profile.name || ""
503
+ };
504
+ }
505
+
506
+ // src/server/sync.ts
507
+ var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
508
+ var mimeByExtension = {
509
+ avif: "image/avif",
510
+ gif: "image/gif",
511
+ ico: "image/x-icon",
512
+ jpg: "image/jpeg",
513
+ jpeg: "image/jpeg",
514
+ pdf: "application/pdf",
515
+ png: "image/png",
516
+ svg: "image/svg+xml",
517
+ webp: "image/webp"
518
+ };
519
+ var cleanPath = (src) => src.split("?")[0]?.split("#")[0] || src;
520
+ var filenameFromPath = (path) => {
521
+ const clean = cleanPath(path);
522
+ return clean.split("/").filter(Boolean).pop() || clean;
523
+ };
524
+ var mimeFromPath = (path) => {
525
+ const extension = filenameFromPath(path).split(".").pop()?.toLowerCase() || "";
526
+ return mimeByExtension[extension] || "";
527
+ };
528
+ var shouldIndexMediaPath = (src) => Boolean(src) && !src.startsWith("data:") && !/^https?:\/\//i.test(src);
529
+ var toSyncMedia = (value) => {
530
+ const src = typeof value.src === "string" ? value.src.trim() : "";
531
+ if (!shouldIndexMediaPath(src)) return null;
532
+ const filename = typeof value.filename === "string" && value.filename.trim() ? value.filename.trim() : filenameFromPath(src);
533
+ return {
534
+ storagePath: src,
535
+ filename,
536
+ alt: typeof value.alt === "string" ? value.alt : "",
537
+ caption: typeof value.caption === "string" ? value.caption : "",
538
+ mimeType: mimeFromPath(src)
539
+ };
540
+ };
541
+ var collectLayoutMedia = (value, media) => {
542
+ if (Array.isArray(value)) {
543
+ for (const item of value) collectLayoutMedia(item, media);
544
+ return;
545
+ }
546
+ if (!isRecord(value)) return;
547
+ if (typeof value.src === "string" && ("mediaId" in value || "alt" in value || "caption" in value || "filename" in value)) {
548
+ const item = toSyncMedia(value);
549
+ if (item && !media.has(item.storagePath)) media.set(item.storagePath, item);
550
+ }
551
+ for (const child of Object.values(value)) collectLayoutMedia(child, media);
552
+ };
553
+ var normalizeManualMedia = (item) => {
554
+ if (!item || typeof item.storagePath !== "string") return null;
555
+ const storagePath = item.storagePath.trim();
556
+ if (!shouldIndexMediaPath(storagePath)) return null;
557
+ return {
558
+ storagePath,
559
+ filename: typeof item.filename === "string" && item.filename.trim() ? item.filename.trim() : filenameFromPath(storagePath),
560
+ alt: typeof item.alt === "string" ? item.alt : "",
561
+ caption: typeof item.caption === "string" ? item.caption : "",
562
+ mimeType: typeof item.mimeType === "string" ? item.mimeType : mimeFromPath(storagePath),
563
+ width: typeof item.width === "number" ? item.width : null,
564
+ height: typeof item.height === "number" ? item.height : null,
565
+ filesize: typeof item.filesize === "number" ? item.filesize : null
566
+ };
567
+ };
568
+ async function runContentSync(client, registry, input) {
569
+ const pages = input.pages || [];
570
+ const globals = input.globals || [];
571
+ const forms = input.forms || [];
572
+ const media = /* @__PURE__ */ new Map();
573
+ const synced = [];
574
+ const skipped = [];
575
+ for (const page of pages) {
576
+ if (!page || typeof page.slug !== "string") continue;
577
+ const validated = registry.validateLayout(page.layout ?? []);
578
+ if (!validated.ok) {
579
+ skipped.push({ slug: page.slug, issues: validated.issues });
580
+ continue;
581
+ }
582
+ const { error } = await client.rpc("cms_sync_page", {
583
+ p_slug: page.slug,
584
+ p_path: typeof page.path === "string" ? page.path : page.slug === "home" ? "/" : `/${page.slug}`,
585
+ p_title: typeof page.title === "string" ? page.title : page.slug,
586
+ p_seo: isRecord(page.seo) ? page.seo : {},
587
+ p_layout: validated.layout
588
+ });
589
+ if (error) {
590
+ skipped.push({ slug: page.slug, issues: error.message });
591
+ continue;
592
+ }
593
+ collectLayoutMedia(validated.layout, media);
594
+ synced.push(page.slug);
595
+ }
596
+ for (const item of input.media || []) {
597
+ const normalized = normalizeManualMedia(item);
598
+ if (normalized && !media.has(normalized.storagePath)) media.set(normalized.storagePath, normalized);
599
+ }
600
+ let syncedMedia = 0;
601
+ for (const item of media.values()) {
602
+ const { data: existing, error: readError } = await client.from("cms_media").select("id").eq("storage_path", item.storagePath).maybeSingle();
603
+ if (readError || existing) continue;
604
+ const { error: insertError } = await client.from("cms_media").insert({
605
+ storage_path: item.storagePath,
606
+ filename: item.filename || filenameFromPath(item.storagePath),
607
+ alt: item.alt || "",
608
+ caption: item.caption || "",
609
+ mime_type: item.mimeType || mimeFromPath(item.storagePath),
610
+ width: item.width ?? null,
611
+ height: item.height ?? null,
612
+ filesize: item.filesize ?? null
613
+ });
614
+ if (!insertError) syncedMedia += 1;
615
+ }
616
+ const failed = [];
617
+ let syncedGlobals = 0;
618
+ for (const global of globals) {
619
+ if (!global || typeof global.key !== "string" || !isRecord(global.data)) continue;
620
+ const { error } = await client.from("cms_globals").upsert({ key: global.key, data: global.data, updated_at: (/* @__PURE__ */ new Date()).toISOString() });
621
+ if (error) failed.push({ kind: "global", key: global.key, error: error.message });
622
+ else syncedGlobals += 1;
623
+ }
624
+ let syncedForms = 0;
625
+ for (const form of forms) {
626
+ if (!form || typeof form.slug !== "string") continue;
627
+ const { error } = await client.from("cms_forms").upsert(
628
+ {
629
+ slug: form.slug,
630
+ title: typeof form.title === "string" ? form.title : form.slug,
631
+ config: isRecord(form.config) ? form.config : {},
632
+ success_message: typeof form.successMessage === "string" ? form.successMessage : "",
633
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
634
+ },
635
+ { onConflict: "slug" }
636
+ );
637
+ if (error) failed.push({ kind: "form", key: form.slug, error: error.message });
638
+ else syncedForms += 1;
639
+ }
640
+ return { synced, skipped, globals: syncedGlobals, forms: syncedForms, media: syncedMedia, failed };
641
+ }
642
+
643
+ // src/server/routes.ts
644
+ import { timingSafeEqual as timingSafeEqual2 } from "crypto";
645
+ var tokenEquals = (candidate, secret) => {
646
+ if (!candidate || !secret) return false;
647
+ const a = Buffer.from(candidate);
648
+ const b = Buffer.from(secret);
649
+ return a.length === b.length && timingSafeEqual2(a, b);
650
+ };
651
+ var json = (body, status = 200) => new Response(JSON.stringify(body), {
652
+ status,
653
+ headers: { "content-type": "application/json" }
654
+ });
655
+ var errors = {
656
+ unauthorized: () => json({ error: "Not authorized." }, 401),
657
+ forbidden: () => json({ error: "Forbidden." }, 403),
658
+ notFound: () => json({ error: "Not found." }, 404),
659
+ badRequest: (message, extra) => json({ error: message, ...extra }, 400),
660
+ conflict: (message, extra) => json({ error: message, ...extra }, 409)
661
+ };
662
+ var isRecord2 = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
663
+ var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
664
+ var PATH_PATTERN = /^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/;
665
+ var DEFAULT_MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
666
+ var ALLOWED_UPLOAD_TYPES = /* @__PURE__ */ new Set([
667
+ "image/jpeg",
668
+ "image/png",
669
+ "image/webp",
670
+ "image/gif",
671
+ "image/avif",
672
+ "image/svg+xml",
673
+ "application/pdf"
674
+ ]);
675
+ async function readJson(request) {
676
+ try {
677
+ const parsed = await request.json();
678
+ return isRecord2(parsed) ? parsed : null;
679
+ } catch {
680
+ return null;
681
+ }
682
+ }
683
+ async function revalidateContent() {
684
+ try {
685
+ const mod = await import("next/cache");
686
+ if (typeof mod.updateTag === "function") {
687
+ ;
688
+ mod.updateTag(CONTENT_CACHE_TAG);
689
+ } else if (typeof mod.revalidateTag === "function") {
690
+ ;
691
+ mod.revalidateTag(CONTENT_CACHE_TAG, "max");
692
+ }
693
+ } catch {
694
+ }
695
+ }
696
+ var clientKey = (request) => {
697
+ const vercel = request.headers.get("x-vercel-forwarded-for");
698
+ if (vercel) return vercel.split(",").pop().trim();
699
+ const real = request.headers.get("x-real-ip");
700
+ if (real) return real.trim();
701
+ const forwarded = request.headers.get("x-forwarded-for");
702
+ if (forwarded) {
703
+ const parts = forwarded.split(",").map((part) => part.trim()).filter(Boolean);
704
+ if (parts.length > 0) return parts[parts.length - 1];
705
+ }
706
+ return "unknown";
707
+ };
708
+ var MAX_PUBLIC_BODY_BYTES = 64 * 1024;
709
+ var MAX_PUBLIC_BODY_DEPTH = 12;
710
+ function exceedsDepth(value, limit, depth = 0) {
711
+ if (depth > limit) return true;
712
+ if (Array.isArray(value)) return value.some((item) => exceedsDepth(item, limit, depth + 1));
713
+ if (isRecord2(value)) return Object.values(value).some((item) => exceedsDepth(item, limit, depth + 1));
714
+ return false;
715
+ }
716
+ async function readJsonLimited(request, limits) {
717
+ let text;
718
+ try {
719
+ text = await request.text();
720
+ } catch {
721
+ return null;
722
+ }
723
+ if (text.length > limits.maxBytes) return null;
724
+ let parsed;
725
+ try {
726
+ parsed = JSON.parse(text);
727
+ } catch {
728
+ return null;
729
+ }
730
+ if (!isRecord2(parsed)) return null;
731
+ if (exceedsDepth(parsed, limits.maxDepth)) return null;
732
+ return parsed;
733
+ }
734
+ var hashedClientKey = (ip) => createHash2("sha256").update(ip).digest("hex").slice(0, 24);
735
+ function createDurableRateLimitStore(getClient, options) {
736
+ const max = options?.max ?? 5;
737
+ const windowSeconds = Math.max(1, Math.round((options?.windowMs ?? 6e4) / 1e3));
738
+ const bucket = options?.bucket ?? "default";
739
+ return {
740
+ async isLimited(key) {
741
+ try {
742
+ const { data, error } = await getClient().rpc("cms_rate_limit_consume", {
743
+ p_key: `${bucket}:${key}`,
744
+ p_max: max,
745
+ p_window_seconds: windowSeconds
746
+ });
747
+ if (error) return false;
748
+ return data === false;
749
+ } catch {
750
+ return false;
751
+ }
752
+ }
753
+ };
754
+ }
755
+ function createCmsRoutes(options) {
756
+ const { registry, allowedOrigins, syncToken, knownGoodEmailDomains } = options;
757
+ const db = () => options.client ?? getServiceClient();
758
+ const rateLimit = options.rateLimitStore === null ? null : options.rateLimitStore || (options.memoryMode ? createMemoryRateLimitStore() : createDurableRateLimitStore(db, { max: 5, windowMs: 6e4, bucket: "submit" }));
759
+ const sendEmail = options.sendEmail === null ? null : options.sendEmail || createResendSender();
760
+ const maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
761
+ const previewSecret = () => options.previewSecret || process.env.SUPABASE_SERVICE_ROLE_KEY || (options.memoryMode ? "orion-memory-preview-secret" : "");
762
+ const eventsRateLimit = options.memoryMode ? createMemoryRateLimitStore({ max: 120, windowMs: 6e4 }) : createDurableRateLimitStore(db, { max: 120, windowMs: 6e4, bucket: "events" });
763
+ const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
764
+ const requestSessionKey = (request) => {
765
+ const secret = previewSecret() || "orion-analytics";
766
+ return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "", secret);
767
+ };
768
+ const guard = async (request, action) => {
769
+ const user = await resolveUser(request, db());
770
+ if (!user) return errors.unauthorized();
771
+ if (!can(user, action)) return errors.forbidden();
772
+ return { user };
773
+ };
774
+ const logActivity = async (user, action, subject) => {
775
+ try {
776
+ await db().from("cms_activity").insert({
777
+ actor: user?.id ?? null,
778
+ actor_name: user?.name || user?.email || "",
779
+ action,
780
+ subject
781
+ });
782
+ } catch {
783
+ }
784
+ };
785
+ const hasDraftChanges = (page) => {
786
+ if (page.status !== "published") return true;
787
+ return JSON.stringify(page.draft_layout ?? []) !== JSON.stringify(page.published_layout ?? []);
788
+ };
789
+ const listPages = async (request) => {
790
+ const auth = await guard(request, "pages.read");
791
+ if (auth instanceof Response) return auth;
792
+ const { data, error } = await db().from("cms_pages").select(
793
+ "id, slug, path, title, status, builder_owned, updated_at, published_at, publish_at, draft_layout, published_layout"
794
+ ).order("path");
795
+ if (error) return errors.badRequest(error.message);
796
+ const pages = (data || []).map((page) => {
797
+ const { draft_layout, published_layout, ...summary } = page;
798
+ void draft_layout;
799
+ void published_layout;
800
+ return { ...summary, has_draft_changes: hasDraftChanges(page) };
801
+ });
802
+ return json({ pages });
803
+ };
804
+ const getPage = async (request, id) => {
805
+ const auth = await guard(request, "pages.read");
806
+ if (auth instanceof Response) return auth;
807
+ const { data, error } = await db().from("cms_pages").select("*").eq("id", id).maybeSingle();
808
+ if (error) return errors.badRequest(error.message);
809
+ if (!data) return errors.notFound();
810
+ return json({ page: data });
811
+ };
812
+ const createPage = async (request) => {
813
+ const auth = await guard(request, "pages.create");
814
+ if (auth instanceof Response) return auth;
815
+ const body = await readJson(request);
816
+ if (!body || typeof body.slug !== "string" || typeof body.title !== "string") {
817
+ return errors.badRequest("slug and title are required.");
818
+ }
819
+ const slug = body.slug.trim().toLowerCase();
820
+ if (!SLUG_PATTERN.test(slug)) {
821
+ return errors.badRequest("Slug may only contain lowercase letters, numbers, and dashes.");
822
+ }
823
+ const path = typeof body.path === "string" && body.path.length > 0 ? body.path : slug === "home" ? "/" : `/${slug}`;
824
+ if (!PATH_PATTERN.test(path)) {
825
+ return errors.badRequest("Path must look like /about or /services/pest-control.");
826
+ }
827
+ const { data, error } = await db().from("cms_pages").insert({ slug, path, title: body.title, seo: isRecord2(body.seo) ? body.seo : {} }).select("*").single();
828
+ if (error) {
829
+ if (error.message.includes("duplicate")) {
830
+ return errors.badRequest("A page with this slug or path already exists.");
831
+ }
832
+ return errors.badRequest(error.message);
833
+ }
834
+ await logActivity(auth.user, "page.create", path);
835
+ return json({ page: data }, 201);
836
+ };
837
+ const savePageDraft = async (request, id) => {
838
+ const auth = await guard(request, "pages.saveDraft");
839
+ if (auth instanceof Response) return auth;
840
+ const body = await readJson(request);
841
+ if (!body) return errors.badRequest("Invalid body.");
842
+ let layout = null;
843
+ if (body.layout !== void 0) {
844
+ const validated = registry.validateLayout(body.layout);
845
+ if (!validated.ok) {
846
+ return errors.badRequest("Layout validation failed.", { issues: validated.issues });
847
+ }
848
+ layout = validated.layout;
849
+ }
850
+ const wantsIdentityChange = body.slug !== void 0 || body.path !== void 0;
851
+ const wantsSchedule = body.publishAt !== void 0;
852
+ const needCurrent = wantsIdentityChange || wantsSchedule || layout !== null && !can(auth.user, "pages.changeStructure");
853
+ let current = null;
854
+ if (needCurrent) {
855
+ const { data: data2 } = await db().from("cms_pages").select("*").eq("id", id).maybeSingle();
856
+ if (!data2) return errors.notFound();
857
+ current = data2;
858
+ }
859
+ if (layout && !can(auth.user, "pages.changeStructure") && current) {
860
+ const previous = current.draft_layout || [];
861
+ if (isStructuralChange(previous, layout)) {
862
+ return errors.forbidden();
863
+ }
864
+ }
865
+ if (wantsIdentityChange && current) {
866
+ if (!can(auth.user, "pages.create")) return errors.forbidden();
867
+ const patch = {};
868
+ if (body.slug !== void 0) {
869
+ const slug = String(body.slug).trim().toLowerCase();
870
+ if (!SLUG_PATTERN.test(slug)) {
871
+ return errors.badRequest("Slug may only contain lowercase letters, numbers, and dashes.");
872
+ }
873
+ patch.slug = slug;
874
+ }
875
+ if (body.path !== void 0) {
876
+ const path = String(body.path).trim();
877
+ if (!PATH_PATTERN.test(path)) {
878
+ return errors.badRequest("Path must look like /about or /services/pest-control.");
879
+ }
880
+ patch.path = path;
881
+ }
882
+ if (Object.keys(patch).length > 0) {
883
+ const oldPath = String(current.path || "");
884
+ const wasPublished = current.status === "published";
885
+ const { error: error2 } = await db().from("cms_pages").update(patch).eq("id", id);
886
+ if (error2) {
887
+ if (error2.message.includes("duplicate")) {
888
+ return errors.badRequest("Another page already uses this slug or path.");
889
+ }
890
+ return errors.badRequest(error2.message);
891
+ }
892
+ const newPath = typeof patch.path === "string" ? patch.path : oldPath;
893
+ if (newPath !== oldPath && wasPublished && oldPath !== "/") {
894
+ await db().from("cms_redirects").delete().eq("from_path", newPath);
895
+ await db().from("cms_redirects").upsert({ from_path: oldPath, to_path: newPath, permanent: true }, { onConflict: "from_path" });
896
+ }
897
+ if (newPath !== oldPath) {
898
+ await logActivity(auth.user, "page.rename", `${oldPath} \u2192 ${newPath}`);
899
+ await revalidateContent();
900
+ }
901
+ }
902
+ }
903
+ if (wantsSchedule) {
904
+ if (!can(auth.user, "pages.publish")) return errors.forbidden();
905
+ let publishAt = null;
906
+ if (body.publishAt !== null) {
907
+ const parsed = Date.parse(String(body.publishAt));
908
+ if (Number.isNaN(parsed)) return errors.badRequest("publishAt must be a valid date.");
909
+ publishAt = new Date(parsed).toISOString();
910
+ }
911
+ const { error: error2 } = await db().from("cms_pages").update({ publish_at: publishAt }).eq("id", id);
912
+ if (error2) return errors.badRequest(error2.message);
913
+ await logActivity(
914
+ auth.user,
915
+ publishAt ? "page.schedule" : "page.unschedule",
916
+ `${current?.path ?? id}${publishAt ? ` @ ${publishAt}` : ""}`
917
+ );
918
+ }
919
+ const hasContentChange = body.title !== void 0 || body.seo !== void 0 || layout !== null;
920
+ if (!hasContentChange) {
921
+ const { data: data2 } = await db().from("cms_pages").select("*").eq("id", id).maybeSingle();
922
+ return json({ page: data2 });
923
+ }
924
+ const { data, error } = await db().rpc("cms_save_page_draft", {
925
+ p_page_id: id,
926
+ p_title: typeof body.title === "string" ? body.title : null,
927
+ p_seo: isRecord2(body.seo) ? body.seo : null,
928
+ p_layout: layout,
929
+ p_actor: auth.user.id
930
+ });
931
+ if (error) return errors.badRequest(error.message);
932
+ return json({ page: data });
933
+ };
934
+ const publishPage = async (request, id) => {
935
+ const auth = await guard(request, "pages.publish");
936
+ if (auth instanceof Response) return auth;
937
+ const { data, error } = await db().rpc("cms_publish_page", {
938
+ p_page_id: id,
939
+ p_actor: auth.user.id
940
+ });
941
+ if (error) return errors.badRequest(error.message);
942
+ await db().from("cms_pages").update({ publish_at: null }).eq("id", id);
943
+ await logActivity(auth.user, "page.publish", String(data?.path ?? id));
944
+ await revalidateContent();
945
+ return json({ page: data });
946
+ };
947
+ const unpublishPage = async (request, id) => {
948
+ const auth = await guard(request, "pages.publish");
949
+ if (auth instanceof Response) return auth;
950
+ const { data, error } = await db().from("cms_pages").update({ status: "draft", publish_at: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).select("*").single();
951
+ if (error) return errors.badRequest(error.message);
952
+ await logActivity(auth.user, "page.unpublish", String(data?.path ?? id));
953
+ await revalidateContent();
954
+ return json({ page: data });
955
+ };
956
+ const duplicatePage = async (request, id) => {
957
+ const auth = await guard(request, "pages.create");
958
+ if (auth instanceof Response) return auth;
959
+ const { data: source } = await db().from("cms_pages").select("*").eq("id", id).maybeSingle();
960
+ if (!source) return errors.notFound();
961
+ const baseSlug = `${source.slug}-copy`;
962
+ let slug = baseSlug;
963
+ for (let attempt = 2; attempt < 50; attempt += 1) {
964
+ const { data: existing } = await db().from("cms_pages").select("id").eq("slug", slug).maybeSingle();
965
+ if (!existing) break;
966
+ slug = `${baseSlug}-${attempt}`;
967
+ }
968
+ const { data, error } = await db().from("cms_pages").insert({
969
+ slug,
970
+ path: `/${slug}`,
971
+ title: `${source.title} (copy)`,
972
+ seo: source.seo ?? {},
973
+ draft_layout: source.draft_layout ?? [],
974
+ status: "draft",
975
+ builder_owned: true
976
+ }).select("*").single();
977
+ if (error) return errors.badRequest(error.message);
978
+ await logActivity(auth.user, "page.duplicate", `${source.path} \u2192 /${slug}`);
979
+ return json({ page: data }, 201);
980
+ };
981
+ const deletePage = async (request, id) => {
982
+ const auth = await guard(request, "pages.delete");
983
+ if (auth instanceof Response) return auth;
984
+ const { data: doc } = await db().from("cms_pages").select("path").eq("id", id).maybeSingle();
985
+ const { error } = await db().from("cms_pages").delete().eq("id", id);
986
+ if (error) return errors.badRequest(error.message);
987
+ await logActivity(auth.user, "page.delete", String(doc?.path ?? id));
988
+ await revalidateContent();
989
+ return json({ success: true });
990
+ };
991
+ const previewToken = async (request, id) => {
992
+ const auth = await guard(request, "pages.read");
993
+ if (auth instanceof Response) return auth;
994
+ const secret = previewSecret();
995
+ if (!secret) return errors.badRequest("Preview is not configured on this site.");
996
+ const { data } = await db().from("cms_pages").select("id, path").eq("id", id).maybeSingle();
997
+ if (!data) return errors.notFound();
998
+ const token = createPreviewToken(id, secret);
999
+ return json({ token, path: data.path, url: `${data.path}?preview=${encodeURIComponent(token)}` });
1000
+ };
1001
+ const previewPage = async (request) => {
1002
+ const secret = previewSecret();
1003
+ if (!secret) return errors.notFound();
1004
+ const token = new URL(request.url).searchParams.get("token") || "";
1005
+ const pageId = verifyPreviewToken(token, secret);
1006
+ if (!pageId) return errors.forbidden();
1007
+ const { data } = await db().from("cms_pages").select("id, slug, path, title, seo, draft_layout").eq("id", pageId).maybeSingle();
1008
+ if (!data) return errors.notFound();
1009
+ return json({
1010
+ page: {
1011
+ id: data.id,
1012
+ slug: data.slug,
1013
+ path: data.path,
1014
+ title: data.title,
1015
+ seo: data.seo ?? {},
1016
+ layout: data.draft_layout ?? []
1017
+ }
1018
+ });
1019
+ };
1020
+ const listVersions = async (request, id) => {
1021
+ const auth = await guard(request, "pages.restore");
1022
+ if (auth instanceof Response) return auth;
1023
+ const { data, error } = await db().from("cms_page_versions").select("id, kind, title, created_by, created_at").eq("page_id", id).order("created_at", { ascending: false }).limit(50);
1024
+ if (error) return errors.badRequest(error.message);
1025
+ const { data: profiles } = await db().from("cms_profiles").select("user_id, name");
1026
+ const names = new Map(
1027
+ (profiles || []).map((profile) => [profile.user_id, profile.name || ""])
1028
+ );
1029
+ const versions = (data || []).map((version) => ({
1030
+ ...version,
1031
+ author: version.created_by ? names.get(version.created_by) || "Unknown user" : "System"
1032
+ }));
1033
+ return json({ versions });
1034
+ };
1035
+ const getVersion = async (request, versionId) => {
1036
+ const auth = await guard(request, "pages.restore");
1037
+ if (auth instanceof Response) return auth;
1038
+ const { data, error } = await db().from("cms_page_versions").select("*").eq("id", Number(versionId)).maybeSingle();
1039
+ if (error) return errors.badRequest(error.message);
1040
+ if (!data) return errors.notFound();
1041
+ return json({ version: data });
1042
+ };
1043
+ const restoreVersion = async (request, versionId) => {
1044
+ const auth = await guard(request, "pages.restore");
1045
+ if (auth instanceof Response) return auth;
1046
+ const { data, error } = await db().rpc("cms_restore_page_version", {
1047
+ p_version_id: Number(versionId),
1048
+ p_actor: auth.user.id
1049
+ });
1050
+ if (error) return errors.badRequest(error.message);
1051
+ await logActivity(auth.user, "page.restore", String(data?.path ?? versionId));
1052
+ return json({ page: data });
1053
+ };
1054
+ const getGlobal = async (request, key) => {
1055
+ const auth = await guard(request, "globals.read");
1056
+ if (auth instanceof Response) return auth;
1057
+ const { data, error } = await db().from("cms_globals").select("*").eq("key", key).maybeSingle();
1058
+ if (error) return errors.badRequest(error.message);
1059
+ return json({ global: data ?? { key, data: {} } });
1060
+ };
1061
+ const updateGlobal = async (request, key) => {
1062
+ const auth = await guard(request, "globals.write");
1063
+ if (auth instanceof Response) return auth;
1064
+ const body = await readJson(request);
1065
+ if (!body || !isRecord2(body.data)) return errors.badRequest("data object required.");
1066
+ const { data, error } = await db().rpc("cms_update_global", {
1067
+ p_key: key,
1068
+ p_data: body.data,
1069
+ p_actor: auth.user.id
1070
+ });
1071
+ if (error) return errors.badRequest(error.message);
1072
+ await logActivity(auth.user, "global.save", key);
1073
+ await revalidateContent();
1074
+ return json({ global: data });
1075
+ };
1076
+ const listGlobalVersions = async (request, key) => {
1077
+ const auth = await guard(request, "globals.read");
1078
+ if (auth instanceof Response) return auth;
1079
+ const { data, error } = await db().from("cms_global_versions").select("id, key, data, created_by, created_at").eq("key", key).order("created_at", { ascending: false }).limit(30);
1080
+ if (error) return errors.badRequest(error.message);
1081
+ return json({ versions: data });
1082
+ };
1083
+ const restoreGlobalVersion = async (request, versionId) => {
1084
+ const auth = await guard(request, "globals.write");
1085
+ if (auth instanceof Response) return auth;
1086
+ const { data: version } = await db().from("cms_global_versions").select("*").eq("id", Number(versionId)).maybeSingle();
1087
+ if (!version) return errors.notFound();
1088
+ const { data, error } = await db().from("cms_globals").upsert({
1089
+ key: version.key,
1090
+ data: version.data,
1091
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1092
+ }).select("*").single();
1093
+ if (error) return errors.badRequest(error.message);
1094
+ await db().from("cms_global_versions").insert({ key: version.key, data: version.data, created_by: auth.user.id });
1095
+ await logActivity(auth.user, "global.restore", String(version.key));
1096
+ await revalidateContent();
1097
+ return json({ global: data });
1098
+ };
1099
+ const listMedia = async (request) => {
1100
+ const auth = await guard(request, "media.read");
1101
+ if (auth instanceof Response) return auth;
1102
+ const { data, error } = await db().from("cms_media").select("*").order("created_at", { ascending: false }).limit(500);
1103
+ if (error) return errors.badRequest(error.message);
1104
+ return json({ media: data });
1105
+ };
1106
+ const validateUpload = (file) => {
1107
+ if (file.size > maxUploadBytes) {
1108
+ return `File is too large (max ${Math.round(maxUploadBytes / 1024 / 1024)} MB).`;
1109
+ }
1110
+ const type = file.type || "";
1111
+ if (!ALLOWED_UPLOAD_TYPES.has(type)) {
1112
+ return "Unsupported file type. Allowed: images (JPEG, PNG, WebP, GIF, AVIF, SVG) and PDF.";
1113
+ }
1114
+ return null;
1115
+ };
1116
+ const uploadMedia = async (request) => {
1117
+ const auth = await guard(request, "media.upload");
1118
+ if (auth instanceof Response) return auth;
1119
+ const form = await request.formData().catch(() => null);
1120
+ const file = form?.get("file");
1121
+ if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
1122
+ const invalid = validateUpload(file);
1123
+ if (invalid) return errors.badRequest(invalid);
1124
+ const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
1125
+ let storagePath = `${Date.now().toString(36)}-${safeName}`;
1126
+ if (options.memoryMode) {
1127
+ const buffer = Buffer.from(await file.arrayBuffer());
1128
+ storagePath = `data:${file.type || "application/octet-stream"};base64,${buffer.toString("base64")}`;
1129
+ } else {
1130
+ const { error: storageError } = await db().storage.from("media").upload(storagePath, file, { contentType: file.type || "application/octet-stream" });
1131
+ if (storageError) return errors.badRequest(storageError.message);
1132
+ }
1133
+ const uploadedToStorage = !options.memoryMode;
1134
+ const width = Number(form.get("width")) || null;
1135
+ const height = Number(form.get("height")) || null;
1136
+ const { data, error } = await db().from("cms_media").insert({
1137
+ storage_path: storagePath,
1138
+ filename: safeName,
1139
+ alt: String(form.get("alt") || ""),
1140
+ caption: String(form.get("caption") || ""),
1141
+ mime_type: file.type || "",
1142
+ width,
1143
+ height,
1144
+ filesize: file.size
1145
+ }).select("*").single();
1146
+ if (error) {
1147
+ if (uploadedToStorage) {
1148
+ await db().storage.from("media").remove([storagePath]).catch(() => void 0);
1149
+ }
1150
+ return errors.badRequest(error.message);
1151
+ }
1152
+ await logActivity(auth.user, "media.upload", safeName);
1153
+ return json({ media: data }, 201);
1154
+ };
1155
+ const updateMedia = async (request, id) => {
1156
+ const auth = await guard(request, "media.update");
1157
+ if (auth instanceof Response) return auth;
1158
+ const body = await readJson(request);
1159
+ if (!body) return errors.badRequest("Invalid body.");
1160
+ const patch = { updated_at: (/* @__PURE__ */ new Date()).toISOString() };
1161
+ if (typeof body.alt === "string") patch.alt = body.alt;
1162
+ if (typeof body.caption === "string") patch.caption = body.caption;
1163
+ if (typeof body.filename === "string" && body.filename.trim()) {
1164
+ patch.filename = body.filename.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
1165
+ }
1166
+ const { data, error } = await db().from("cms_media").update(patch).eq("id", id).select("*").single();
1167
+ if (error) return errors.badRequest(error.message);
1168
+ if (!data) return errors.notFound();
1169
+ return json({ media: data });
1170
+ };
1171
+ const findMediaUsage = async (mediaId) => {
1172
+ const { data: pages } = await db().from("cms_pages").select("id, title, path, draft_layout, published_layout");
1173
+ const needle = `"mediaId":"${mediaId}"`;
1174
+ return (pages || []).filter((page) => {
1175
+ const haystack = JSON.stringify(page.draft_layout ?? []) + JSON.stringify(page.published_layout ?? []);
1176
+ return haystack.includes(needle);
1177
+ }).map((page) => ({
1178
+ id: String(page.id),
1179
+ title: String(page.title ?? ""),
1180
+ path: String(page.path ?? "")
1181
+ }));
1182
+ };
1183
+ const mediaUsage = async (request, id) => {
1184
+ const auth = await guard(request, "media.read");
1185
+ if (auth instanceof Response) return auth;
1186
+ return json({ usage: await findMediaUsage(id) });
1187
+ };
1188
+ const replaceMedia = async (request, id) => {
1189
+ const auth = await guard(request, "media.upload");
1190
+ if (auth instanceof Response) return auth;
1191
+ const { data: doc } = await db().from("cms_media").select("*").eq("id", id).maybeSingle();
1192
+ if (!doc) return errors.notFound();
1193
+ const form = await request.formData().catch(() => null);
1194
+ const file = form?.get("file");
1195
+ if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
1196
+ const invalid = validateUpload(file);
1197
+ if (invalid) return errors.badRequest(invalid);
1198
+ let storagePath = String(doc.storage_path);
1199
+ if (options.memoryMode) {
1200
+ const buffer = Buffer.from(await file.arrayBuffer());
1201
+ storagePath = `data:${file.type || "application/octet-stream"};base64,${buffer.toString("base64")}`;
1202
+ } else {
1203
+ const { error: storageError } = await db().storage.from("media").upload(storagePath, file, {
1204
+ contentType: file.type || "application/octet-stream",
1205
+ upsert: true
1206
+ });
1207
+ if (storageError) return errors.badRequest(storageError.message);
1208
+ }
1209
+ const width = Number(form.get("width")) || null;
1210
+ const height = Number(form.get("height")) || null;
1211
+ const { data, error } = await db().from("cms_media").update({
1212
+ storage_path: storagePath,
1213
+ mime_type: file.type || "",
1214
+ filesize: file.size,
1215
+ width,
1216
+ height,
1217
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1218
+ }).eq("id", id).select("*").single();
1219
+ if (error) return errors.badRequest(error.message);
1220
+ await logActivity(auth.user, "media.replace", String(doc.filename));
1221
+ await revalidateContent();
1222
+ return json({ media: data });
1223
+ };
1224
+ const deleteMedia = async (request, id) => {
1225
+ const auth = await guard(request, "media.delete");
1226
+ if (auth instanceof Response) return auth;
1227
+ const { data: doc } = await db().from("cms_media").select("*").eq("id", id).maybeSingle();
1228
+ if (!doc) return errors.notFound();
1229
+ const force = new URL(request.url).searchParams.get("force") === "true";
1230
+ const usage = await findMediaUsage(id);
1231
+ if (usage.length > 0 && !force) {
1232
+ return errors.conflict("This file is used on pages.", { usage });
1233
+ }
1234
+ const { error } = await db().from("cms_media").delete().eq("id", id);
1235
+ if (error) return errors.badRequest(error.message);
1236
+ if (!String(doc.storage_path).startsWith("data:")) {
1237
+ await db().storage.from("media").remove([doc.storage_path]).catch(() => void 0);
1238
+ }
1239
+ await logActivity(auth.user, "media.delete", String(doc.filename));
1240
+ return json({ success: true });
1241
+ };
1242
+ const listForms = async (request) => {
1243
+ const auth = await guard(request, "forms.read");
1244
+ if (auth instanceof Response) return auth;
1245
+ const { data: forms, error } = await db().from("cms_forms").select("id, slug, title, config, success_message, updated_at").order("title");
1246
+ if (error) return errors.badRequest(error.message);
1247
+ const { data: submissions } = await db().from("cms_form_submissions").select("form_id, read_at").limit(1e4);
1248
+ const counts = /* @__PURE__ */ new Map();
1249
+ for (const submission of submissions || []) {
1250
+ const formId = String(submission.form_id);
1251
+ const entry = counts.get(formId) || { total: 0, unread: 0 };
1252
+ entry.total += 1;
1253
+ if (!submission.read_at) entry.unread += 1;
1254
+ counts.set(formId, entry);
1255
+ }
1256
+ return json({
1257
+ forms: (forms || []).map((form) => ({
1258
+ ...form,
1259
+ submissionCount: counts.get(String(form.id))?.total ?? 0,
1260
+ unreadCount: counts.get(String(form.id))?.unread ?? 0
1261
+ }))
1262
+ });
1263
+ };
1264
+ const createForm = async (request) => {
1265
+ const auth = await guard(request, "forms.write");
1266
+ if (auth instanceof Response) return auth;
1267
+ const body = await readJson(request);
1268
+ const slug = typeof body?.slug === "string" ? body.slug.trim().toLowerCase() : "";
1269
+ const title = typeof body?.title === "string" ? body.title.trim() : "";
1270
+ if (!slug || !SLUG_PATTERN.test(slug)) {
1271
+ return errors.badRequest("Slug may only contain lowercase letters, numbers, and dashes.");
1272
+ }
1273
+ if (!title) return errors.badRequest("Title is required.");
1274
+ const { data, error } = await db().from("cms_forms").insert({
1275
+ slug,
1276
+ title,
1277
+ config: { steps: [{ title: "", fields: [] }] },
1278
+ success_message: "Thanks \u2014 we received your submission."
1279
+ }).select("*").single();
1280
+ if (error) {
1281
+ if (error.message.includes("duplicate")) {
1282
+ return errors.badRequest("A form with this slug already exists.");
1283
+ }
1284
+ return errors.badRequest(error.message);
1285
+ }
1286
+ await logActivity(auth.user, "form.create", slug);
1287
+ return json({ form: data }, 201);
1288
+ };
1289
+ const getForm = async (request, slug) => {
1290
+ const auth = await guard(request, "forms.read");
1291
+ if (auth instanceof Response) return auth;
1292
+ const { data, error } = await db().from("cms_forms").select("*").eq("slug", slug).maybeSingle();
1293
+ if (error) return errors.badRequest(error.message);
1294
+ if (!data) return errors.notFound();
1295
+ return json({ form: data });
1296
+ };
1297
+ const updateForm = async (request, slug) => {
1298
+ const auth = await guard(request, "forms.write");
1299
+ if (auth instanceof Response) return auth;
1300
+ const body = await readJson(request);
1301
+ if (!body) return errors.badRequest("Invalid body.");
1302
+ const config = isRecord2(body.config) ? { ...body.config } : {};
1303
+ const notify = isRecord2(body.notify) ? body.notify : isRecord2(config.notify) ? config.notify : {};
1304
+ delete config.notify;
1305
+ const { data, error } = await db().from("cms_forms").upsert(
1306
+ {
1307
+ slug,
1308
+ title: typeof body.title === "string" ? body.title : "",
1309
+ config,
1310
+ notify,
1311
+ success_message: typeof body.successMessage === "string" ? body.successMessage : "",
1312
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1313
+ },
1314
+ { onConflict: "slug" }
1315
+ ).select("*").single();
1316
+ if (error) return errors.badRequest(error.message);
1317
+ await logActivity(auth.user, "form.update", slug);
1318
+ return json({ form: data });
1319
+ };
1320
+ const deleteForm = async (request, slug) => {
1321
+ const auth = await guard(request, "forms.delete");
1322
+ if (auth instanceof Response) return auth;
1323
+ const { data: doc } = await db().from("cms_forms").select("id").eq("slug", slug).maybeSingle();
1324
+ if (!doc) return errors.notFound();
1325
+ const { error } = await db().from("cms_forms").delete().eq("slug", slug);
1326
+ if (error) return errors.badRequest(error.message);
1327
+ await logActivity(auth.user, "form.delete", slug);
1328
+ return json({ success: true });
1329
+ };
1330
+ const listSubmissions = async (request) => {
1331
+ const auth = await guard(request, "submissions.read");
1332
+ if (auth instanceof Response) return auth;
1333
+ const url = new URL(request.url);
1334
+ const form = url.searchParams.get("form") || "";
1335
+ const beforeId = Number(url.searchParams.get("beforeId")) || 0;
1336
+ const unreadOnly = url.searchParams.get("unread") === "1";
1337
+ const limit = Math.min(Math.max(Number(url.searchParams.get("limit")) || 50, 1), 200);
1338
+ let query = db().from("cms_form_submissions").select("id, form_id, data, source, read_at, created_at").order("id", { ascending: false }).limit(limit + 1);
1339
+ if (form) query = query.eq("form_id", form);
1340
+ if (unreadOnly) query = query.is("read_at", null);
1341
+ if (beforeId > 0) query = query.lt("id", beforeId);
1342
+ const { data, error } = await query;
1343
+ if (error) return errors.badRequest(error.message);
1344
+ const rows = data || [];
1345
+ const hasMore = rows.length > limit;
1346
+ return json({ submissions: rows.slice(0, limit), hasMore });
1347
+ };
1348
+ const updateSubmission = async (request, id) => {
1349
+ const auth = await guard(request, "submissions.read");
1350
+ if (auth instanceof Response) return auth;
1351
+ const body = await readJson(request);
1352
+ if (!body || typeof body.read !== "boolean") return errors.badRequest("read boolean required.");
1353
+ const { error } = await db().from("cms_form_submissions").update({ read_at: body.read ? (/* @__PURE__ */ new Date()).toISOString() : null }).eq("id", Number(id));
1354
+ if (error) return errors.badRequest(error.message);
1355
+ return json({ success: true });
1356
+ };
1357
+ const deleteSubmission = async (request, id) => {
1358
+ const auth = await guard(request, "submissions.manage");
1359
+ if (auth instanceof Response) return auth;
1360
+ const { error } = await db().from("cms_form_submissions").delete().eq("id", Number(id));
1361
+ if (error) return errors.badRequest(error.message);
1362
+ return json({ success: true });
1363
+ };
1364
+ const csvEscape = (value) => {
1365
+ let text = Array.isArray(value) ? value.join("; ") : typeof value === "string" ? value : value === null || value === void 0 ? "" : JSON.stringify(value);
1366
+ if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
1367
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
1368
+ };
1369
+ const exportSubmissions = async (request) => {
1370
+ const auth = await guard(request, "submissions.read");
1371
+ if (auth instanceof Response) return auth;
1372
+ const url = new URL(request.url);
1373
+ const form = url.searchParams.get("form") || "";
1374
+ let query = db().from("cms_form_submissions").select("id, form_id, data, created_at").order("created_at", { ascending: false }).limit(5e3);
1375
+ if (form) query = query.eq("form_id", form);
1376
+ const { data, error } = await query;
1377
+ if (error) return errors.badRequest(error.message);
1378
+ const rows = data || [];
1379
+ const keys = [];
1380
+ for (const row of rows) {
1381
+ for (const key of Object.keys(row.data || {})) {
1382
+ if (!keys.includes(key)) keys.push(key);
1383
+ }
1384
+ }
1385
+ const header = ["submitted_at", ...keys];
1386
+ const lines = [header.map(csvEscape).join(",")];
1387
+ for (const row of rows) {
1388
+ const record = row.data || {};
1389
+ lines.push(
1390
+ [row.created_at, ...keys.map((key) => record[key])].map(csvEscape).join(",")
1391
+ );
1392
+ }
1393
+ return new Response(lines.join("\n"), {
1394
+ status: 200,
1395
+ headers: {
1396
+ "content-type": "text/csv; charset=utf-8",
1397
+ "content-disposition": `attachment; filename="submissions-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv"`
1398
+ }
1399
+ });
1400
+ };
1401
+ const getFormConfig = async (slug) => {
1402
+ const { data, error } = await db().from("cms_forms").select("slug, title, config, success_message").eq("slug", slug).maybeSingle();
1403
+ if (error) return errors.badRequest(error.message);
1404
+ if (!data) return errors.notFound();
1405
+ const config = { ...data.config || {} };
1406
+ delete config.notify;
1407
+ return json({
1408
+ slug: data.slug,
1409
+ title: data.title,
1410
+ config,
1411
+ successMessage: data.success_message
1412
+ });
1413
+ };
1414
+ const verifyTurnstile = async (token, ip) => {
1415
+ if (!options.turnstileSecret) return true;
1416
+ if (!token) return false;
1417
+ try {
1418
+ const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
1419
+ method: "POST",
1420
+ headers: { "content-type": "application/json" },
1421
+ body: JSON.stringify({ secret: options.turnstileSecret, response: token, remoteip: ip })
1422
+ });
1423
+ const body = await response.json();
1424
+ return body.success === true;
1425
+ } catch {
1426
+ return false;
1427
+ }
1428
+ };
1429
+ const submitForm = async (request, slug) => {
1430
+ if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
1431
+ const now = Date.now();
1432
+ const ip = clientKey(request);
1433
+ const key = hashedClientKey(ip);
1434
+ if (rateLimit && await rateLimit.isLimited(key, now)) {
1435
+ return json({ error: "Too many requests. Please try again shortly." }, 429);
1436
+ }
1437
+ const body = await readJsonLimited(request, {
1438
+ maxBytes: MAX_PUBLIC_BODY_BYTES,
1439
+ maxDepth: MAX_PUBLIC_BODY_DEPTH
1440
+ });
1441
+ if (!body) return errors.badRequest("Invalid submission body.");
1442
+ const data = isRecord2(body.data) ? body.data : body;
1443
+ if (!await verifyTurnstile(String(data._turnstileToken || ""), ip)) {
1444
+ return errors.badRequest("Verification failed. Please try again.");
1445
+ }
1446
+ const { data: form, error: formError } = await db().from("cms_forms").select("id, slug, title, config, notify, success_message").eq("slug", slug).maybeSingle();
1447
+ if (formError || !form) return errors.notFound();
1448
+ const result = processSubmission({
1449
+ config: form.config,
1450
+ data,
1451
+ now,
1452
+ knownGoodEmailDomains
1453
+ });
1454
+ if (result.outcome === "spam") return json({ success: true });
1455
+ if (result.outcome === "invalid") {
1456
+ return json({ error: "Validation failed.", fieldErrors: result.fieldErrors }, 422);
1457
+ }
1458
+ const { data: created, error } = await db().from("cms_form_submissions").insert({
1459
+ form_id: form.id,
1460
+ data: result.normalizedData,
1461
+ client_key: key,
1462
+ // Same daily hash the analytics events use — links this lead to the
1463
+ // journey that produced it without identifying anyone.
1464
+ session_key: requestSessionKey(request)
1465
+ }).select("id").single();
1466
+ if (error) return errors.badRequest(error.message);
1467
+ if (sendEmail) {
1468
+ await notifySubmission({
1469
+ sendEmail,
1470
+ formTitle: String(form.title || form.slug),
1471
+ // Notify settings live in their own column; merge for legacy configs.
1472
+ config: {
1473
+ ...form.config || {},
1474
+ ...isRecord2(form.notify) && Object.keys(form.notify).length > 0 ? { notify: form.notify } : {}
1475
+ },
1476
+ successMessage: String(form.success_message || ""),
1477
+ data: result.normalizedData,
1478
+ siteName: options.siteName
1479
+ });
1480
+ }
1481
+ return json({ success: true, id: created.id });
1482
+ };
1483
+ const listRedirects = async (request) => {
1484
+ const auth = await guard(request, "redirects.manage");
1485
+ if (auth instanceof Response) return auth;
1486
+ const { data, error } = await db().from("cms_redirects").select("*").order("from_path");
1487
+ if (error) return errors.badRequest(error.message);
1488
+ return json({ redirects: data });
1489
+ };
1490
+ const createRedirect = async (request) => {
1491
+ const auth = await guard(request, "redirects.manage");
1492
+ if (auth instanceof Response) return auth;
1493
+ const body = await readJson(request);
1494
+ const fromPath = typeof body?.fromPath === "string" ? body.fromPath.trim() : "";
1495
+ const toPath = typeof body?.toPath === "string" ? body.toPath.trim() : "";
1496
+ const permanent = body?.permanent !== false;
1497
+ if (!fromPath.startsWith("/")) return errors.badRequest("From path must start with /.");
1498
+ if (!toPath.startsWith("/") && !/^https?:\/\//.test(toPath)) {
1499
+ return errors.badRequest("To path must start with / or be a full URL.");
1500
+ }
1501
+ if (fromPath === toPath) return errors.badRequest("A redirect cannot point at itself.");
1502
+ const { data, error } = await db().from("cms_redirects").upsert({ from_path: fromPath, to_path: toPath, permanent }, { onConflict: "from_path" }).select("*").single();
1503
+ if (error) return errors.badRequest(error.message);
1504
+ await logActivity(auth.user, "redirect.save", `${fromPath} \u2192 ${toPath}`);
1505
+ return json({ redirect: data }, 201);
1506
+ };
1507
+ const deleteRedirect = async (request, id) => {
1508
+ const auth = await guard(request, "redirects.manage");
1509
+ if (auth instanceof Response) return auth;
1510
+ const { error } = await db().from("cms_redirects").delete().eq("id", id);
1511
+ if (error) return errors.badRequest(error.message);
1512
+ return json({ success: true });
1513
+ };
1514
+ const ingestEvents = async (request) => {
1515
+ if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
1516
+ const userAgent = request.headers.get("user-agent") || "";
1517
+ if (isBotRequest(userAgent)) return json({ success: true });
1518
+ const key = hashedClientKey(clientKey(request));
1519
+ if (await eventsRateLimit.isLimited(key, Date.now())) return json({ success: true });
1520
+ const body = await readJsonLimited(request, { maxBytes: 128 * 1024, maxDepth: 8 });
1521
+ if (!body) return json({ success: true });
1522
+ const { rows } = parseEventBatch(body, {
1523
+ sessionKey: requestSessionKey(request),
1524
+ visitorKey: visitorKeyFor(
1525
+ body?.visitorId,
1526
+ previewSecret() || "orion-analytics"
1527
+ ),
1528
+ device: deviceFrom(userAgent),
1529
+ ...geoFrom(request)
1530
+ });
1531
+ if (rows.length > 0) {
1532
+ try {
1533
+ await db().from("cms_events").insert(rows);
1534
+ } catch {
1535
+ }
1536
+ }
1537
+ return json({ success: true });
1538
+ };
1539
+ const fetchEvents = async (fromIso, toIso) => {
1540
+ const all = [];
1541
+ let cursor = 0;
1542
+ for (let page = 0; page < 60; page += 1) {
1543
+ 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);
1544
+ if (error || !data || data.length === 0) break;
1545
+ all.push(...data);
1546
+ cursor = Number(data[data.length - 1].id);
1547
+ if (data.length < 1e3) break;
1548
+ }
1549
+ return all;
1550
+ };
1551
+ const getAnalytics = async (request) => {
1552
+ const auth = await guard(request, "analytics.read");
1553
+ if (auth instanceof Response) return auth;
1554
+ const url = new URL(request.url);
1555
+ const toMs = Date.parse(url.searchParams.get("to") || "") || Date.now();
1556
+ const defaultFrom = toMs - 30 * 864e5;
1557
+ const fromMs = Math.min(Date.parse(url.searchParams.get("from") || "") || defaultFrom, toMs);
1558
+ const windowMs = Math.max(toMs - fromMs, 864e5);
1559
+ const from = new Date(fromMs).toISOString();
1560
+ const to = new Date(toMs).toISOString();
1561
+ const previousFrom = new Date(fromMs - windowMs).toISOString();
1562
+ const [events, previousEvents] = await Promise.all([
1563
+ fetchEvents(from, to),
1564
+ fetchEvents(previousFrom, from)
1565
+ ]);
1566
+ return json(aggregateAnalytics(events, previousEvents, { from, to }));
1567
+ };
1568
+ const pruneEvents = async () => {
1569
+ const cutoff = new Date(Date.now() - analyticsRetentionDays * 864e5).toISOString();
1570
+ try {
1571
+ await db().from("cms_events").delete().lt("created_at", cutoff);
1572
+ } catch {
1573
+ }
1574
+ };
1575
+ const publishDuePages = async () => {
1576
+ const { data, error } = await db().rpc("cms_publish_due_pages");
1577
+ if (error) throw new Error(error.message);
1578
+ const published = Array.isArray(data) ? data.map((row) => typeof row === "string" ? row : String(row?.cms_publish_due_pages ?? "")).filter(Boolean) : [];
1579
+ if (published.length > 0) await revalidateContent();
1580
+ return published;
1581
+ };
1582
+ const cronPublishDue = async (request) => {
1583
+ const bearer = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
1584
+ let authorized = tokenEquals(bearer, syncToken || "") || tokenEquals(bearer, process.env.CRON_SECRET || "");
1585
+ if (!authorized) {
1586
+ const user = await resolveUser(request, db());
1587
+ authorized = Boolean(user && can(user, "pages.publish"));
1588
+ }
1589
+ if (!authorized) return errors.forbidden();
1590
+ const published = await publishDuePages();
1591
+ await pruneEvents();
1592
+ return json({ success: true, published });
1593
+ };
1594
+ const dashboard = async (request) => {
1595
+ const auth = await guard(request, "pages.read");
1596
+ if (auth instanceof Response) return auth;
1597
+ await publishDuePages().catch(() => void 0);
1598
+ const { data: pages } = await db().from("cms_pages").select(
1599
+ "id, title, path, status, updated_at, published_at, publish_at, draft_layout, published_layout"
1600
+ );
1601
+ const allPages = pages || [];
1602
+ const pendingDrafts = allPages.filter((page) => hasDraftChanges(page));
1603
+ const recentPages = [...allPages].sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))).slice(0, 5).map(({ draft_layout, published_layout, ...summary }) => {
1604
+ void draft_layout;
1605
+ void published_layout;
1606
+ return summary;
1607
+ });
1608
+ const lastPublishedAt = allPages.map((page) => page.published_at).filter(Boolean).sort().pop();
1609
+ const { data: submissions } = await db().from("cms_form_submissions").select("id, form_id, data, read_at, created_at").order("created_at", { ascending: false }).limit(200);
1610
+ const allSubmissions = submissions || [];
1611
+ const unread = allSubmissions.filter((submission) => !submission.read_at);
1612
+ let activity = [];
1613
+ if (can(auth.user, "activity.read")) {
1614
+ const { data: events } = await db().from("cms_activity").select("id, actor_name, action, subject, created_at").order("created_at", { ascending: false }).limit(12);
1615
+ activity = events || [];
1616
+ }
1617
+ return json({
1618
+ pages: {
1619
+ total: allPages.length,
1620
+ pendingDrafts: pendingDrafts.map((page) => ({
1621
+ id: page.id,
1622
+ title: page.title,
1623
+ path: page.path,
1624
+ status: page.status,
1625
+ publish_at: page.publish_at
1626
+ })),
1627
+ recent: recentPages,
1628
+ lastPublishedAt: lastPublishedAt ?? null
1629
+ },
1630
+ submissions: {
1631
+ total: allSubmissions.length,
1632
+ unread: unread.length,
1633
+ recent: allSubmissions.slice(0, 5)
1634
+ },
1635
+ activity
1636
+ });
1637
+ };
1638
+ const listActivity = async (request) => {
1639
+ const auth = await guard(request, "activity.read");
1640
+ if (auth instanceof Response) return auth;
1641
+ const { data, error } = await db().from("cms_activity").select("id, actor, actor_name, action, subject, created_at").order("created_at", { ascending: false }).limit(50);
1642
+ if (error) return errors.badRequest(error.message);
1643
+ return json({ activity: data });
1644
+ };
1645
+ const listUsers = async (request) => {
1646
+ const auth = await guard(request, "users.manage");
1647
+ if (auth instanceof Response) return auth;
1648
+ const { data: authData, error: authError } = await db().auth.admin.listUsers({ perPage: 1e3 });
1649
+ if (authError) return errors.badRequest(authError.message);
1650
+ const { data: profiles, error: profileError } = await db().from("cms_profiles").select("user_id, role, name");
1651
+ if (profileError) return errors.badRequest(profileError.message);
1652
+ const roleById = new Map(
1653
+ (profiles || []).map((profile) => [profile.user_id, profile])
1654
+ );
1655
+ const users = authData.users.map((user) => {
1656
+ const profile = roleById.get(user.id);
1657
+ return {
1658
+ id: user.id,
1659
+ email: user.email ?? null,
1660
+ name: profile?.name || "",
1661
+ role: profile?.role ?? null,
1662
+ created_at: user.created_at ?? null,
1663
+ last_sign_in_at: user.last_sign_in_at ?? null
1664
+ };
1665
+ });
1666
+ return json({ users, assignableRoles: assignableRoles(auth.user.role) });
1667
+ };
1668
+ const createUser = async (request) => {
1669
+ const auth = await guard(request, "users.manage");
1670
+ if (auth instanceof Response) return auth;
1671
+ const body = await readJson(request);
1672
+ const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
1673
+ const password = typeof body?.password === "string" ? body.password : "";
1674
+ const name = typeof body?.name === "string" ? body.name.trim() : "";
1675
+ const role = body?.role;
1676
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
1677
+ return errors.badRequest("A valid email is required.");
1678
+ }
1679
+ if (password.length < 8) return errors.badRequest("Password must be at least 8 characters.");
1680
+ if (!isCmsRole(role)) return errors.badRequest("A valid role is required.");
1681
+ if (!outranksOrEqual(auth.user.role, role)) {
1682
+ return errors.badRequest("You can only assign roles at or below your own.");
1683
+ }
1684
+ const { data: created, error: createError } = await db().auth.admin.createUser({
1685
+ email,
1686
+ password,
1687
+ email_confirm: true
1688
+ });
1689
+ if (createError) return errors.badRequest(createError.message);
1690
+ const userId = created.user.id;
1691
+ const { error: profileError } = await db().from("cms_profiles").upsert({ user_id: userId, role, name }, { onConflict: "user_id" });
1692
+ if (profileError) {
1693
+ await db().auth.admin.deleteUser(userId).catch(() => void 0);
1694
+ return errors.badRequest(profileError.message);
1695
+ }
1696
+ await logActivity(auth.user, "user.create", email);
1697
+ return json({ user: { id: userId, email, name, role } }, 201);
1698
+ };
1699
+ const targetRole = async (userId) => {
1700
+ const { data } = await db().from("cms_profiles").select("role").eq("user_id", userId).maybeSingle();
1701
+ return data?.role ?? null;
1702
+ };
1703
+ const updateUser = async (request, userId) => {
1704
+ const auth = await guard(request, "users.manage");
1705
+ if (auth instanceof Response) return auth;
1706
+ const body = await readJson(request);
1707
+ if (!body) return errors.badRequest("Invalid body.");
1708
+ const current = await targetRole(userId);
1709
+ if (current && !outranksOrEqual(auth.user.role, current)) return errors.forbidden();
1710
+ if (body.role !== void 0) {
1711
+ if (!isCmsRole(body.role)) return errors.badRequest("Invalid role.");
1712
+ if (userId === auth.user.id && body.role !== auth.user.role) {
1713
+ return errors.badRequest("You can't change your own role.");
1714
+ }
1715
+ if (!outranksOrEqual(auth.user.role, body.role)) {
1716
+ return errors.badRequest("You can only assign roles at or below your own.");
1717
+ }
1718
+ }
1719
+ if (typeof body.password === "string") {
1720
+ if (body.password.length < 8) return errors.badRequest("Password must be at least 8 characters.");
1721
+ const { error: passwordError } = await db().auth.admin.updateUserById(userId, {
1722
+ password: body.password
1723
+ });
1724
+ if (passwordError) return errors.badRequest(passwordError.message);
1725
+ }
1726
+ if (body.role !== void 0 || typeof body.name === "string") {
1727
+ const patch = { user_id: userId };
1728
+ if (body.role !== void 0) patch.role = body.role;
1729
+ if (typeof body.name === "string") patch.name = body.name.trim();
1730
+ const { error: profileError } = await db().from("cms_profiles").upsert(patch, { onConflict: "user_id" });
1731
+ if (profileError) return errors.badRequest(profileError.message);
1732
+ }
1733
+ await logActivity(auth.user, "user.update", userId);
1734
+ return json({ success: true });
1735
+ };
1736
+ const deleteUser = async (request, userId) => {
1737
+ const auth = await guard(request, "users.manage");
1738
+ if (auth instanceof Response) return auth;
1739
+ if (userId === auth.user.id) return errors.badRequest("You can't remove your own account.");
1740
+ const current = await targetRole(userId);
1741
+ if (current && !outranksOrEqual(auth.user.role, current)) return errors.forbidden();
1742
+ const { error: authError } = await db().auth.admin.deleteUser(userId);
1743
+ if (authError) return errors.badRequest(authError.message);
1744
+ const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", userId);
1745
+ if (profileError) return errors.badRequest(profileError.message);
1746
+ await logActivity(auth.user, "user.delete", userId);
1747
+ return json({ success: true });
1748
+ };
1749
+ const runSync = async (request) => {
1750
+ const bearer = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
1751
+ let authorized = tokenEquals(bearer, syncToken || "");
1752
+ if (!authorized) {
1753
+ const user = await resolveUser(request, db());
1754
+ authorized = Boolean(user && can(user, "sync.run"));
1755
+ }
1756
+ if (!authorized) return errors.forbidden();
1757
+ const body = await readJson(request);
1758
+ if (!body) return errors.badRequest("Invalid body.");
1759
+ const result = await runContentSync(db(), registry, body);
1760
+ await revalidateContent();
1761
+ return json({ success: true, ...result });
1762
+ };
1763
+ const dispatch = async (request, context) => {
1764
+ const params = await context.params;
1765
+ const segments = params?.path || [];
1766
+ const method = request.method.toUpperCase();
1767
+ const [head, second, third] = segments;
1768
+ if (head === "pages") {
1769
+ if (!second) {
1770
+ if (method === "GET") return listPages(request);
1771
+ if (method === "POST") return createPage(request);
1772
+ } else if (!third) {
1773
+ if (method === "GET") return getPage(request, second);
1774
+ if (method === "PATCH") return savePageDraft(request, second);
1775
+ if (method === "DELETE") return deletePage(request, second);
1776
+ } else if (third === "publish" && method === "POST") {
1777
+ return publishPage(request, second);
1778
+ } else if (third === "unpublish" && method === "POST") {
1779
+ return unpublishPage(request, second);
1780
+ } else if (third === "duplicate" && method === "POST") {
1781
+ return duplicatePage(request, second);
1782
+ } else if (third === "preview" && method === "POST") {
1783
+ return previewToken(request, second);
1784
+ } else if (third === "versions" && method === "GET") {
1785
+ return listVersions(request, second);
1786
+ }
1787
+ }
1788
+ if (head === "preview" && !second && method === "GET") return previewPage(request);
1789
+ if (head === "versions" && second) {
1790
+ if (third === "restore" && method === "POST") return restoreVersion(request, second);
1791
+ if (!third && method === "GET") return getVersion(request, second);
1792
+ }
1793
+ if (head === "globals" && second) {
1794
+ if (third === "versions" && method === "GET") return listGlobalVersions(request, second);
1795
+ if (!third && method === "GET") return getGlobal(request, second);
1796
+ if (!third && method === "PATCH") return updateGlobal(request, second);
1797
+ }
1798
+ if (head === "global-versions" && second && third === "restore" && method === "POST") {
1799
+ return restoreGlobalVersion(request, second);
1800
+ }
1801
+ if (head === "media") {
1802
+ if (!second) {
1803
+ if (method === "GET") return listMedia(request);
1804
+ if (method === "POST") return uploadMedia(request);
1805
+ } else if (!third) {
1806
+ if (method === "PATCH") return updateMedia(request, second);
1807
+ if (method === "DELETE") return deleteMedia(request, second);
1808
+ } else if (third === "usage" && method === "GET") {
1809
+ return mediaUsage(request, second);
1810
+ } else if (third === "replace" && method === "POST") {
1811
+ return replaceMedia(request, second);
1812
+ }
1813
+ }
1814
+ if (head === "forms") {
1815
+ if (!second) {
1816
+ if (method === "GET") return listForms(request);
1817
+ if (method === "POST") return createForm(request);
1818
+ } else {
1819
+ if (third === "submit" && method === "POST") return submitForm(request, second);
1820
+ if (third === "config" && method === "GET") return getFormConfig(second);
1821
+ if (!third && method === "GET") return getForm(request, second);
1822
+ if (!third && method === "PATCH") return updateForm(request, second);
1823
+ if (!third && method === "DELETE") return deleteForm(request, second);
1824
+ }
1825
+ }
1826
+ if (head === "submissions") {
1827
+ if (!second) {
1828
+ if (method === "GET") return listSubmissions(request);
1829
+ } else if (second === "export" && method === "GET") {
1830
+ return exportSubmissions(request);
1831
+ } else if (!third) {
1832
+ if (method === "PATCH") return updateSubmission(request, second);
1833
+ if (method === "DELETE") return deleteSubmission(request, second);
1834
+ }
1835
+ }
1836
+ if (head === "redirects") {
1837
+ if (!second) {
1838
+ if (method === "GET") return listRedirects(request);
1839
+ if (method === "POST") return createRedirect(request);
1840
+ } else if (method === "DELETE") {
1841
+ return deleteRedirect(request, second);
1842
+ }
1843
+ }
1844
+ if (head === "cron" && second === "publish-due" && (method === "POST" || method === "GET")) {
1845
+ return cronPublishDue(request);
1846
+ }
1847
+ if (head === "events" && !second && method === "POST") return ingestEvents(request);
1848
+ if (head === "analytics" && !second && method === "GET") return getAnalytics(request);
1849
+ if (head === "dashboard" && method === "GET") return dashboard(request);
1850
+ if (head === "activity" && method === "GET") return listActivity(request);
1851
+ if (head === "users") {
1852
+ if (!second) {
1853
+ if (method === "GET") return listUsers(request);
1854
+ if (method === "POST") return createUser(request);
1855
+ } else if (!third) {
1856
+ if (method === "PATCH") return updateUser(request, second);
1857
+ if (method === "DELETE") return deleteUser(request, second);
1858
+ }
1859
+ }
1860
+ if (head === "sync" && method === "POST") return runSync(request);
1861
+ if (head === "me" && method === "GET") {
1862
+ const user = await resolveUser(request, db());
1863
+ if (!user) return errors.unauthorized();
1864
+ return json({ user });
1865
+ }
1866
+ return errors.notFound();
1867
+ };
1868
+ return {
1869
+ GET: dispatch,
1870
+ POST: dispatch,
1871
+ PATCH: dispatch,
1872
+ DELETE: dispatch
1873
+ };
1874
+ }
1875
+
1876
+ // src/server/memory.ts
1877
+ var MEMORY_DEV_TOKEN = "orion-dev-token";
1878
+ var DEV_USER_ID = "00000000-0000-4000-8000-000000000001";
1879
+ var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
1880
+ var newId = () => "xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx".replace(
1881
+ /x/g,
1882
+ () => Math.floor(Math.random() * 16).toString(16)
1883
+ );
1884
+ var MemoryQuery = class _MemoryQuery {
1885
+ rows;
1886
+ table;
1887
+ store;
1888
+ filters = [];
1889
+ orderKey = null;
1890
+ orderAsc = true;
1891
+ limitCount = null;
1892
+ pendingWrite = null;
1893
+ constructor(store, table) {
1894
+ this.store = store;
1895
+ this.table = table;
1896
+ this.rows = store.table(table);
1897
+ }
1898
+ select(_columns, _options) {
1899
+ return this;
1900
+ }
1901
+ eq(column, value) {
1902
+ this.filters.push((row) => row[column] === value);
1903
+ return this;
1904
+ }
1905
+ neq(column, value) {
1906
+ this.filters.push((row) => row[column] !== value);
1907
+ return this;
1908
+ }
1909
+ static compare(left, right) {
1910
+ if (typeof left === "number" && typeof right === "number") return left - right;
1911
+ return String(left ?? "") < String(right ?? "") ? -1 : String(left ?? "") > String(right ?? "") ? 1 : 0;
1912
+ }
1913
+ lt(column, value) {
1914
+ this.filters.push((row) => _MemoryQuery.compare(row[column], value) < 0);
1915
+ return this;
1916
+ }
1917
+ lte(column, value) {
1918
+ this.filters.push((row) => _MemoryQuery.compare(row[column], value) <= 0);
1919
+ return this;
1920
+ }
1921
+ gt(column, value) {
1922
+ this.filters.push((row) => _MemoryQuery.compare(row[column], value) > 0);
1923
+ return this;
1924
+ }
1925
+ gte(column, value) {
1926
+ this.filters.push((row) => _MemoryQuery.compare(row[column], value) >= 0);
1927
+ return this;
1928
+ }
1929
+ is(column, value) {
1930
+ this.filters.push((row) => value === null ? row[column] == null : row[column] === value);
1931
+ return this;
1932
+ }
1933
+ order(column, options) {
1934
+ this.orderKey = column;
1935
+ this.orderAsc = options?.ascending !== false;
1936
+ return this;
1937
+ }
1938
+ limit(count) {
1939
+ this.limitCount = count;
1940
+ return this;
1941
+ }
1942
+ insert(values) {
1943
+ this.pendingWrite = { kind: "insert", values };
1944
+ return this;
1945
+ }
1946
+ upsert(values, options) {
1947
+ this.pendingWrite = { kind: "upsert", values, conflictKey: options?.onConflict || "key" };
1948
+ return this;
1949
+ }
1950
+ update(values) {
1951
+ this.pendingWrite = { kind: "update", values };
1952
+ return this;
1953
+ }
1954
+ delete() {
1955
+ this.pendingWrite = { kind: "delete" };
1956
+ return this;
1957
+ }
1958
+ applyWrite() {
1959
+ const write = this.pendingWrite;
1960
+ if (!write) return { data: null, error: null };
1961
+ if (write.kind === "delete") {
1962
+ const remaining = this.rows.filter((row) => !this.filters.every((filter) => filter(row)));
1963
+ this.store.setTable(this.table, remaining);
1964
+ return { data: null, error: null };
1965
+ }
1966
+ if (write.kind === "update") {
1967
+ const matched = this.rows.filter((row) => this.filters.every((filter) => filter(row)));
1968
+ for (const row of matched) {
1969
+ Object.assign(row, write.values);
1970
+ }
1971
+ return { data: matched[0] ?? null, error: null };
1972
+ }
1973
+ if (write.kind === "insert" && Array.isArray(write.values)) {
1974
+ let first = null;
1975
+ for (const entry of write.values) {
1976
+ this.pendingWrite = { kind: "insert", values: entry };
1977
+ const { data, error } = this.applyWrite();
1978
+ if (error) return { data: null, error };
1979
+ first = first ?? data;
1980
+ }
1981
+ return { data: first, error: null };
1982
+ }
1983
+ const values = { ...write.values };
1984
+ if (write.kind === "upsert") {
1985
+ const conflictKey = write.conflictKey || "key";
1986
+ const existing = this.rows.find((row) => row[conflictKey] === values[conflictKey]);
1987
+ if (existing) {
1988
+ Object.assign(existing, values);
1989
+ return { data: existing, error: null };
1990
+ }
1991
+ }
1992
+ if (this.table === "cms_pages") {
1993
+ if (this.rows.some((row) => row.slug === values.slug)) {
1994
+ return { data: null, error: { message: 'duplicate key value violates unique constraint "cms_pages_slug_key"' } };
1995
+ }
1996
+ values.draft_layout = values.draft_layout ?? [];
1997
+ values.published_layout = values.published_layout ?? null;
1998
+ values.status = values.status ?? "draft";
1999
+ values.builder_owned = values.builder_owned ?? false;
2000
+ values.seo = values.seo ?? {};
2001
+ }
2002
+ const serialTables = /* @__PURE__ */ new Set([
2003
+ "cms_form_submissions",
2004
+ "cms_page_versions",
2005
+ "cms_global_versions",
2006
+ "cms_activity",
2007
+ "cms_events"
2008
+ ]);
2009
+ values.id = values.id ?? (serialTables.has(this.table) ? this.store.nextSerial() : newId());
2010
+ values.created_at = values.created_at ?? nowIso();
2011
+ values.updated_at = values.updated_at ?? nowIso();
2012
+ this.rows.push(values);
2013
+ return { data: values, error: null };
2014
+ }
2015
+ resolve() {
2016
+ let result = this.rows.filter((row) => this.filters.every((filter) => filter(row)));
2017
+ if (this.orderKey) {
2018
+ const key = this.orderKey;
2019
+ result = [...result].sort((a, b) => {
2020
+ const order = _MemoryQuery.compare(a[key], b[key]);
2021
+ return this.orderAsc ? order : -order;
2022
+ });
2023
+ }
2024
+ if (this.limitCount !== null) result = result.slice(0, this.limitCount);
2025
+ return { data: result, error: null };
2026
+ }
2027
+ async maybeSingle() {
2028
+ if (this.pendingWrite) {
2029
+ const { data: data2, error } = this.applyWrite();
2030
+ return { data: data2, error };
2031
+ }
2032
+ const { data } = this.resolve();
2033
+ return { data: data[0] ?? null, error: null };
2034
+ }
2035
+ async single() {
2036
+ return this.maybeSingle();
2037
+ }
2038
+ // Thenable: `await query` resolves list reads and writes without .select()
2039
+ then(onFulfilled) {
2040
+ if (this.pendingWrite) {
2041
+ const { data, error } = this.applyWrite();
2042
+ return Promise.resolve(onFulfilled({ data, error }));
2043
+ }
2044
+ return Promise.resolve(onFulfilled(this.resolve()));
2045
+ }
2046
+ };
2047
+ var MemoryStore = class {
2048
+ tables = /* @__PURE__ */ new Map();
2049
+ serial = 0;
2050
+ table(name) {
2051
+ if (!this.tables.has(name)) this.tables.set(name, []);
2052
+ return this.tables.get(name);
2053
+ }
2054
+ setTable(name, rows) {
2055
+ this.tables.set(name, rows);
2056
+ }
2057
+ nextSerial() {
2058
+ this.serial += 1;
2059
+ return this.serial;
2060
+ }
2061
+ };
2062
+ function runRpc(store, fn, args = {}) {
2063
+ const pages = store.table("cms_pages");
2064
+ const versions = store.table("cms_page_versions");
2065
+ const snapshot = (page, kind, layout) => {
2066
+ const latest = [...versions].filter((v) => v.page_id === page.id).pop();
2067
+ if ((kind === "draft" || kind === "sync") && latest && JSON.stringify(latest.title) === JSON.stringify(page.title ?? "") && JSON.stringify(latest.seo) === JSON.stringify(page.seo ?? {}) && JSON.stringify(latest.layout) === JSON.stringify(layout ?? [])) {
2068
+ return;
2069
+ }
2070
+ versions.push({
2071
+ id: store.nextSerial(),
2072
+ page_id: page.id,
2073
+ kind,
2074
+ title: page.title ?? "",
2075
+ seo: page.seo ?? {},
2076
+ layout: layout ?? [],
2077
+ created_by: args.p_actor ?? null,
2078
+ created_at: nowIso()
2079
+ });
2080
+ };
2081
+ switch (fn) {
2082
+ case "cms_save_page_draft": {
2083
+ const page = pages.find((row) => row.id === args.p_page_id);
2084
+ if (!page) return { data: null, error: { message: `page ${args.p_page_id} not found` } };
2085
+ if (args.p_title != null) page.title = args.p_title;
2086
+ if (args.p_seo != null) page.seo = args.p_seo;
2087
+ if (args.p_layout != null) page.draft_layout = args.p_layout;
2088
+ page.builder_owned = Boolean(page.builder_owned) || args.p_mark_builder_owned !== false;
2089
+ page.updated_at = nowIso();
2090
+ snapshot(page, "draft", page.draft_layout);
2091
+ return { data: page, error: null };
2092
+ }
2093
+ case "cms_publish_page": {
2094
+ const page = pages.find((row) => row.id === args.p_page_id);
2095
+ if (!page) return { data: null, error: { message: `page ${args.p_page_id} not found` } };
2096
+ page.published_layout = page.draft_layout;
2097
+ page.status = "published";
2098
+ page.published_at = nowIso();
2099
+ page.updated_at = nowIso();
2100
+ snapshot(page, "publish", page.published_layout);
2101
+ return { data: page, error: null };
2102
+ }
2103
+ case "cms_update_global": {
2104
+ const globals = store.table("cms_globals");
2105
+ const globalVersions = store.table("cms_global_versions");
2106
+ const key = String(args.p_key);
2107
+ let row = globals.find((r) => r.key === key);
2108
+ if (!row) {
2109
+ row = { key, label: "", data: args.p_data ?? {}, updated_at: nowIso() };
2110
+ globals.push(row);
2111
+ } else {
2112
+ row.data = args.p_data ?? {};
2113
+ row.updated_at = nowIso();
2114
+ }
2115
+ globalVersions.push({
2116
+ id: store.nextSerial(),
2117
+ key,
2118
+ data: args.p_data ?? {},
2119
+ created_by: args.p_actor ?? null,
2120
+ created_at: nowIso()
2121
+ });
2122
+ return { data: row, error: null };
2123
+ }
2124
+ case "cms_rate_limit_consume": {
2125
+ const limits = store.table("cms_rate_limits");
2126
+ const key = String(args.p_key);
2127
+ const max = Number(args.p_max);
2128
+ const windowMs = Number(args.p_window_seconds) * 1e3;
2129
+ const nowMs = Date.now();
2130
+ let row = limits.find((r) => r.key === key);
2131
+ if (!row) {
2132
+ row = { key, window_start: nowMs, count: 0 };
2133
+ limits.push(row);
2134
+ }
2135
+ if (nowMs - Number(row.window_start) >= windowMs) {
2136
+ row.window_start = nowMs;
2137
+ row.count = 1;
2138
+ } else {
2139
+ row.count = Number(row.count) + 1;
2140
+ }
2141
+ return { data: Number(row.count) <= max, error: null };
2142
+ }
2143
+ case "cms_publish_due_pages": {
2144
+ const nowMs = Date.now();
2145
+ const published = [];
2146
+ for (const page of pages) {
2147
+ const at = typeof page.publish_at === "string" ? Date.parse(page.publish_at) : NaN;
2148
+ if (Number.isNaN(at) || at > nowMs) continue;
2149
+ page.published_layout = page.draft_layout;
2150
+ page.status = "published";
2151
+ page.published_at = nowIso();
2152
+ page.publish_at = null;
2153
+ page.updated_at = nowIso();
2154
+ snapshot(page, "publish", page.published_layout);
2155
+ published.push(String(page.path));
2156
+ }
2157
+ return { data: published, error: null };
2158
+ }
2159
+ case "cms_restore_page_version": {
2160
+ const version = versions.find((row) => row.id === Number(args.p_version_id));
2161
+ if (!version) return { data: null, error: { message: `version ${args.p_version_id} not found` } };
2162
+ const page = pages.find((row) => row.id === version.page_id);
2163
+ if (!page) return { data: null, error: { message: "page not found" } };
2164
+ page.draft_layout = version.layout;
2165
+ page.title = version.title;
2166
+ page.seo = version.seo;
2167
+ page.builder_owned = true;
2168
+ page.updated_at = nowIso();
2169
+ snapshot(page, "restore", version.layout);
2170
+ return { data: page, error: null };
2171
+ }
2172
+ case "cms_sync_page": {
2173
+ let page = pages.find((row) => row.slug === args.p_slug);
2174
+ if (!page) {
2175
+ page = {
2176
+ id: newId(),
2177
+ slug: args.p_slug,
2178
+ path: args.p_path,
2179
+ title: args.p_title,
2180
+ seo: args.p_seo ?? {},
2181
+ draft_layout: args.p_layout ?? [],
2182
+ published_layout: args.p_layout ?? [],
2183
+ status: "published",
2184
+ builder_owned: false,
2185
+ created_at: nowIso(),
2186
+ updated_at: nowIso(),
2187
+ published_at: nowIso()
2188
+ };
2189
+ pages.push(page);
2190
+ } else if (!page.builder_owned) {
2191
+ page.path = args.p_path;
2192
+ page.title = args.p_title;
2193
+ page.seo = args.p_seo ?? {};
2194
+ page.draft_layout = args.p_layout ?? [];
2195
+ page.published_layout = args.p_layout ?? [];
2196
+ page.updated_at = nowIso();
2197
+ }
2198
+ snapshot(page, "sync", page.draft_layout);
2199
+ return { data: page, error: null };
2200
+ }
2201
+ default:
2202
+ return { data: null, error: { message: `unknown function ${fn}` } };
2203
+ }
2204
+ }
2205
+ function createMemoryCms() {
2206
+ const store = new MemoryStore();
2207
+ store.setTable("cms_profiles", [
2208
+ { user_id: DEV_USER_ID, role: "admin", name: "Dev Admin", created_at: nowIso() }
2209
+ ]);
2210
+ store.setTable("auth_users", [
2211
+ { id: DEV_USER_ID, email: "dev@local", created_at: nowIso(), last_sign_in_at: nowIso() }
2212
+ ]);
2213
+ const client = {
2214
+ from: (table) => new MemoryQuery(store, table),
2215
+ rpc: async (fn, args) => runRpc(store, fn, args),
2216
+ auth: {
2217
+ getUser: async (token) => token === MEMORY_DEV_TOKEN ? { data: { user: { id: DEV_USER_ID, email: "dev@local" } }, error: null } : { data: { user: null }, error: { message: "invalid token" } },
2218
+ admin: {
2219
+ listUsers: async () => ({ data: { users: [...store.table("auth_users")] }, error: null }),
2220
+ createUser: async (attributes) => {
2221
+ const users = store.table("auth_users");
2222
+ if (users.some((user2) => user2.email === attributes.email)) {
2223
+ return { data: { user: null }, error: { message: "A user with this email already exists." } };
2224
+ }
2225
+ const user = { id: newId(), email: attributes.email, created_at: nowIso(), last_sign_in_at: null };
2226
+ users.push(user);
2227
+ return { data: { user }, error: null };
2228
+ },
2229
+ updateUserById: async (id) => store.table("auth_users").some((user) => user.id === id) ? { data: {}, error: null } : { data: {}, error: { message: "User not found." } },
2230
+ deleteUser: async (id) => {
2231
+ store.setTable("auth_users", store.table("auth_users").filter((user) => user.id !== id));
2232
+ return { data: {}, error: null };
2233
+ }
2234
+ }
2235
+ },
2236
+ storage: {
2237
+ from: () => ({
2238
+ upload: async () => ({ error: null }),
2239
+ remove: async () => ({ error: null })
2240
+ })
2241
+ }
2242
+ };
2243
+ return { client, store, devToken: MEMORY_DEV_TOKEN };
2244
+ }
2245
+ function getMemoryCms() {
2246
+ const globalStore = globalThis;
2247
+ if (!globalStore.__orionMemoryCms) {
2248
+ globalStore.__orionMemoryCms = createMemoryCms();
2249
+ }
2250
+ return globalStore.__orionMemoryCms;
2251
+ }
2252
+ export {
2253
+ CONTENT_CACHE_TAG,
2254
+ MEMORY_DEV_TOKEN,
2255
+ PREVIEW_TOKEN_TTL_MS,
2256
+ aggregateAnalytics,
2257
+ can,
2258
+ createCmsRoutes,
2259
+ createDurableRateLimitStore,
2260
+ createMemoryCms,
2261
+ createPreviewToken,
2262
+ createResendSender,
2263
+ deviceFrom,
2264
+ formatSubmissionText,
2265
+ geoFrom,
2266
+ getMemoryCms,
2267
+ getPreviewPage,
2268
+ getServiceClient,
2269
+ isBotRequest,
2270
+ isStructuralChange,
2271
+ notifySubmission,
2272
+ parseEventBatch,
2273
+ readCmsEnv,
2274
+ resolveUser,
2275
+ runContentSync,
2276
+ sessionKeyFor,
2277
+ setServiceClientForTesting,
2278
+ verifyPreviewToken,
2279
+ visitorKeyFor
2280
+ };