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