@velora-cms/plugin-sdk 0.9.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,348 @@
1
+ import { z } from "zod";
2
+ import { CreateDocumentTypeRequestSchema } from "@velora-cms/api-schemas";
3
+ // Runtime mirror of manifest.ts — the same contract, enforced for
4
+ // manifests that arrive as VALUES (Module Federation remotes, sideloaded
5
+ // plugins, the future `velora-plugin validate` CLI) where the compiler
6
+ // can't help. The contribution matrix is enforced by .strict() objects:
7
+ // each per-type contributions schema declares only its allowed keys, so
8
+ // a disallowed contribution fails as an unrecognized key with a clear
9
+ // path like `contributions.adminSections`.
10
+ //
11
+ // Lives in the SDK, not @velora-cms/api-schemas, deliberately: the manifest
12
+ // carries React components and functions (api-schemas must never know
13
+ // React), and third-party tooling needs this validator from the
14
+ // published package.
15
+ // Zod 4: z.function() is not a schema — validate callables with
16
+ // z.custom. The type parameter is the CONTRACT type from manifest.ts,
17
+ // which is what makes the compile-time sync assertion at the bottom of
18
+ // this file hold. React components may be functions OR objects
19
+ // (React.memo/forwardRef produce exotic objects).
20
+ const callable = (label) => z.custom((value) => typeof value === "function", { message: `${label} must be a function` });
21
+ const componentSchema = z.custom((value) => typeof value === "function" || (typeof value === "object" && value !== null), { message: "must be a React component" });
22
+ // A JSON Schema document is a plain object (DataTypePlugin.schema is the
23
+ // JSONSchema7 object interface, not the boolean shorthand).
24
+ const jsonSchemaSchema = z.custom((value) => typeof value === "object" && value !== null && !Array.isArray(value), { message: "must be a JSON Schema object" });
25
+ // A plugin-declared request schema must be a REAL Zod schema instance
26
+ // (z.object(...), z.string(), etc. — every Zod schema extends the base
27
+ // ZodType class), not just an object that looks like one — plugin-api-
28
+ // routes.ts calls .safeParse() on it directly at request time.
29
+ const zodSchemaField = z.instanceof(z.ZodType);
30
+ // Reverse-domain id per the documented contract, e.g. "com.vendor.name".
31
+ const pluginIdSchema = z
32
+ .string()
33
+ .regex(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i, "must be a reverse-domain id like com.vendor.plugin");
34
+ const dialectSchema = z.enum(["postgresql", "mysql", "sqlite", "mssql", "mongodb"]);
35
+ const databaseCompatibilitySchema = z
36
+ .object({
37
+ storageType: z.enum(["simple", "structured"]),
38
+ // A plugin supporting zero databases can never be installed anywhere.
39
+ supported: z.array(dialectSchema).min(1),
40
+ unsupportedReason: z.partialRecord(dialectSchema, z.string()).optional(),
41
+ })
42
+ .strict();
43
+ const permissionsSchema = z
44
+ .object({
45
+ needsStructuredData: z.boolean(),
46
+ needsExternalNetwork: z.boolean(),
47
+ needsFileSystemAccess: z.boolean(),
48
+ })
49
+ .strict();
50
+ const pluginMigrationSchema = z
51
+ .object({
52
+ id: z.string().min(1),
53
+ name: z.string().min(1),
54
+ up: callable("up"),
55
+ down: callable("down"),
56
+ })
57
+ .strict();
58
+ const migrationHooksSchema = z
59
+ .object({
60
+ beforeMigration: callable("beforeMigration").optional(),
61
+ transformRow: callable("transformRow").optional(),
62
+ afterMigration: callable("afterMigration").optional(),
63
+ validateMigration: callable("validateMigration").optional(),
64
+ })
65
+ .strict();
66
+ const HOOK_EVENTS = [
67
+ "content:beforeSave",
68
+ "content:afterSave",
69
+ "content:beforePublish",
70
+ "content:afterPublish",
71
+ "content:afterTrash",
72
+ "content:afterRestore",
73
+ "content:beforeDelete",
74
+ "media:afterUpload",
75
+ "user:afterLogin",
76
+ "api:beforeResponse",
77
+ ];
78
+ // z.partialRecord, not z.record: with enum keys, Zod 4's record is
79
+ // exhaustive (it would demand a handler for all ten events).
80
+ const hooksSchema = z.partialRecord(z.enum(HOOK_EVENTS), callable("hook handler"));
81
+ // The DataTypePlugin contract's structural shape — shared by manifests
82
+ // (dataTypes contributions arriving as data) and definePlugin (which
83
+ // fronts it with friendlier required-view messages).
84
+ export const DataTypePluginSchema = z
85
+ .object({
86
+ id: pluginIdSchema,
87
+ name: z.string().min(1),
88
+ icon: z.string().min(1),
89
+ version: z.string().min(1),
90
+ type: z.literal("datatype"),
91
+ schema: jsonSchemaSchema,
92
+ settingsSchema: jsonSchemaSchema,
93
+ defaultSettings: z.record(z.string(), z.unknown()),
94
+ views: z
95
+ .object({
96
+ input: componentSchema,
97
+ readOnly: componentSchema,
98
+ settings: componentSchema,
99
+ preview: componentSchema.optional(),
100
+ })
101
+ .strict(),
102
+ serialize: callable("serialize"),
103
+ deserialize: callable("deserialize"),
104
+ })
105
+ .strict();
106
+ const adminSectionSchema = z
107
+ .object({
108
+ id: z.string().min(1),
109
+ name: z.string().min(1),
110
+ icon: z.string().min(1),
111
+ view: componentSchema,
112
+ adminOnly: z.boolean().optional(),
113
+ })
114
+ .strict();
115
+ const dashboardWidgetSchema = z
116
+ .object({ id: z.string().min(1), name: z.string().min(1), view: componentSchema })
117
+ .strict();
118
+ const apiRouteSchema = z
119
+ .object({
120
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
121
+ path: z.string().min(1).refine((p) => p.startsWith("/"), { message: 'path must start with "/"' }),
122
+ access: z.enum(["admin", "apiKey", "public"]).optional(),
123
+ schema: z
124
+ .object({
125
+ body: zodSchemaField.optional(),
126
+ querystring: zodSchemaField.optional(),
127
+ params: zodSchemaField.optional(),
128
+ })
129
+ .strict()
130
+ .optional(),
131
+ handler: callable("handler"),
132
+ })
133
+ .strict();
134
+ const customFormatContributionSchema = z.object({
135
+ name: z.string().regex(/^[a-z0-9-]+$/),
136
+ build: callable("build"),
137
+ }).strict();
138
+ const serializerContributionSchema = z
139
+ .object({
140
+ extendContentResponse: callable("extendContentResponse").optional(),
141
+ customFormats: z.array(customFormatContributionSchema).optional(),
142
+ })
143
+ .strict()
144
+ .superRefine((value, ctx) => {
145
+ const names = (value.customFormats ?? []).map((format) => format.name);
146
+ if (new Set(names).size !== names.length) {
147
+ ctx.addIssue({ code: "custom", message: "customFormats names must be unique within a manifest" });
148
+ }
149
+ });
150
+ const scheduledTaskSchema = z
151
+ .object({
152
+ id: z.string().min(1),
153
+ name: z.string().min(1),
154
+ cron: z.string().min(1),
155
+ run: callable("run"),
156
+ })
157
+ .strict();
158
+ const mediaProcessorSchema = z
159
+ .object({
160
+ id: z.string().min(1),
161
+ name: z.string().min(1),
162
+ process: callable("process"),
163
+ })
164
+ .strict();
165
+ const settingsPageSchema = z
166
+ .object({ id: z.string().min(1), name: z.string().min(1), view: componentSchema })
167
+ .strict();
168
+ const contentTreeActionSchema = z
169
+ .object({
170
+ id: z.string().min(1),
171
+ label: z.string().min(1),
172
+ onSelect: callable("onSelect"),
173
+ })
174
+ .strict();
175
+ const editorToolbarSchema = z
176
+ .object({
177
+ id: z.string().min(1),
178
+ label: z.string().min(1),
179
+ icon: z.string().min(1),
180
+ onClick: callable("onClick"),
181
+ })
182
+ .strict();
183
+ const exposedServiceSchema = z
184
+ .object({ id: z.string().min(1), name: z.string().min(1), service: z.unknown() })
185
+ .strict();
186
+ const bundledTemplateSchema = z
187
+ .object({
188
+ name: z.string().min(1),
189
+ description: z.string().optional(),
190
+ repository: z.string().optional(),
191
+ })
192
+ .strict();
193
+ // Both the record KEY (kebab-cased into the CSS custom-property name by
194
+ // @velora-cms/design-tokens's flatten()) and the VALUE (emitted verbatim after
195
+ // the colon) end up spliced into a shared, admin-wide `[data-theme] { ... }`
196
+ // block at runtime (see theme-css.ts's themeOverridesToCss). Braces,
197
+ // semicolons, and control characters are never legitimate in a single CSS
198
+ // identifier or value, and would otherwise let an installed theme plugin
199
+ // break out of its declaration and inject arbitrary rules. This is a belt
200
+ // to theme-css.ts's own runtime filter, enforced earlier at manifest
201
+ // validation time.
202
+ const safeCssTokenSchema = z
203
+ .string()
204
+ .max(200)
205
+ .regex(new RegExp('^[^{};\\u0000-\\u001f\\u007f]+$'), "must not contain CSS-escaping characters");
206
+ // themeTokens: minimal strict mirror of ThemeTokenOverrides — only
207
+ // color/radius keys exist (themes are cosmetic by construction);
208
+ // design-tokens has no Zod and shouldn't gain one, so token values are
209
+ // validated shallowly here.
210
+ const themeTokensSchema = z
211
+ .object({
212
+ color: z.record(safeCssTokenSchema, z.record(safeCssTokenSchema, safeCssTokenSchema)).optional(),
213
+ radius: z.record(safeCssTokenSchema, safeCssTokenSchema).optional(),
214
+ })
215
+ .strict();
216
+ // --- per-type contribution surfaces (the matrix, one .strict() each) -------
217
+ const dataTypeContributionsSchema = z
218
+ .object({
219
+ dataTypes: z.array(DataTypePluginSchema).optional(),
220
+ mediaProcessors: z.array(mediaProcessorSchema).optional(),
221
+ settingsPages: z.array(settingsPageSchema).optional(),
222
+ })
223
+ .strict();
224
+ const sectionContributionsSchema = z
225
+ .object({
226
+ dataTypes: z.array(DataTypePluginSchema).optional(),
227
+ adminSections: z.array(adminSectionSchema).optional(),
228
+ dashboardWidgets: z.array(dashboardWidgetSchema).optional(),
229
+ apiRoutes: z.array(apiRouteSchema).optional(),
230
+ hooks: hooksSchema.optional(),
231
+ scheduledTasks: z.array(scheduledTaskSchema).optional(),
232
+ settingsPages: z.array(settingsPageSchema).optional(),
233
+ documentTypes: z.array(CreateDocumentTypeRequestSchema).optional(),
234
+ bundledTemplate: bundledTemplateSchema.optional(),
235
+ })
236
+ .strict()
237
+ .superRefine((value, ctx) => {
238
+ // Two apiRoutes entries sharing a (method, path) pair within one
239
+ // manifest would silently last-wins in plugin-api-routes.ts's
240
+ // dispatcher — a validation error, not a runtime surprise, same
241
+ // reasoning as document-type-api.schema.ts's duplicate-field-id check.
242
+ const seen = new Set();
243
+ (value.apiRoutes ?? []).forEach((route, index) => {
244
+ const key = `${route.method} ${route.path}`;
245
+ if (seen.has(key)) {
246
+ ctx.addIssue({
247
+ code: "custom",
248
+ message: `Duplicate apiRoute "${key}" — method+path pairs must be unique within a manifest`,
249
+ path: ["apiRoutes", index],
250
+ });
251
+ }
252
+ seen.add(key);
253
+ });
254
+ });
255
+ const templateContributionsSchema = z
256
+ .object({
257
+ documentTypes: z.array(CreateDocumentTypeRequestSchema).optional(),
258
+ })
259
+ .strict();
260
+ const themeContributionsSchema = z
261
+ .object({
262
+ themeTokens: themeTokensSchema.optional(),
263
+ })
264
+ .strict();
265
+ const integrationContributionsSchema = z
266
+ .object({
267
+ hooks: hooksSchema.optional(),
268
+ settingsPages: z.array(settingsPageSchema).optional(),
269
+ exposedServices: z.array(exposedServiceSchema).optional(),
270
+ serializers: serializerContributionSchema.optional(),
271
+ })
272
+ .strict();
273
+ const utilityContributionsSchema = z
274
+ .object({
275
+ dashboardWidgets: z.array(dashboardWidgetSchema).optional(),
276
+ hooks: hooksSchema.optional(),
277
+ scheduledTasks: z.array(scheduledTaskSchema).optional(),
278
+ mediaProcessors: z.array(mediaProcessorSchema).optional(),
279
+ settingsPages: z.array(settingsPageSchema).optional(),
280
+ contentTreeActions: z.array(contentTreeActionSchema).optional(),
281
+ editorToolbar: z.array(editorToolbarSchema).optional(),
282
+ serializers: serializerContributionSchema.optional(),
283
+ })
284
+ .strict();
285
+ const bundleContributionsSchema = z
286
+ .object({
287
+ bundledPlugins: z.array(pluginIdSchema).optional(),
288
+ bundledTemplate: bundledTemplateSchema.optional(),
289
+ })
290
+ .strict();
291
+ // --- the manifest -----------------------------------------------------------
292
+ const baseFields = {
293
+ id: pluginIdSchema,
294
+ name: z.string().min(1),
295
+ // Non-empty for now; strict semver checking belongs to
296
+ // `velora-plugin validate` (Month 7).
297
+ version: z.string().min(1),
298
+ description: z.string().min(1),
299
+ author: z.string().min(1),
300
+ license: z.string().min(1),
301
+ price: z.number().nonnegative().optional(),
302
+ // Optional by design: sideloaded/dev plugins are unsigned (Tier 2).
303
+ signature: z.string().optional(),
304
+ cmsVersion: z.string().min(1),
305
+ pluginDependencies: z.array(pluginIdSchema).optional(),
306
+ databaseCompatibility: databaseCompatibilitySchema,
307
+ permissions: permissionsSchema,
308
+ migrations: z.array(pluginMigrationSchema).optional(),
309
+ onInstall: callable("onInstall").optional(),
310
+ onUninstall: callable("onUninstall").optional(),
311
+ onActivate: callable("onActivate").optional(),
312
+ onDeactivate: callable("onDeactivate").optional(),
313
+ onUpdate: callable("onUpdate").optional(),
314
+ migrationHooks: migrationHooksSchema.optional(),
315
+ };
316
+ export const CMSPluginSchema = z.discriminatedUnion("type", [
317
+ z.object({ ...baseFields, type: z.literal("datatype"), contributions: dataTypeContributionsSchema }).strict(),
318
+ z.object({ ...baseFields, type: z.literal("section"), contributions: sectionContributionsSchema }).strict(),
319
+ z.object({ ...baseFields, type: z.literal("template"), contributions: templateContributionsSchema }).strict(),
320
+ z.object({ ...baseFields, type: z.literal("theme"), contributions: themeContributionsSchema }).strict(),
321
+ z
322
+ .object({ ...baseFields, type: z.literal("integration"), contributions: integrationContributionsSchema })
323
+ .strict(),
324
+ z.object({ ...baseFields, type: z.literal("utility"), contributions: utilityContributionsSchema }).strict(),
325
+ z.object({ ...baseFields, type: z.literal("bundle"), contributions: bundleContributionsSchema }).strict(),
326
+ ]);
327
+ // Shared by validatePluginManifest and definePlugin — one formatting
328
+ // convention for every validation surface: `path: message`.
329
+ export function formatIssues(issues) {
330
+ return issues.map((issue) => {
331
+ const path = issue.path.join(".");
332
+ return path ? `${path}: ${issue.message}` : issue.message;
333
+ });
334
+ }
335
+ /**
336
+ * Validate an untrusted value as a CMSPlugin manifest. The registry's
337
+ * install() (Session 62) and `velora-plugin validate` (Month 7) both go
338
+ * through this — errors come back as readable `path: message` strings
339
+ * (e.g. `contributions.adminSections: Unrecognized key ...` for a
340
+ * contribution-matrix violation).
341
+ */
342
+ export function validatePluginManifest(value) {
343
+ const result = CMSPluginSchema.safeParse(value);
344
+ if (result.success) {
345
+ return { ok: true, manifest: result.data };
346
+ }
347
+ return { ok: false, errors: formatIssues(result.error.issues) };
348
+ }