@batterai/strapi-plugin-canvas 0.1.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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +16 -0
- package/README.md +131 -0
- package/dist/admin/appearance.d.ts +12 -0
- package/dist/admin/appearance.d.ts.map +1 -0
- package/dist/admin/appearance.js +44 -0
- package/dist/admin/appearance.js.map +1 -0
- package/dist/admin/block-packages.d.ts +4 -0
- package/dist/admin/block-packages.d.ts.map +1 -0
- package/dist/admin/block-packages.js +14 -0
- package/dist/admin/block-packages.js.map +1 -0
- package/dist/admin/client/content-model.d.ts +3 -0
- package/dist/admin/client/content-model.d.ts.map +1 -0
- package/dist/admin/client/content-model.js +76 -0
- package/dist/admin/client/content-model.js.map +1 -0
- package/dist/admin/client/host-services.d.ts +4 -0
- package/dist/admin/client/host-services.d.ts.map +1 -0
- package/dist/admin/client/host-services.js +141 -0
- package/dist/admin/client/host-services.js.map +1 -0
- package/dist/admin/index.d.ts +37 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +34 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/App.d.ts +4 -0
- package/dist/admin/pages/App.d.ts.map +1 -0
- package/dist/admin/pages/App.js +50 -0
- package/dist/admin/pages/App.js.map +1 -0
- package/dist/admin/translations.d.ts +4 -0
- package/dist/admin/translations.d.ts.map +1 -0
- package/dist/admin/translations.js +16 -0
- package/dist/admin/translations.js.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +18 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/server/bootstrap.d.ts +4 -0
- package/dist/server/bootstrap.d.ts.map +1 -0
- package/dist/server/bootstrap.js +37 -0
- package/dist/server/bootstrap.js.map +1 -0
- package/dist/server/config/index.d.ts +24 -0
- package/dist/server/config/index.d.ts.map +1 -0
- package/dist/server/config/index.js +33 -0
- package/dist/server/config/index.js.map +1 -0
- package/dist/server/content-types/canvas-page/schema.d.ts +45 -0
- package/dist/server/content-types/canvas-page/schema.d.ts.map +1 -0
- package/dist/server/content-types/canvas-page/schema.js +21 -0
- package/dist/server/content-types/canvas-page/schema.js.map +1 -0
- package/dist/server/content-types/canvas-site/schema.d.ts +41 -0
- package/dist/server/content-types/canvas-site/schema.d.ts.map +1 -0
- package/dist/server/content-types/canvas-site/schema.js +20 -0
- package/dist/server/content-types/canvas-site/schema.js.map +1 -0
- package/dist/server/content-types/index.d.ts +90 -0
- package/dist/server/content-types/index.d.ts.map +1 -0
- package/dist/server/content-types/index.js +7 -0
- package/dist/server/content-types/index.js.map +1 -0
- package/dist/server/controllers/canvas.d.ts +48 -0
- package/dist/server/controllers/canvas.d.ts.map +1 -0
- package/dist/server/controllers/canvas.js +409 -0
- package/dist/server/controllers/canvas.js.map +1 -0
- package/dist/server/controllers/index.d.ts +448 -0
- package/dist/server/controllers/index.d.ts.map +1 -0
- package/dist/server/controllers/index.js +5 -0
- package/dist/server/controllers/index.js.map +1 -0
- package/dist/server/index.d.ts +645 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +21 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/license.d.ts +13 -0
- package/dist/server/license.d.ts.map +1 -0
- package/dist/server/license.js +24 -0
- package/dist/server/license.js.map +1 -0
- package/dist/server/policies/is-admin-authenticated.d.ts +2 -0
- package/dist/server/policies/is-admin-authenticated.d.ts.map +1 -0
- package/dist/server/policies/is-admin-authenticated.js +4 -0
- package/dist/server/policies/is-admin-authenticated.js.map +1 -0
- package/dist/server/register.d.ts +2 -0
- package/dist/server/register.d.ts.map +1 -0
- package/dist/server/register.js +4 -0
- package/dist/server/register.js.map +1 -0
- package/dist/server/routes/admin.d.ts +18 -0
- package/dist/server/routes/admin.d.ts.map +1 -0
- package/dist/server/routes/admin.js +38 -0
- package/dist/server/routes/admin.js.map +1 -0
- package/dist/server/routes/index.d.ts +31 -0
- package/dist/server/routes/index.d.ts.map +1 -0
- package/dist/server/routes/index.js +7 -0
- package/dist/server/routes/index.js.map +1 -0
- package/dist/server/routes/public.d.ts +13 -0
- package/dist/server/routes/public.d.ts.map +1 -0
- package/dist/server/routes/public.js +7 -0
- package/dist/server/routes/public.js.map +1 -0
- package/dist/server/services/collaboration-service.d.ts +24 -0
- package/dist/server/services/collaboration-service.d.ts.map +1 -0
- package/dist/server/services/collaboration-service.js +164 -0
- package/dist/server/services/collaboration-service.js.map +1 -0
- package/dist/server/services/document-command-service.d.ts +34 -0
- package/dist/server/services/document-command-service.d.ts.map +1 -0
- package/dist/server/services/document-command-service.js +418 -0
- package/dist/server/services/document-command-service.js.map +1 -0
- package/dist/server/services/document-service.d.ts +14 -0
- package/dist/server/services/document-service.d.ts.map +1 -0
- package/dist/server/services/document-service.js +144 -0
- package/dist/server/services/document-service.js.map +1 -0
- package/dist/server/services/index.d.ts +68 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +28 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/page-doc-store.d.ts +59 -0
- package/dist/server/services/page-doc-store.d.ts.map +1 -0
- package/dist/server/services/page-doc-store.js +285 -0
- package/dist/server/services/page-doc-store.js.map +1 -0
- package/dist/server/services/site-store.d.ts +43 -0
- package/dist/server/services/site-store.d.ts.map +1 -0
- package/dist/server/services/site-store.js +212 -0
- package/dist/server/services/site-store.js.map +1 -0
- package/dist/server-cjs/index.js +17381 -0
- package/dist/server-cjs/package.json +3 -0
- package/package.json +77 -0
- package/src/admin/appearance.ts +56 -0
- package/src/admin/block-packages.ts +16 -0
- package/src/admin/client/content-model.ts +88 -0
- package/src/admin/client/host-services.ts +172 -0
- package/src/admin/index.tsx +56 -0
- package/src/admin/pages/App.tsx +56 -0
- package/src/admin/strapi-admin.d.ts +1 -0
- package/src/admin/translations.ts +23 -0
- package/src/constants.ts +21 -0
- package/src/index.ts +11 -0
- package/src/server/bootstrap.ts +45 -0
- package/src/server/config/index.ts +46 -0
- package/src/server/content-types/canvas-page/schema.ts +20 -0
- package/src/server/content-types/canvas-site/schema.ts +19 -0
- package/src/server/content-types/index.ts +7 -0
- package/src/server/controllers/canvas.ts +468 -0
- package/src/server/controllers/index.ts +5 -0
- package/src/server/index.ts +21 -0
- package/src/server/license.ts +42 -0
- package/src/server/policies/is-admin-authenticated.ts +3 -0
- package/src/server/register.ts +3 -0
- package/src/server/routes/admin.ts +40 -0
- package/src/server/routes/index.ts +7 -0
- package/src/server/routes/public.ts +6 -0
- package/src/server/services/collaboration-service.ts +180 -0
- package/src/server/services/document-command-service.ts +495 -0
- package/src/server/services/document-service.ts +162 -0
- package/src/server/services/index.ts +41 -0
- package/src/server/services/page-doc-store.ts +348 -0
- package/src/server/services/site-store.ts +243 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { locale, pageId } from "@batterai/bind-core";
|
|
3
|
+
import type { CanvasPage } from "@batterai/contract";
|
|
4
|
+
import type { CollabPageRecord, PageOp } from "@batterai/collab-protocol";
|
|
5
|
+
import { createCollabHttpHandler, createCollabServer, type CollabTokenClaims } from "@batterai/collab-server";
|
|
6
|
+
import {
|
|
7
|
+
PageDocConflict,
|
|
8
|
+
PageDraftVersionConflict,
|
|
9
|
+
cmsSessionFromToken,
|
|
10
|
+
createPageDocPageDraftStore,
|
|
11
|
+
delegatedViewer,
|
|
12
|
+
type PageDocPort,
|
|
13
|
+
type PageDocStoredPageDraftRoom,
|
|
14
|
+
type PageDraftStore,
|
|
15
|
+
type StoredPageDoc,
|
|
16
|
+
type Viewer,
|
|
17
|
+
} from "@batterai/host";
|
|
18
|
+
import { canvasPageKey } from "../../constants";
|
|
19
|
+
import { CanvasPageConflict, createPageDocStore, createStrapiPageDocRepository } from "./page-doc-store";
|
|
20
|
+
import { defaultHomePage } from "./site-store";
|
|
21
|
+
|
|
22
|
+
type Room = PageDocStoredPageDraftRoom<CollabPageRecord, PageOp>;
|
|
23
|
+
type CollaborationConfig = {
|
|
24
|
+
readonly secret: string;
|
|
25
|
+
readonly origins?: readonly string[];
|
|
26
|
+
readonly tokenTtlSeconds?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function encode(value: string | Uint8Array): string {
|
|
30
|
+
return Buffer.from(value).toString("base64url");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sign(payload: string, secret: string): string {
|
|
34
|
+
return encode(createHmac("sha256", secret).update(payload).digest());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function decodeToken(token: string, secret: string): CollabTokenClaims {
|
|
38
|
+
const [payload, signature, extra] = token.split(".");
|
|
39
|
+
if (!payload || !signature || extra) throw new Error("Malformed collaboration token");
|
|
40
|
+
const expected = Buffer.from(sign(payload, secret));
|
|
41
|
+
const actual = Buffer.from(signature);
|
|
42
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) throw new Error("Invalid collaboration token");
|
|
43
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as CollabTokenClaims;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toRecord(page: CanvasPage): CollabPageRecord {
|
|
47
|
+
const { id, root, ...canvasSettings } = page;
|
|
48
|
+
return { page: { id: pageId(id), path: page.path ?? "", root }, canvasSettings };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fromRecord(record: CollabPageRecord): CanvasPage {
|
|
52
|
+
return {
|
|
53
|
+
id: String(record.page.id),
|
|
54
|
+
title: record.canvasSettings?.title ?? String(record.page.id),
|
|
55
|
+
...(record.canvasSettings?.path !== undefined ? { path: record.canvasSettings.path } : {}),
|
|
56
|
+
...(record.canvasSettings?.parentId !== undefined ? { parentId: record.canvasSettings.parentId } : {}),
|
|
57
|
+
...(record.canvasSettings?.contentBinding !== undefined ? { contentBinding: record.canvasSettings.contentBinding } : {}),
|
|
58
|
+
...(record.canvasSettings?.seo !== undefined ? { seo: record.canvasSettings.seo } : {}),
|
|
59
|
+
...(record.canvasSettings?.layout !== undefined ? { layout: record.canvasSettings.layout } : {}),
|
|
60
|
+
root: record.page.root,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createStrapiCollaborationService(strapi: any) {
|
|
65
|
+
const config = (strapi.config.get("plugin::canvas")?.collaboration ?? null) as CollaborationConfig | null;
|
|
66
|
+
if (!config) return null;
|
|
67
|
+
const roomDocs = createPageDocStore<StoredPageDoc<Room>>(createStrapiPageDocRepository(strapi));
|
|
68
|
+
const pages = createPageDocStore<CanvasPage>(createStrapiPageDocRepository(strapi));
|
|
69
|
+
const port: PageDocPort<Room> = {
|
|
70
|
+
async read(key) {
|
|
71
|
+
const row = await roomDocs.read(key);
|
|
72
|
+
return row?.document.page;
|
|
73
|
+
},
|
|
74
|
+
async write(key, doc, options) {
|
|
75
|
+
try {
|
|
76
|
+
await roomDocs.save(key, doc, options.expectedDocVersion);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof CanvasPageConflict) throw new PageDocConflict(key, options.expectedDocVersion, error.actualDocVersion);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const durable = createPageDocPageDraftStore<CollabPageRecord, PageOp>({
|
|
84
|
+
port,
|
|
85
|
+
authorize: (viewer) => viewer.session ? true : "Authenticated CMS editor required",
|
|
86
|
+
keyPrefix: "canvas-collab-room",
|
|
87
|
+
});
|
|
88
|
+
const bridged: PageDraftStore<CollabPageRecord, PageOp> = {
|
|
89
|
+
...durable,
|
|
90
|
+
async flushDraft(room, input) {
|
|
91
|
+
const key = canvasPageKey(String(room.pageId));
|
|
92
|
+
const existing = await pages.read(key);
|
|
93
|
+
if (!existing) throw new PageDraftVersionConflict(input.previousDraftVersion, undefined);
|
|
94
|
+
const target = fromRecord(input.record);
|
|
95
|
+
if (JSON.stringify(existing.document.page) !== JSON.stringify(target)) {
|
|
96
|
+
try {
|
|
97
|
+
await pages.save(key, target, existing.docVersion);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error instanceof CanvasPageConflict) throw new PageDraftVersionConflict(input.previousDraftVersion, undefined);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return durable.flushDraft(room, input);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const server = createCollabServer({
|
|
107
|
+
store: bridged,
|
|
108
|
+
verifier: {
|
|
109
|
+
async verify(token) {
|
|
110
|
+
const claims = decodeToken(token, config.secret);
|
|
111
|
+
const now = Math.floor(Date.now() / 1000);
|
|
112
|
+
if (claims.iss !== "strapi-canvas" || claims.aud !== "battery-collab" || claims.exp <= now || (claims.nbf ?? 0) > now) {
|
|
113
|
+
throw new Error("Expired or invalid collaboration token");
|
|
114
|
+
}
|
|
115
|
+
return { viewer: delegatedViewer(cmsSessionFromToken(token), locale(claims.locale)), claims };
|
|
116
|
+
},
|
|
117
|
+
async isRevoked() { return false; },
|
|
118
|
+
},
|
|
119
|
+
opAdmission: {
|
|
120
|
+
canEditBlockField: () => true,
|
|
121
|
+
canRemoveBlockField: () => true,
|
|
122
|
+
canInsertNode: () => true,
|
|
123
|
+
},
|
|
124
|
+
participantIdentity: ({ claims }) => ({
|
|
125
|
+
displayName: claims.sub,
|
|
126
|
+
color: `#${createHmac("sha256", config.secret).update(claims.userId).digest("hex").slice(0, 6)}`,
|
|
127
|
+
}),
|
|
128
|
+
now: () => new Date().toISOString(),
|
|
129
|
+
origins: config.origins ?? [],
|
|
130
|
+
requireSecureTransport: process.env.NODE_ENV === "production",
|
|
131
|
+
requireWriteLocks: true,
|
|
132
|
+
});
|
|
133
|
+
const handler = createCollabHttpHandler(server, { basePath: "/collab" });
|
|
134
|
+
const sessionOwners = new Map<string, string>();
|
|
135
|
+
|
|
136
|
+
async function ensurePage(canvasPageId: string) {
|
|
137
|
+
let row = await pages.read(canvasPageKey(canvasPageId));
|
|
138
|
+
if (!row && canvasPageId === "home") {
|
|
139
|
+
row = await pages.save(canvasPageKey(canvasPageId), defaultHomePage(), 0);
|
|
140
|
+
}
|
|
141
|
+
if (!row) throw new Error(`Canvas page ${canvasPageId} does not exist`);
|
|
142
|
+
await durable.refreshDraft({ ref: { pageId: pageId(canvasPageId) }, record: toRecord(row.document.page) });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
mintToken(user: { readonly id: string | number; readonly firstname?: string; readonly lastname?: string; readonly username?: string; readonly roles?: readonly { readonly id?: string | number; readonly code?: string; readonly name?: string }[] }) {
|
|
147
|
+
const now = Math.floor(Date.now() / 1000);
|
|
148
|
+
const displayName = [user.firstname, user.lastname].filter(Boolean).join(" ") || user.username || `User ${user.id}`;
|
|
149
|
+
const claims: CollabTokenClaims = {
|
|
150
|
+
iss: "strapi-canvas",
|
|
151
|
+
aud: "battery-collab",
|
|
152
|
+
sub: displayName,
|
|
153
|
+
jti: randomUUID(),
|
|
154
|
+
exp: now + (config.tokenTtlSeconds ?? 300),
|
|
155
|
+
nbf: now - 5,
|
|
156
|
+
tenant: "strapi",
|
|
157
|
+
userId: String(user.id),
|
|
158
|
+
roles: (user.roles ?? []).map((role) => String(role.id ?? role.code ?? role.name ?? "unknown")),
|
|
159
|
+
locale: "nl",
|
|
160
|
+
};
|
|
161
|
+
const payload = encode(JSON.stringify(claims));
|
|
162
|
+
return { token: `${payload}.${sign(payload, config.secret)}`, expiresAt: new Date(claims.exp * 1000).toISOString() };
|
|
163
|
+
},
|
|
164
|
+
ensurePage,
|
|
165
|
+
async readAudit(canvasPageId: string) {
|
|
166
|
+
await ensurePage(canvasPageId);
|
|
167
|
+
return durable.readAuditEvents({ pageId: pageId(canvasPageId) });
|
|
168
|
+
},
|
|
169
|
+
assertTokenOwner(token: string, userId: string | number) {
|
|
170
|
+
if (decodeToken(token, config.secret).userId !== String(userId)) throw new Error("Collaboration token belongs to another CMS user");
|
|
171
|
+
},
|
|
172
|
+
assertSessionOwner(sessionId: string, userId: string | number) {
|
|
173
|
+
if (sessionOwners.get(sessionId) !== String(userId)) throw new Error("Collaboration session belongs to another CMS user");
|
|
174
|
+
},
|
|
175
|
+
claimSession(sessionId: string, userId: string | number) {
|
|
176
|
+
sessionOwners.set(sessionId, String(userId));
|
|
177
|
+
},
|
|
178
|
+
handler,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyCanvasChangeSet,
|
|
3
|
+
hostConcurrencyToken,
|
|
4
|
+
parseCanvasDocument,
|
|
5
|
+
parseCanvasPage,
|
|
6
|
+
type CanvasChange,
|
|
7
|
+
type CanvasChangeSet,
|
|
8
|
+
type CanvasDocument,
|
|
9
|
+
type CanvasHostIssue,
|
|
10
|
+
type CanvasHostResult,
|
|
11
|
+
type CanvasPage,
|
|
12
|
+
type HostConcurrencyToken,
|
|
13
|
+
type HostPageState,
|
|
14
|
+
type HostPublishReceipt,
|
|
15
|
+
type HostSaveReceipt,
|
|
16
|
+
type HostStageReceipt,
|
|
17
|
+
} from "@batterai/contract";
|
|
18
|
+
import { CANVAS_PAGE_PREFIX, canvasPageKey } from "../../constants";
|
|
19
|
+
import {
|
|
20
|
+
CanvasPageConflict,
|
|
21
|
+
createPageDocStore,
|
|
22
|
+
createStrapiPageDocRepository,
|
|
23
|
+
type CanvasPageRow,
|
|
24
|
+
} from "./page-doc-store";
|
|
25
|
+
import {
|
|
26
|
+
CanvasSiteConflict,
|
|
27
|
+
createSiteStore,
|
|
28
|
+
defaultHomePage,
|
|
29
|
+
type CanvasSiteDocument,
|
|
30
|
+
} from "./site-store";
|
|
31
|
+
|
|
32
|
+
const CONCURRENCY_PREFIX = "strapi:canvas:v1:";
|
|
33
|
+
|
|
34
|
+
type CommandPageStore = {
|
|
35
|
+
list(): Promise<CanvasPageRow<CanvasPage>[]>;
|
|
36
|
+
save(pageKey: string, page: CanvasPage, expectedDocVersion: number): Promise<CanvasPageRow<CanvasPage>>;
|
|
37
|
+
remove(pageKey: string, expectedDocVersion: number): Promise<void>;
|
|
38
|
+
publish(pageKey: string, expectedDocVersion: number, publishedAt?: Date): Promise<CanvasPageRow<CanvasPage>>;
|
|
39
|
+
stage(pageKey: string, expectedDocVersion: number): Promise<CanvasPageRow<CanvasPage>>;
|
|
40
|
+
unstage(pageKey: string, expectedDocVersion: number): Promise<CanvasPageRow<CanvasPage>>;
|
|
41
|
+
publishStaged(pageKey: string, expectedDocVersion: number, publishedAt?: Date): Promise<CanvasPageRow<CanvasPage>>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type CommandSiteStore = {
|
|
45
|
+
readOrCreate(): Promise<CanvasSiteDocument>;
|
|
46
|
+
updateIfVersion(
|
|
47
|
+
expectedDocVersion: number,
|
|
48
|
+
mutator: (document: CanvasSiteDocument) => CanvasSiteDocument,
|
|
49
|
+
): Promise<CanvasSiteDocument>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type CanvasDocumentCommandTransaction = {
|
|
53
|
+
readonly pageStore: CommandPageStore;
|
|
54
|
+
readonly siteStore: CommandSiteStore;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type CanvasDocumentCommandTransactionRunner = <T>(
|
|
58
|
+
work: (transaction: CanvasDocumentCommandTransaction) => Promise<T>,
|
|
59
|
+
) => Promise<T>;
|
|
60
|
+
|
|
61
|
+
export type CanvasDocumentCommandServiceOptions = {
|
|
62
|
+
readonly now?: () => Date;
|
|
63
|
+
readonly runTransaction?: CanvasDocumentCommandTransactionRunner;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
class CanvasDocumentCommandValidationError extends Error {
|
|
67
|
+
constructor(readonly issues: readonly CanvasHostIssue[]) {
|
|
68
|
+
super(issues.map((issue) => issue.message).join(" "));
|
|
69
|
+
this.name = "CanvasDocumentCommandValidationError";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function strapiCanvasConcurrency(revision: number): HostConcurrencyToken {
|
|
74
|
+
return hostConcurrencyToken(`${CONCURRENCY_PREFIX}${revision}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createDocumentCommandService(
|
|
78
|
+
strapi: any,
|
|
79
|
+
options: CanvasDocumentCommandServiceOptions = {},
|
|
80
|
+
) {
|
|
81
|
+
const now = options.now ?? (() => new Date());
|
|
82
|
+
const config = strapi.config.get("plugin::canvas") ?? {};
|
|
83
|
+
|
|
84
|
+
async function productionTransaction<T>(
|
|
85
|
+
work: (transaction: CanvasDocumentCommandTransaction) => Promise<T>,
|
|
86
|
+
): Promise<T> {
|
|
87
|
+
return strapi.db.transaction(async ({ trx }: { readonly trx: (tableName: string) => any }) => work({
|
|
88
|
+
pageStore: createPageDocStore<CanvasPage>(createStrapiPageDocRepository<CanvasPage>(strapi, trx)),
|
|
89
|
+
siteStore: createSiteStore(strapi, config.seedLibrary, trx),
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const runTransaction = options.runTransaction ?? productionTransaction;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
async saveDocument(request: unknown): Promise<CanvasHostResult<HostSaveReceipt>> {
|
|
97
|
+
let expectedRevision: number | undefined;
|
|
98
|
+
try {
|
|
99
|
+
const body = record(request, "$", "Document command request must be an object.");
|
|
100
|
+
expectedRevision = parseExpectedRevision(body.expected);
|
|
101
|
+
const changes = parseChangeSet(body.changes);
|
|
102
|
+
const receipt = await runTransaction(async ({ pageStore, siteStore }) => {
|
|
103
|
+
const site = await siteStore.readOrCreate();
|
|
104
|
+
assertAggregateRevision(site, expectedRevision!);
|
|
105
|
+
const rows = canvasPageRows(await pageStore.list());
|
|
106
|
+
const current = validateDocument(draftDocument(site, rows));
|
|
107
|
+
const next = validateDocument(normalizeDocumentOrder(applyCanvasChangeSet(current, changes)));
|
|
108
|
+
const operations = pageOperations(rows, current, next);
|
|
109
|
+
const nextSite = await siteStore.updateIfVersion(expectedRevision!, (stored) => ({
|
|
110
|
+
...stored,
|
|
111
|
+
docVersion: expectedRevision! + 1,
|
|
112
|
+
pageOrder: next.pages.map((page) => page.id),
|
|
113
|
+
canvas: {
|
|
114
|
+
theme: next.theme,
|
|
115
|
+
assets: next.assets,
|
|
116
|
+
variables: next.variables,
|
|
117
|
+
layout: next.layout,
|
|
118
|
+
},
|
|
119
|
+
}));
|
|
120
|
+
for (const operation of operations) {
|
|
121
|
+
if (operation.kind === "remove") {
|
|
122
|
+
await pageStore.remove(operation.pageKey, operation.expectedDocVersion);
|
|
123
|
+
} else {
|
|
124
|
+
await pageStore.save(operation.pageKey, operation.page, operation.expectedDocVersion);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const nextRows = canvasPageRows(await pageStore.list());
|
|
128
|
+
return {
|
|
129
|
+
concurrency: strapiCanvasConcurrency(nextSite.docVersion),
|
|
130
|
+
state: { pages: hostPageStates(nextRows, next.pages) },
|
|
131
|
+
} satisfies HostSaveReceipt;
|
|
132
|
+
});
|
|
133
|
+
return { ok: true, value: receipt };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return commandError(error, expectedRevision);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async publishDocument(request: unknown): Promise<CanvasHostResult<HostPublishReceipt>> {
|
|
140
|
+
let expectedRevision: number | undefined;
|
|
141
|
+
try {
|
|
142
|
+
const body = record(request, "$", "Publish command request must be an object.");
|
|
143
|
+
expectedRevision = parseExpectedRevision(body.expected);
|
|
144
|
+
const receipt = await runTransaction(async ({ pageStore, siteStore }) => {
|
|
145
|
+
const site = await siteStore.readOrCreate();
|
|
146
|
+
assertAggregateRevision(site, expectedRevision!);
|
|
147
|
+
const initialRows = canvasPageRows(await pageStore.list());
|
|
148
|
+
const stagedRows = initialRows.filter((row) => Boolean(row.document.staged));
|
|
149
|
+
if (stagedRows.length === 0) {
|
|
150
|
+
throw new CanvasDocumentCommandValidationError([
|
|
151
|
+
{ path: "$.pages", message: "Stage at least one page before publishing a release." },
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
const publishedAt = now();
|
|
155
|
+
for (const row of [...stagedRows].sort((left, right) => left.pageKey.localeCompare(right.pageKey))) {
|
|
156
|
+
await pageStore.publishStaged(row.pageKey, row.docVersion, publishedAt);
|
|
157
|
+
}
|
|
158
|
+
const nextRows = canvasPageRows(await pageStore.list());
|
|
159
|
+
const document = validateDocument(publishedDocument(site, nextRows));
|
|
160
|
+
const nextSite = await siteStore.updateIfVersion(expectedRevision!, (stored) => ({
|
|
161
|
+
...stored,
|
|
162
|
+
docVersion: expectedRevision! + 1,
|
|
163
|
+
published: {
|
|
164
|
+
document,
|
|
165
|
+
content: structuredClone(stored.content),
|
|
166
|
+
library: structuredClone(stored.library),
|
|
167
|
+
publishedAt: publishedAt.toISOString(),
|
|
168
|
+
sourceDocVersion: expectedRevision!,
|
|
169
|
+
},
|
|
170
|
+
}));
|
|
171
|
+
return {
|
|
172
|
+
concurrency: strapiCanvasConcurrency(nextSite.docVersion),
|
|
173
|
+
state: {
|
|
174
|
+
publicationLabel: "Published",
|
|
175
|
+
pages: hostPageStates(nextRows, draftDocument(nextSite, nextRows).pages),
|
|
176
|
+
},
|
|
177
|
+
} satisfies HostPublishReceipt;
|
|
178
|
+
});
|
|
179
|
+
return { ok: true, value: receipt };
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return commandError(error, expectedRevision);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async stagePage(request: unknown): Promise<CanvasHostResult<HostStageReceipt>> {
|
|
186
|
+
return changePageStage(request, true);
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async unstagePage(request: unknown): Promise<CanvasHostResult<HostStageReceipt>> {
|
|
190
|
+
return changePageStage(request, false);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
async function changePageStage(request: unknown, staged: boolean): Promise<CanvasHostResult<HostStageReceipt>> {
|
|
195
|
+
let expectedRevision: number | undefined;
|
|
196
|
+
try {
|
|
197
|
+
const body = record(request, "$", "Page stage command request must be an object.");
|
|
198
|
+
assertKeys(body, new Set(["expected", "pageId"]), "$");
|
|
199
|
+
expectedRevision = parseExpectedRevision(body.expected);
|
|
200
|
+
if (typeof body.pageId !== "string" || !body.pageId) validation("$.pageId", "pageId must be a non-empty string.");
|
|
201
|
+
const pageId = body.pageId;
|
|
202
|
+
const receipt = await runTransaction(async ({ pageStore, siteStore }) => {
|
|
203
|
+
const site = await siteStore.readOrCreate();
|
|
204
|
+
assertAggregateRevision(site, expectedRevision!);
|
|
205
|
+
const rows = canvasPageRows(await pageStore.list());
|
|
206
|
+
let row = pageRowsById(rows).get(pageId);
|
|
207
|
+
if (!row) {
|
|
208
|
+
const virtualDraft = draftDocument(site, rows).pages.find((page) => page.id === pageId);
|
|
209
|
+
if (!virtualDraft) {
|
|
210
|
+
throw new CanvasDocumentCommandValidationError([{ path: "$.pageId", message: `Page ${pageId} does not exist.` }]);
|
|
211
|
+
}
|
|
212
|
+
row = await pageStore.save(canvasPageKey(pageId), virtualDraft, 0);
|
|
213
|
+
}
|
|
214
|
+
if (staged && !row.document.page) {
|
|
215
|
+
throw new CanvasDocumentCommandValidationError([{ path: "$.pageId", message: `Page ${pageId} has no draft revision.` }]);
|
|
216
|
+
}
|
|
217
|
+
const nextSite = await siteStore.updateIfVersion(expectedRevision!, (stored) => ({
|
|
218
|
+
...stored,
|
|
219
|
+
docVersion: expectedRevision! + 1,
|
|
220
|
+
}));
|
|
221
|
+
if (staged) await pageStore.stage(row.pageKey, row.docVersion);
|
|
222
|
+
else if (row.document.staged) await pageStore.unstage(row.pageKey, row.docVersion);
|
|
223
|
+
const nextRows = canvasPageRows(await pageStore.list());
|
|
224
|
+
const pages = draftDocument(nextSite, nextRows).pages;
|
|
225
|
+
return {
|
|
226
|
+
concurrency: strapiCanvasConcurrency(nextSite.docVersion),
|
|
227
|
+
state: { pages: hostPageStates(nextRows, pages) },
|
|
228
|
+
} satisfies HostStageReceipt;
|
|
229
|
+
});
|
|
230
|
+
return { ok: true, value: receipt };
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return commandError(error, expectedRevision);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parseExpectedRevision(value: unknown): number {
|
|
238
|
+
if (typeof value !== "string" || !value.startsWith(CONCURRENCY_PREFIX)) {
|
|
239
|
+
throw new CanvasDocumentCommandValidationError([
|
|
240
|
+
{ path: "$.expected", message: "A Strapi Canvas aggregate concurrency token is required." },
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
const revision = Number(value.slice(CONCURRENCY_PREFIX.length));
|
|
244
|
+
if (!Number.isInteger(revision) || revision < 0 || value !== `${CONCURRENCY_PREFIX}${revision}`) {
|
|
245
|
+
throw new CanvasDocumentCommandValidationError([
|
|
246
|
+
{ path: "$.expected", message: "The Strapi Canvas aggregate concurrency token is invalid." },
|
|
247
|
+
]);
|
|
248
|
+
}
|
|
249
|
+
return revision;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function assertAggregateRevision(site: CanvasSiteDocument, expectedRevision: number): void {
|
|
253
|
+
if (site.docVersion !== expectedRevision) {
|
|
254
|
+
throw new CanvasSiteConflict(expectedRevision, site.docVersion);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseChangeSet(value: unknown): CanvasChangeSet {
|
|
259
|
+
const candidate = record(value, "$.changes", "Canvas changes must be an object.");
|
|
260
|
+
if (!Array.isArray(candidate.changes) || candidate.changes.length === 0) {
|
|
261
|
+
throw new CanvasDocumentCommandValidationError([
|
|
262
|
+
{ path: "$.changes.changes", message: "Canvas changes must contain at least one change." },
|
|
263
|
+
]);
|
|
264
|
+
}
|
|
265
|
+
const changes = candidate.changes.map((change, index) => parseChange(change, `$.changes.changes[${index}]`));
|
|
266
|
+
return { changes };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parseChange(value: unknown, path: string): CanvasChange {
|
|
270
|
+
const change = record(value, path, "Canvas change must be an object.");
|
|
271
|
+
const kind = change.kind;
|
|
272
|
+
if (kind === "upsert-page") {
|
|
273
|
+
assertKeys(change, new Set(["kind", "page"]), path);
|
|
274
|
+
return { kind, page: change.page as CanvasPage };
|
|
275
|
+
}
|
|
276
|
+
if (kind === "remove-page") {
|
|
277
|
+
assertKeys(change, new Set(["kind", "pageId"]), path);
|
|
278
|
+
if (typeof change.pageId !== "string" || !change.pageId) validation(path, "pageId must be a non-empty string.");
|
|
279
|
+
return { kind, pageId: change.pageId as never };
|
|
280
|
+
}
|
|
281
|
+
if (kind === "set-theme") {
|
|
282
|
+
assertKeys(change, new Set(["kind", "theme"]), path);
|
|
283
|
+
return { kind, theme: change.theme as never };
|
|
284
|
+
}
|
|
285
|
+
if (kind === "set-assets") {
|
|
286
|
+
assertKeys(change, new Set(["kind", "assets"]), path);
|
|
287
|
+
return { kind, assets: change.assets as never };
|
|
288
|
+
}
|
|
289
|
+
if (kind === "set-variable") {
|
|
290
|
+
assertKeys(change, new Set(["kind", "name", "value"]), path);
|
|
291
|
+
if (typeof change.name !== "string" || !change.name || !("value" in change)) {
|
|
292
|
+
validation(path, "set-variable requires a non-empty name and value.");
|
|
293
|
+
}
|
|
294
|
+
return { kind, name: change.name, value: change.value as never };
|
|
295
|
+
}
|
|
296
|
+
if (kind === "remove-variable") {
|
|
297
|
+
assertKeys(change, new Set(["kind", "name"]), path);
|
|
298
|
+
if (typeof change.name !== "string" || !change.name) validation(path, "remove-variable requires a name.");
|
|
299
|
+
return { kind, name: change.name };
|
|
300
|
+
}
|
|
301
|
+
if (kind === "set-layout") {
|
|
302
|
+
assertKeys(change, new Set(["kind", "layout"]), path);
|
|
303
|
+
return { kind, layout: change.layout as never };
|
|
304
|
+
}
|
|
305
|
+
validation(`${path}.kind`, "Unsupported Canvas change kind.");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function draftDocument(site: CanvasSiteDocument, rows: readonly CanvasPageRow<CanvasPage>[]): CanvasDocument {
|
|
309
|
+
const byId = pageRowsById(rows);
|
|
310
|
+
if (byId.size === 0) {
|
|
311
|
+
const home = defaultHomePage();
|
|
312
|
+
byId.set(home.id, { pageKey: canvasPageKey(home.id), docVersion: 0, document: { kind: "canvas-editor-page", docVersion: 0, page: home, updatedAt: "" } });
|
|
313
|
+
}
|
|
314
|
+
const ordered = site.pageOrder.flatMap((id) => byId.get(id)?.document.page ?? []);
|
|
315
|
+
const orderedIds = new Set(ordered.map((page) => page.id));
|
|
316
|
+
const pages = [...ordered, ...[...byId.values()].map((row) => row.document.page).filter((page) => !orderedIds.has(page.id))];
|
|
317
|
+
return {
|
|
318
|
+
schemaVersion: 1,
|
|
319
|
+
pages,
|
|
320
|
+
...site.canvas,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function publishedDocument(site: CanvasSiteDocument, rows: readonly CanvasPageRow<CanvasPage>[]): CanvasDocument {
|
|
325
|
+
// Start from the last public release so an unrelated release cannot
|
|
326
|
+
// accidentally unpublish a page whose draft was deleted. Explicit staged
|
|
327
|
+
// unpublish commands can remove that baseline in a future workflow slice.
|
|
328
|
+
const previouslyPublished = site.published?.document.pages ?? [];
|
|
329
|
+
const rowPublications = rows.flatMap((row) => row.document.published?.page ?? []);
|
|
330
|
+
const byId = new Map(previouslyPublished.map((page) => [page.id, page]));
|
|
331
|
+
for (const page of rowPublications) byId.set(page.id, page);
|
|
332
|
+
const requestedOrder = site.pageOrder.filter((id) => byId.has(id));
|
|
333
|
+
const extras = [...byId.values()].filter((page) => !requestedOrder.includes(page.id));
|
|
334
|
+
const pages = [...requestedOrder.map((id) => byId.get(id)!), ...extras];
|
|
335
|
+
return normalizeDocumentOrder({
|
|
336
|
+
schemaVersion: 1,
|
|
337
|
+
pages,
|
|
338
|
+
...site.canvas,
|
|
339
|
+
layout: { ...site.canvas.layout, pageOrder: pages.map((page) => page.id) as never },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function normalizeDocumentOrder(document: CanvasDocument): CanvasDocument {
|
|
344
|
+
const pagesById = new Map(document.pages.map((page) => [page.id, page]));
|
|
345
|
+
const requested = document.layout?.pageOrder ?? [];
|
|
346
|
+
const pageOrder: string[] = [];
|
|
347
|
+
for (const id of requested) {
|
|
348
|
+
if (pagesById.has(id) && !pageOrder.includes(id)) pageOrder.push(id);
|
|
349
|
+
}
|
|
350
|
+
for (const page of document.pages) {
|
|
351
|
+
if (!pageOrder.includes(page.id)) pageOrder.push(page.id);
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
...document,
|
|
355
|
+
pages: pageOrder.map((id) => pagesById.get(id)!),
|
|
356
|
+
layout: { ...document.layout, pageOrder: pageOrder as never },
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function validateDocument(document: CanvasDocument): CanvasDocument {
|
|
361
|
+
const parsed = parseCanvasDocument(document);
|
|
362
|
+
const issues: CanvasHostIssue[] = parsed.ok ? [] : [...parsed.issues];
|
|
363
|
+
const paths = new Map<string, string>();
|
|
364
|
+
for (const page of document.pages) {
|
|
365
|
+
if (!page.path) continue;
|
|
366
|
+
const path = normalizePath(page.path);
|
|
367
|
+
const existing = paths.get(path);
|
|
368
|
+
if (existing) {
|
|
369
|
+
issues.push({ path: `$.pages.${page.id}.path`, message: `Route ${path} is already used by page ${existing}.` });
|
|
370
|
+
} else {
|
|
371
|
+
paths.set(path, page.id);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (issues.length) throw new CanvasDocumentCommandValidationError(issues);
|
|
375
|
+
return parsed.ok ? parsed.document : document;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function pageOperations(
|
|
379
|
+
rows: readonly CanvasPageRow<CanvasPage>[],
|
|
380
|
+
current: CanvasDocument,
|
|
381
|
+
next: CanvasDocument,
|
|
382
|
+
) {
|
|
383
|
+
const rowsById = pageRowsById(rows);
|
|
384
|
+
const currentById = new Map(current.pages.map((page) => [page.id, page]));
|
|
385
|
+
const nextById = new Map(next.pages.map((page) => [page.id, page]));
|
|
386
|
+
const operations: Array<
|
|
387
|
+
| { readonly kind: "remove"; readonly pageKey: string; readonly expectedDocVersion: number }
|
|
388
|
+
| { readonly kind: "upsert"; readonly pageKey: string; readonly expectedDocVersion: number; readonly page: CanvasPage }
|
|
389
|
+
> = [];
|
|
390
|
+
for (const [pageId] of currentById) {
|
|
391
|
+
if (nextById.has(pageId)) continue;
|
|
392
|
+
const row = rowsById.get(pageId);
|
|
393
|
+
if (row) operations.push({ kind: "remove", pageKey: row.pageKey, expectedDocVersion: row.docVersion });
|
|
394
|
+
}
|
|
395
|
+
for (const [pageId, page] of nextById) {
|
|
396
|
+
const existing = currentById.get(pageId);
|
|
397
|
+
if (existing && JSON.stringify(existing) === JSON.stringify(page)) continue;
|
|
398
|
+
const row = rowsById.get(pageId);
|
|
399
|
+
operations.push({
|
|
400
|
+
kind: "upsert",
|
|
401
|
+
pageKey: row?.pageKey ?? canvasPageKey(pageId),
|
|
402
|
+
expectedDocVersion: row?.docVersion ?? 0,
|
|
403
|
+
page,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return operations.sort((left, right) => left.pageKey.localeCompare(right.pageKey));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function pageRowsById(rows: readonly CanvasPageRow<CanvasPage>[]) {
|
|
410
|
+
return new Map(rows.map((row) => [row.document.page.id, row]));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function canvasPageRows(rows: readonly CanvasPageRow<CanvasPage>[]): CanvasPageRow<CanvasPage>[] {
|
|
414
|
+
return rows
|
|
415
|
+
.filter((row) => row.pageKey.startsWith(CANVAS_PAGE_PREFIX))
|
|
416
|
+
.map((row) => {
|
|
417
|
+
const parsed = parseCanvasPage(row.document?.page);
|
|
418
|
+
if (!parsed.ok) {
|
|
419
|
+
throw new CanvasDocumentCommandValidationError(parsed.issues.map((issue) => ({
|
|
420
|
+
path: `$.storedPages[${JSON.stringify(row.pageKey)}]${issue.path.slice(1)}`,
|
|
421
|
+
message: `Stored Canvas page ${row.pageKey} is invalid: ${issue.message}`,
|
|
422
|
+
})));
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
...row,
|
|
426
|
+
document: { ...row.document, page: parsed.page },
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function hostPageStates(
|
|
432
|
+
rows: readonly CanvasPageRow<CanvasPage>[],
|
|
433
|
+
pages: readonly CanvasPage[],
|
|
434
|
+
): Record<string, HostPageState> {
|
|
435
|
+
const rowsById = pageRowsById(rows);
|
|
436
|
+
return Object.fromEntries(pages.map((page) => {
|
|
437
|
+
const row = rowsById.get(page.id);
|
|
438
|
+
const published = row?.document.published;
|
|
439
|
+
const staged = row?.document.staged;
|
|
440
|
+
const draftDiffersFromPublished = Boolean(published && JSON.stringify(page) !== JSON.stringify(published.page));
|
|
441
|
+
const draftDiffersFromStaged = Boolean(staged && JSON.stringify(page) !== JSON.stringify(staged.page));
|
|
442
|
+
const workflowLabel = staged
|
|
443
|
+
? (draftDiffersFromStaged ? "Staged · newer draft changes" : "Staged")
|
|
444
|
+
: published
|
|
445
|
+
? (draftDiffersFromPublished ? "Draft changes" : undefined)
|
|
446
|
+
: "Not published";
|
|
447
|
+
return [page.id, {
|
|
448
|
+
publication: published ? "published" as const : "draft" as const,
|
|
449
|
+
hasPublishedVersion: Boolean(published),
|
|
450
|
+
hasStagedVersion: Boolean(staged),
|
|
451
|
+
draftDiffersFromPublished,
|
|
452
|
+
draftDiffersFromStaged,
|
|
453
|
+
...(workflowLabel ? { workflowLabel } : {}),
|
|
454
|
+
...(published?.publishedAt ? { lastSavedLabel: published.publishedAt } : {}),
|
|
455
|
+
}];
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function commandError<T>(error: unknown, expectedRevision?: number): CanvasHostResult<T> {
|
|
460
|
+
if (error instanceof CanvasDocumentCommandValidationError) {
|
|
461
|
+
return { ok: false, error: { kind: "validation", issues: error.issues } };
|
|
462
|
+
}
|
|
463
|
+
if (error instanceof CanvasSiteConflict) {
|
|
464
|
+
return { ok: false, error: { kind: "conflict", latest: strapiCanvasConcurrency(error.actualDocVersion) } };
|
|
465
|
+
}
|
|
466
|
+
if (error instanceof CanvasPageConflict) {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
error: {
|
|
470
|
+
kind: "conflict",
|
|
471
|
+
...(expectedRevision === undefined ? {} : { latest: strapiCanvasConcurrency(expectedRevision) }),
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
return { ok: false, error: { kind: "unavailable", retryable: true } };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function record(value: unknown, path: string, message: string): Record<string, unknown> {
|
|
479
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) validation(path, message);
|
|
480
|
+
return value as Record<string, unknown>;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function assertKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>, path: string): void {
|
|
484
|
+
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
|
|
485
|
+
if (unexpected) validation(`${path}.${unexpected}`, `Unexpected Canvas change field ${unexpected}.`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function validation(path: string, message: string): never {
|
|
489
|
+
throw new CanvasDocumentCommandValidationError([{ path, message }]);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function normalizePath(path: string): string {
|
|
493
|
+
if (path === "/") return path;
|
|
494
|
+
return `/${path.replace(/^\/+|\/+$/g, "")}`;
|
|
495
|
+
}
|