@manablox/api-rpc 0.2.0 → 0.3.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/dist/index.js ADDED
@@ -0,0 +1,994 @@
1
+ import { z } from "zod";
2
+ import { MIN_PASSWORD_LENGTH, actorRoles, allowedTypeIds, assertCan, effectiveGrants, normaliseGrants } from "@manablox/auth";
3
+ import { ManabloxError, TRANSPORT_CODE, WORKFLOW_CONDITION_OPERATORS, WORKFLOW_EVENTS } from "@manablox/core";
4
+ import { ORPCError, os } from "@orpc/server";
5
+ import { renderContentTypeConfig } from "@manablox/services";
6
+ //#region src/base.ts
7
+ /**
8
+ * Base procedure builder. Every management procedure runs through it, so the
9
+ * `ManabloxError` → transport mapping lives in exactly one place.
10
+ */
11
+ const base = os.$context().use(async ({ next }) => {
12
+ try {
13
+ return await next();
14
+ } catch (error) {
15
+ throw toOrpcError(error);
16
+ }
17
+ });
18
+ function toOrpcError(error) {
19
+ if (!ManabloxError.is(error)) return error;
20
+ return new ORPCError(TRANSPORT_CODE[error.kind], {
21
+ message: error.key,
22
+ data: {
23
+ key: error.key,
24
+ details: error.details
25
+ }
26
+ });
27
+ }
28
+ /** Requires an authenticated principal. */
29
+ const authed = base.use(async ({ context, next }) => {
30
+ if (!context.principal) throw toOrpcError(ManabloxError.unauthorized());
31
+ return next({ context: {
32
+ ...context,
33
+ principal: context.principal
34
+ } });
35
+ });
36
+ /**
37
+ * Requires a permission in the space named by the input's `spaceId`.
38
+ *
39
+ * Authorisation is a middleware rather than a call at the top of each handler, so a new
40
+ * procedure cannot forget it — the input type makes `spaceId` mandatory.
41
+ */
42
+ function scoped(permission) {
43
+ return authed.use(async ({ context, next }, input) => {
44
+ const raw = input;
45
+ const spaceId = raw?.spaceId ?? raw?.filter?.spaceId ?? null;
46
+ assertCan(context.principal, spaceId, permission);
47
+ return next();
48
+ });
49
+ }
50
+ /** Requires an instance-wide superadmin, for operations that are not space-scoped. */
51
+ const superadmin = authed.use(async ({ context, next }) => {
52
+ if (context.principal?.role !== "superadmin" || context.principal.allowedSpaceIds) throw toOrpcError(ManabloxError.forbidden("auth.superadminRequired"));
53
+ return next();
54
+ });
55
+ //#endregion
56
+ //#region src/schemas.ts
57
+ /** Input primitives shared by every management router. */
58
+ const uuid = z.string().uuid();
59
+ /** BCP-47-ish, the way the rest of the system stores it: `en`, `de-AT`. */
60
+ const locale = z.string().min(2).max(10);
61
+ const localeList = z.array(locale).min(1);
62
+ /**
63
+ * A technical name: lower-case, starts with a letter, may carry digits, `_` and `-`. It
64
+ * keys the GraphQL schema and the public API's space pinning, so it is stricter than a
65
+ * label.
66
+ */
67
+ const machineName = z.string().regex(/^[a-z][a-z0-9_-]*$/).max(64);
68
+ /** A role's machine name: one of the built-in five, or a role created for the space. */
69
+ const spaceRole = machineName;
70
+ const searchTerm = z.string().max(200);
71
+ /** `image/png`, or a family with a trailing slash: `image/`. */
72
+ const mimeTypePattern = z.string().regex(/^[a-z0-9-]+\/([a-z0-9.+-]+)?$/).max(100);
73
+ /** `limit`/`offset` with the caller's defaults and ceiling. */
74
+ function pagination(options) {
75
+ return z.object({
76
+ limit: z.number().int().min(1).max(options.max).default(options.limit),
77
+ offset: z.number().int().min(0).default(0)
78
+ });
79
+ }
80
+ z.object({ spaceId: uuid });
81
+ //#endregion
82
+ //#region src/routers/asset.ts
83
+ const crop = z.object({
84
+ left: z.number().int().min(0),
85
+ top: z.number().int().min(0),
86
+ width: z.number().int().min(1),
87
+ height: z.number().int().min(1)
88
+ });
89
+ const focalPoint = z.object({
90
+ x: z.number().min(0).max(1),
91
+ y: z.number().min(0).max(1)
92
+ });
93
+ const assetRouter = {
94
+ /** What an upload into this space is held to, and the instance's ceiling above it. */
95
+ limits: scoped("asset:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.media.limits(input.spaceId)),
96
+ list: scoped("asset:read").input(pagination({
97
+ limit: 40,
98
+ max: 100
99
+ }).extend({
100
+ spaceId: uuid,
101
+ mimeType: z.string().optional(),
102
+ search: searchTerm.optional()
103
+ })).handler(async ({ input, context }) => {
104
+ const page = await context.repos.assets.list({
105
+ spaceId: input.spaceId,
106
+ ...input.mimeType ? { mimeType: input.mimeType } : {},
107
+ ...input.search ? { search: input.search } : {}
108
+ }, {
109
+ limit: input.limit,
110
+ offset: input.offset
111
+ });
112
+ return {
113
+ ...page,
114
+ items: page.items.map((asset) => context.media.present(asset))
115
+ };
116
+ }),
117
+ /** Several assets in one round trip, for a field that references them; missing ids are absent. */
118
+ getMany: scoped("asset:read").input(z.object({
119
+ spaceId: uuid,
120
+ ids: z.array(uuid).max(200)
121
+ })).handler(async ({ input, context }) => {
122
+ return (await context.repos.assets.findManyByIds(input.ids, input.spaceId)).map((asset) => context.media.present(asset));
123
+ }),
124
+ get: scoped("asset:read").input(z.object({
125
+ spaceId: uuid,
126
+ id: uuid
127
+ })).handler(async ({ input, context }) => {
128
+ const asset = await context.repos.assets.findById(input.id);
129
+ return asset ? context.media.present(asset) : null;
130
+ }),
131
+ update: scoped("asset:write").input(z.object({
132
+ spaceId: uuid,
133
+ id: uuid,
134
+ name: z.string().max(200).optional(),
135
+ alt: z.string().max(500).nullable().optional(),
136
+ title: z.string().max(500).nullable().optional()
137
+ })).handler(async ({ input, context }) => {
138
+ const { spaceId: _spaceId, id, ...data } = input;
139
+ return context.repos.assets.update(id, data);
140
+ }),
141
+ /**
142
+ * An image's crop and focal point. `null` clears one; omitting it also clears it, so
143
+ * the call always states the whole edit. Every variant re-renders through the result.
144
+ */
145
+ setImageEdits: scoped("asset:write").input(z.object({
146
+ spaceId: uuid,
147
+ id: uuid,
148
+ crop: crop.nullable().optional(),
149
+ focalPoint: focalPoint.nullable().optional()
150
+ })).handler(async ({ input, context }) => {
151
+ const asset = await context.media.setImageEdits(input.id, {
152
+ crop: input.crop ?? null,
153
+ focalPoint: input.focalPoint ?? null
154
+ });
155
+ return context.media.present(asset);
156
+ }),
157
+ delete: scoped("asset:delete").input(z.object({
158
+ spaceId: uuid,
159
+ id: uuid
160
+ })).handler(async ({ input, context }) => {
161
+ await context.media.delete(input.id);
162
+ return { ok: true };
163
+ })
164
+ };
165
+ //#endregion
166
+ //#region src/routers/content.ts
167
+ const filterSchema = z.object({
168
+ spaceId: uuid,
169
+ /** Specific documents, for a relation field's chips: one request instead of one per id. */
170
+ ids: z.array(uuid).max(200).optional(),
171
+ typeIds: z.array(uuid).optional(),
172
+ locale: locale.optional(),
173
+ status: z.enum([
174
+ "draft",
175
+ "published",
176
+ "archived"
177
+ ]).optional(),
178
+ parentId: uuid.nullable().optional(),
179
+ under: uuid.optional(),
180
+ search: searchTerm.optional(),
181
+ fields: z.array(z.object({
182
+ name: z.string(),
183
+ op: z.enum([
184
+ "eq",
185
+ "neq",
186
+ "lt",
187
+ "lte",
188
+ "gt",
189
+ "gte",
190
+ "in",
191
+ "notIn",
192
+ "contains",
193
+ "startsWith",
194
+ "endsWith",
195
+ "isNull",
196
+ "isNotNull"
197
+ ]),
198
+ value: z.unknown().optional()
199
+ })).max(10).optional()
200
+ });
201
+ const paginationSchema = pagination({
202
+ limit: 25,
203
+ max: 200
204
+ });
205
+ const sortSchema = z.array(z.object({
206
+ by: z.enum([
207
+ "position",
208
+ "title",
209
+ "createdAt",
210
+ "updatedAt",
211
+ "publishedAt",
212
+ "slug"
213
+ ]),
214
+ direction: z.enum(["asc", "desc"]).default("asc")
215
+ })).max(3);
216
+ const saveSchema = z.object({
217
+ spaceId: uuid,
218
+ typeId: uuid,
219
+ locale: locale.default("en"),
220
+ localizationId: uuid.optional(),
221
+ parentId: uuid.nullable().optional(),
222
+ title: z.string().min(1).max(500),
223
+ slug: z.string().max(200).optional(),
224
+ fields: z.record(z.string(), z.unknown()).default({}),
225
+ position: z.number().int().optional(),
226
+ expectedVersion: z.number().int().positive().optional()
227
+ });
228
+ const contentRouter = {
229
+ list: scoped("content:read").input(z.object({
230
+ filter: filterSchema,
231
+ pagination: paginationSchema.optional(),
232
+ sort: sortSchema.optional()
233
+ }).transform((v) => ({
234
+ ...v,
235
+ spaceId: v.filter.spaceId
236
+ }))).handler(async ({ input, context }) => {
237
+ const filter = narrowToAllowed(context, input.filter);
238
+ if (!filter) return {
239
+ items: [],
240
+ total: 0,
241
+ ...input.pagination ?? {
242
+ limit: 25,
243
+ offset: 0
244
+ }
245
+ };
246
+ return context.content.list(filter, input.pagination ?? {
247
+ limit: 25,
248
+ offset: 0
249
+ }, input.sort ?? [], { actor: toActor(context, input.filter.spaceId) });
250
+ }),
251
+ tree: scoped("content:read").input(z.object({
252
+ spaceId: uuid,
253
+ locale: locale.default("en"),
254
+ rootId: uuid.nullable().default(null)
255
+ })).handler(async ({ input, context }) => context.content.tree(input.spaceId, input.locale, input.rootId)),
256
+ get: scoped("content:read").input(z.object({
257
+ spaceId: uuid,
258
+ id: uuid
259
+ })).handler(async ({ input, context }) => {
260
+ await assertOnDocument(context, input.spaceId, "content:read", input.id);
261
+ return context.content.get(input.id, { actor: toActor(context, input.spaceId) });
262
+ }),
263
+ /** Field values with defaults filled in — what the editor opens a new document with. */
264
+ blank: scoped("content:read").input(z.object({
265
+ spaceId: uuid,
266
+ typeId: uuid
267
+ })).handler(async ({ input, context }) => {
268
+ assertOnType(context, input.spaceId, "content:read", input.typeId);
269
+ return { fields: await context.content.initFields(context.manablox.contentTypes.get(input.typeId)) };
270
+ }),
271
+ create: scoped("content:write").input(saveSchema).handler(async ({ input, context }) => {
272
+ assertOnType(context, input.spaceId, "content:write", input.typeId);
273
+ return context.content.create(input, toActor(context, input.spaceId));
274
+ }),
275
+ update: scoped("content:write").input(saveSchema.extend({ id: uuid })).handler(async ({ input, context }) => {
276
+ assertOnType(context, input.spaceId, "content:write", input.typeId);
277
+ return context.content.update(input.id, input, toActor(context, input.spaceId));
278
+ }),
279
+ delete: scoped("content:delete").input(z.object({
280
+ spaceId: uuid,
281
+ id: uuid
282
+ })).handler(async ({ input, context }) => {
283
+ await assertOnDocument(context, input.spaceId, "content:delete", input.id);
284
+ return { deleted: await context.content.delete(input.id, toActor(context, input.spaceId)) };
285
+ }),
286
+ publish: scoped("content:publish").input(z.object({
287
+ spaceId: uuid,
288
+ id: uuid
289
+ })).handler(async ({ input, context }) => {
290
+ await assertOnDocument(context, input.spaceId, "content:publish", input.id);
291
+ return context.content.publish(input.id, toActor(context, input.spaceId));
292
+ }),
293
+ unpublish: scoped("content:publish").input(z.object({
294
+ spaceId: uuid,
295
+ id: uuid
296
+ })).handler(async ({ input, context }) => {
297
+ await assertOnDocument(context, input.spaceId, "content:publish", input.id);
298
+ await context.content.unpublish(input.id, toActor(context, input.spaceId));
299
+ return { ok: true };
300
+ }),
301
+ /** Reparent or reorder a document in the tree — a drag in the admin's tree panel. */
302
+ move: scoped("content:write").input(z.object({
303
+ spaceId: uuid,
304
+ id: uuid,
305
+ parentId: uuid.nullable(),
306
+ position: z.number().int().min(0)
307
+ })).handler(async ({ input, context }) => {
308
+ await assertOnDocument(context, input.spaceId, "content:write", input.id);
309
+ return context.content.move(input.spaceId, input.id, input.parentId, input.position);
310
+ }),
311
+ /** Every locale a document exists in, for the editor's language switcher. */
312
+ translations: scoped("content:read").input(z.object({
313
+ spaceId: uuid,
314
+ id: uuid
315
+ })).handler(async ({ input, context }) => context.content.translations(input.spaceId, input.id)),
316
+ /** Starts a translation of an existing document, in the localization group it shares. */
317
+ createTranslation: scoped("content:write").input(z.object({
318
+ spaceId: uuid,
319
+ id: uuid,
320
+ locale
321
+ })).handler(async ({ input, context }) => {
322
+ await assertOnDocument(context, input.spaceId, "content:write", input.id);
323
+ return context.content.createTranslation(input.spaceId, input.id, input.locale, toActor(context, input.spaceId));
324
+ }),
325
+ versions: scoped("content:read").input(z.object({
326
+ spaceId: uuid,
327
+ id: uuid
328
+ })).handler(async ({ input, context }) => context.repos.content.versions(input.id)),
329
+ versionSnapshot: scoped("content:read").input(z.object({
330
+ spaceId: uuid,
331
+ id: uuid,
332
+ version: z.number().int().positive()
333
+ })).handler(async ({ input, context }) => context.repos.content.versionSnapshot(input.id, input.version)),
334
+ restore: scoped("content:write").input(z.object({
335
+ spaceId: uuid,
336
+ id: uuid,
337
+ version: z.number().int().positive()
338
+ })).handler(async ({ input, context }) => {
339
+ await assertOnDocument(context, input.spaceId, "content:write", input.id);
340
+ return context.content.restore(input.id, input.version, toActor(context, input.spaceId));
341
+ })
342
+ };
343
+ function assertOnType(context, spaceId, permission, typeId) {
344
+ try {
345
+ assertCan(context.principal, spaceId, permission, typeId);
346
+ } catch (error) {
347
+ throw toOrpcError(error);
348
+ }
349
+ }
350
+ async function assertOnDocument(context, spaceId, permission, id) {
351
+ if (allowedTypeIds(context.principal, spaceId, permission) === null) return;
352
+ const row = await context.repos.content.findById(id);
353
+ if (!row || row.spaceId !== spaceId) throw toOrpcError(ManabloxError.notFound("content.notFound", { id }));
354
+ assertOnType(context, spaceId, permission, row.typeId);
355
+ }
356
+ /** The filter narrowed to the types the caller may read; `null` when that leaves none. */
357
+ function narrowToAllowed(context, filter) {
358
+ const allowed = allowedTypeIds(context.principal, filter.spaceId, "content:read");
359
+ if (allowed === null) return filter;
360
+ const typeIds = filter.typeIds?.length ? filter.typeIds.filter((typeId) => allowed.includes(typeId)) : allowed;
361
+ return typeIds.length ? {
362
+ ...filter,
363
+ typeIds
364
+ } : null;
365
+ }
366
+ function toActor(context, spaceId) {
367
+ const principal = context.principal;
368
+ if (!principal) return null;
369
+ return {
370
+ userId: principal.userId,
371
+ roles: actorRoles(principal, spaceId)
372
+ };
373
+ }
374
+ //#endregion
375
+ //#region src/routers/content-type.ts
376
+ const fieldSchema = z.object({
377
+ id: z.string().optional(),
378
+ name: z.string().min(1).max(64),
379
+ label: z.string().optional(),
380
+ type: z.string(),
381
+ settings: z.record(z.string(), z.unknown()).default({}),
382
+ required: z.boolean().default(false),
383
+ localized: z.boolean().default(false),
384
+ unique: z.boolean().default(false),
385
+ readRoles: z.array(z.string()).optional(),
386
+ writeRoles: z.array(z.string()).optional(),
387
+ admin: z.object({
388
+ zone: z.enum(["main", "sidebar"]).default("main"),
389
+ width: z.number().int().min(25).max(100).default(100),
390
+ position: z.number().int().default(0),
391
+ help: z.string().optional(),
392
+ placeholder: z.string().optional()
393
+ }).optional()
394
+ });
395
+ const contentTypeSchema = z.object({
396
+ name: z.string().min(1).max(64),
397
+ label: z.string().optional(),
398
+ description: z.string().optional(),
399
+ icon: z.string().optional(),
400
+ kind: z.enum(["content", "block"]).default("content"),
401
+ spaceId: uuid.nullable().default(null),
402
+ hasSlug: z.boolean().optional(),
403
+ isPublishable: z.boolean().optional(),
404
+ isVisibleInTree: z.boolean().optional(),
405
+ canBeVisibleInMenu: z.boolean().optional(),
406
+ fields: z.array(fieldSchema).default([])
407
+ });
408
+ const contentTypeRouter = {
409
+ list: scoped("contentType:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.contentTypes.list(input.spaceId)),
410
+ get: scoped("contentType:read").input(z.object({
411
+ spaceId: uuid,
412
+ id: uuid
413
+ })).handler(async ({ input, context }) => context.contentTypes.get(input.id)),
414
+ create: scoped("contentType:write").input(contentTypeSchema.extend({ spaceId: uuid })).handler(async ({ input, context }) => context.contentTypes.create(input, context.principal.userId)),
415
+ update: scoped("contentType:write").input(contentTypeSchema.extend({
416
+ spaceId: uuid,
417
+ id: uuid
418
+ })).handler(async ({ input, context }) => context.contentTypes.update(input.id, input)),
419
+ delete: scoped("contentType:delete").input(z.object({
420
+ spaceId: uuid,
421
+ id: uuid
422
+ })).handler(async ({ input, context }) => {
423
+ await context.contentTypes.delete(input.id);
424
+ return { ok: true };
425
+ }),
426
+ /**
427
+ * The field-type catalogue the admin's "add field" menu is built from. Derived from
428
+ * the registry, so a plugin's field type appears in the menu with no admin change.
429
+ */
430
+ fieldTypes: base.handler(async ({ context }) => context.manablox.fieldTypes.all.map((type) => ({
431
+ name: type.name,
432
+ label: type.label,
433
+ icon: type.icon ?? null,
434
+ description: type.description ?? null,
435
+ nested: type.nested ?? false,
436
+ filters: type.filters,
437
+ admin: type.admin
438
+ }))),
439
+ /** JSON Schema for one field type's settings, so the admin renders its form generically. */
440
+ fieldTypeSettingsSchema: base.input(z.object({ name: z.string() })).handler(async ({ input, context }) => {
441
+ const type = context.manablox.fieldTypes.get(input.name);
442
+ const schema = type.settingsSchema;
443
+ return {
444
+ name: type.name,
445
+ jsonSchema: typeof schema.toJSONSchema === "function" ? schema.toJSONSchema() : null
446
+ };
447
+ }),
448
+ /**
449
+ * The space's runtime types rendered as `manablox.config.ts` source, for moving a type
450
+ * built in the admin into code where it can be reviewed and versioned.
451
+ */
452
+ config: scoped("contentType:read").input(z.object({
453
+ spaceId: uuid,
454
+ ids: z.array(uuid).optional()
455
+ })).handler(async ({ input, context }) => {
456
+ const wanted = input.ids?.length ? new Set(input.ids) : null;
457
+ const types = context.manablox.contentTypes.forSpace(input.spaceId).filter((type) => type.source !== "code" && (!wanted || wanted.has(type.id)));
458
+ return {
459
+ code: renderContentTypeConfig(types),
460
+ count: types.length
461
+ };
462
+ }),
463
+ reload: superadmin.handler(async ({ context }) => {
464
+ await context.manablox.reload(await context.repos.contentTypes.all());
465
+ return { schemaVersion: context.manablox.contentTypes.schemaVersion };
466
+ })
467
+ };
468
+ //#endregion
469
+ //#region src/routers/menu.ts
470
+ const menuSchema = z.object({
471
+ name: z.string().min(1).max(200),
472
+ machineName,
473
+ description: z.string().max(2e3).nullable().optional()
474
+ });
475
+ const menuItemSchema = z.lazy(() => z.object({
476
+ id: uuid.optional(),
477
+ localizationId: uuid.nullable().optional(),
478
+ label: z.string().max(200).nullable().optional(),
479
+ url: z.string().max(2e3).nullable().optional(),
480
+ children: z.array(menuItemSchema).max(500).optional()
481
+ }));
482
+ /**
483
+ * Menus. Each rule — the unique machine name, what an entry may point at — lives in
484
+ * `MenuService`; a procedure here is an input schema, a permission and one call.
485
+ */
486
+ const menuRouter = {
487
+ list: scoped("menu:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.menus.list(input.spaceId)),
488
+ /** The menu with its entries, each content entry resolved to the document in `locale`. */
489
+ get: scoped("menu:read").input(z.object({
490
+ spaceId: uuid,
491
+ id: uuid,
492
+ locale
493
+ })).handler(async ({ input, context }) => context.menus.get(input.spaceId, input.id, input.locale)),
494
+ create: scoped("menu:write").input(menuSchema.extend({ spaceId: uuid })).handler(async ({ input, context }) => context.menus.create(input)),
495
+ update: scoped("menu:write").input(menuSchema.partial().extend({
496
+ spaceId: uuid,
497
+ id: uuid
498
+ })).handler(async ({ input, context }) => {
499
+ const { spaceId, id, ...data } = input;
500
+ return context.menus.update(spaceId, id, data);
501
+ }),
502
+ delete: scoped("menu:write").input(z.object({
503
+ spaceId: uuid,
504
+ id: uuid
505
+ })).handler(async ({ input, context }) => {
506
+ await context.menus.delete(input.spaceId, input.id);
507
+ return { ok: true };
508
+ }),
509
+ /** Replaces the whole entry tree; the editor saves a menu as one document. */
510
+ setItems: scoped("menu:write").input(z.object({
511
+ spaceId: uuid,
512
+ id: uuid,
513
+ items: z.array(menuItemSchema).max(500)
514
+ })).handler(async ({ input, context }) => context.menus.setItems(input.spaceId, input.id, input.items)),
515
+ /** Menus a document is linked from, for the editor's hint. */
516
+ usedIn: scoped("menu:read").input(z.object({
517
+ spaceId: uuid,
518
+ localizationId: uuid
519
+ })).handler(async ({ input, context }) => context.menus.usedIn(input.spaceId, input.localizationId))
520
+ };
521
+ //#endregion
522
+ //#region src/routers/role.ts
523
+ const roleSchema = z.object({
524
+ spaceId: uuid,
525
+ name: z.string().trim().min(1).max(100),
526
+ machineName,
527
+ description: z.string().max(500).nullable().optional(),
528
+ /** `space:write`, `content:read` for every type, `content:read:<typeId>` for one. */
529
+ permissions: z.array(z.string().max(120)).max(500)
530
+ });
531
+ /**
532
+ * The roles of a space. The rules — reserved names, grants that exist, a role nobody
533
+ * holds before it goes — live in `RoleService`; a procedure here is an input schema, a
534
+ * permission and one call.
535
+ */
536
+ const roleRouter = {
537
+ /** The permission catalogue, grouped the way the role editor lays it out. */
538
+ catalog: authed.handler(async ({ context }) => context.roles.catalog()),
539
+ list: scoped("role:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.roles.list(input.spaceId)),
540
+ get: scoped("role:read").input(z.object({
541
+ spaceId: uuid,
542
+ id: uuid
543
+ })).handler(async ({ input, context }) => context.roles.get(input.spaceId, input.id)),
544
+ create: scoped("role:write").input(roleSchema).handler(async ({ input, context }) => {
545
+ const { spaceId, ...data } = input;
546
+ return context.roles.create(spaceId, data);
547
+ }),
548
+ update: scoped("role:write").input(roleSchema.extend({ id: uuid })).handler(async ({ input, context }) => {
549
+ const { spaceId, id, ...data } = input;
550
+ return context.roles.update(spaceId, id, data);
551
+ }),
552
+ delete: scoped("role:write").input(z.object({
553
+ spaceId: uuid,
554
+ id: uuid
555
+ })).handler(async ({ input, context }) => {
556
+ await context.roles.delete(input.spaceId, input.id);
557
+ return { ok: true };
558
+ })
559
+ };
560
+ //#endregion
561
+ //#region src/routers/space.ts
562
+ const spaceSchema = z.object({
563
+ name: z.string().min(1).max(200),
564
+ machineName,
565
+ description: z.string().nullable().optional(),
566
+ url: z.string().url(),
567
+ defaultLocale: locale.default("en"),
568
+ locales: localeList.default(["en"]),
569
+ settings: z.record(z.string(), z.unknown()).optional()
570
+ });
571
+ /**
572
+ * Spaces and membership. Every rule — the locale invariant, the last-owner guard, the
573
+ * creator-owns-it grant — lives in `SpaceService`; a procedure here is an input schema,
574
+ * a permission and one call.
575
+ */
576
+ const spaceRouter = {
577
+ /**
578
+ * Only the spaces the caller is a member of — a superadmin sees all. A member's query
579
+ * is bounded by their memberships rather than by the instance, so a large multi-tenant
580
+ * install does not load every space to show someone their two.
581
+ */
582
+ list: authed.handler(async ({ context }) => {
583
+ const { principal } = context;
584
+ const all = principal.role === "superadmin" ? await context.repos.spaces.all() : await context.repos.spaces.findManyByIds(Object.keys(principal.spaces));
585
+ const allowed = principal.allowedSpaceIds;
586
+ return allowed ? all.filter((space) => allowed.includes(space.id)) : all;
587
+ }),
588
+ get: scoped("space:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.repos.spaces.findById(input.spaceId)),
589
+ create: superadmin.input(spaceSchema).handler(async ({ input, context }) => context.spaces.create(input, context.principal?.userId ?? null)),
590
+ update: scoped("space:write").input(spaceSchema.partial().extend({ spaceId: uuid })).handler(async ({ input, context }) => {
591
+ const { spaceId, ...data } = input;
592
+ return context.spaces.update(spaceId, data);
593
+ }),
594
+ delete: scoped("space:delete").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => {
595
+ await context.spaces.delete(input.spaceId);
596
+ return { ok: true };
597
+ }),
598
+ /** Nominates one document as the space's root; `null` clears it. */
599
+ setHome: scoped("space:write").input(z.object({
600
+ spaceId: uuid,
601
+ contentId: uuid.nullable()
602
+ })).handler(async ({ input, context }) => context.spaces.setHome(input.spaceId, input.contentId)),
603
+ /**
604
+ * The space's upload limits, each narrowing the instance's. `allowedMimeTypes` absent
605
+ * means the instance's list; empty means the same thing rather than "nothing".
606
+ */
607
+ setAssetSettings: scoped("space:write").input(z.object({
608
+ spaceId: uuid,
609
+ allowedMimeTypes: z.array(mimeTypePattern).max(50).optional(),
610
+ maxFileSize: z.number().int().positive().nullable().optional()
611
+ })).handler(async ({ input, context }) => context.spaces.setAssetSettings(input.spaceId, {
612
+ ...input.allowedMimeTypes?.length ? { allowedMimeTypes: input.allowedMimeTypes } : {},
613
+ ...input.maxFileSize ? { maxFileSize: input.maxFileSize } : {}
614
+ })),
615
+ /**
616
+ * The whole space as one JSON document. `space:write` rather than `space:read` because
617
+ * an export is every field of every document in one file, regardless of who may read
618
+ * what.
619
+ */
620
+ export: scoped("space:write").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.spaces.export(input.spaceId)),
621
+ /** Restores such a document into an instance that does not hold the space yet. Superadmin, because it creates a space. */
622
+ import: superadmin.input(z.object({ payload: z.unknown() })).handler(async ({ input, context }) => context.spaces.import(input.payload, context.principal.userId)),
623
+ members: scoped("user:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.spaces.members(input.spaceId)),
624
+ grant: scoped("user:write").input(z.object({
625
+ spaceId: uuid,
626
+ userId: uuid,
627
+ role: spaceRole
628
+ })).handler(async ({ input, context }) => {
629
+ await context.spaces.grant(input.spaceId, input.userId, input.role);
630
+ return { ok: true };
631
+ }),
632
+ /** Users who are not yet members, for the add-member picker. */
633
+ candidates: scoped("user:write").input(z.object({
634
+ spaceId: uuid,
635
+ search: searchTerm.optional()
636
+ })).handler(async ({ input, context }) => context.spaces.candidates(input.spaceId, input.search)),
637
+ /** Grants the same role to several users at once, as the picker hands them over. */
638
+ addMembers: scoped("user:write").input(z.object({
639
+ spaceId: uuid,
640
+ userIds: z.array(uuid).min(1).max(100),
641
+ role: spaceRole.default("editor")
642
+ })).handler(async ({ input, context }) => ({
643
+ ok: true,
644
+ added: await context.spaces.addMembers(input.spaceId, input.userIds, input.role)
645
+ })),
646
+ revoke: scoped("user:write").input(z.object({
647
+ spaceId: uuid,
648
+ userId: uuid
649
+ })).handler(async ({ input, context }) => {
650
+ await context.spaces.revoke(input.spaceId, input.userId);
651
+ return { ok: true };
652
+ }),
653
+ /** Locales available for a space, for the editor's language switcher. */
654
+ locales: base.input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.spaces.locales(input.spaceId))
655
+ };
656
+ //#endregion
657
+ //#region src/routers/user.ts
658
+ const instanceRole = z.enum(["superadmin", "editor"]);
659
+ const password = z.string().min(MIN_PASSWORD_LENGTH).max(200);
660
+ const email = z.string().email().max(320);
661
+ const displayName = z.string().trim().min(1).max(200);
662
+ /**
663
+ * The caller's own account and keys, and — for a superadmin — every account on the
664
+ * instance. The rules (no locking yourself out, one superadmin always remains) live in
665
+ * `UserService`; a procedure here is an input schema, a permission and one call.
666
+ */
667
+ const userRouter = {
668
+ me: authed.handler(async ({ context }) => {
669
+ const user = await context.repos.users.findById(context.principal.userId);
670
+ return user ? {
671
+ id: user.id,
672
+ email: user.email,
673
+ name: user.name,
674
+ image: user.image,
675
+ role: user.role,
676
+ spaces: context.principal.spaces,
677
+ permissions: Object.fromEntries(Object.keys(context.principal.spaces).map((spaceId) => [spaceId, effectiveGrants(context.principal, spaceId)]))
678
+ } : null;
679
+ }),
680
+ /**
681
+ * Whether the instance still has no account at all. Public, because the login page
682
+ * needs it before anyone is signed in: it decides whether to offer "create the first
683
+ * account". Once one exists, sign-up is closed and every account is created here.
684
+ */
685
+ setupNeeded: base.handler(async ({ context }) => ({ setupNeeded: await context.repos.users.count() === 0 })),
686
+ list: superadmin.input(pagination({
687
+ limit: 25,
688
+ max: 100
689
+ }).extend({ search: searchTerm.optional() })).handler(async ({ input, context }) => context.users.list({
690
+ limit: input.limit,
691
+ offset: input.offset
692
+ }, input.search)),
693
+ get: superadmin.input(z.object({ userId: uuid })).handler(async ({ input, context }) => context.users.get(input.userId)),
694
+ create: superadmin.input(z.object({
695
+ name: displayName,
696
+ email,
697
+ password,
698
+ role: instanceRole.default("editor")
699
+ })).handler(async ({ input, context }) => context.users.create(input)),
700
+ update: superadmin.input(z.object({
701
+ userId: uuid,
702
+ name: displayName.optional(),
703
+ email: email.optional()
704
+ })).handler(async ({ input, context }) => {
705
+ const { userId, ...data } = input;
706
+ return context.users.update(userId, data);
707
+ }),
708
+ setRole: superadmin.input(z.object({
709
+ userId: uuid,
710
+ role: instanceRole
711
+ })).handler(async ({ input, context }) => context.users.setRole(input.userId, input.role)),
712
+ /** Resets a password and signs the account out everywhere. */
713
+ setPassword: superadmin.input(z.object({
714
+ userId: uuid,
715
+ password
716
+ })).handler(async ({ input, context }) => {
717
+ await context.users.setPassword(input.userId, input.password);
718
+ return { ok: true };
719
+ }),
720
+ ban: superadmin.input(z.object({
721
+ userId: uuid,
722
+ reason: z.string().trim().max(500).optional()
723
+ })).handler(async ({ input, context }) => context.users.ban(context.principal.userId, input.userId, input.reason || null)),
724
+ unban: superadmin.input(z.object({ userId: uuid })).handler(async ({ input, context }) => context.users.unban(input.userId)),
725
+ revokeSessions: superadmin.input(z.object({ userId: uuid })).handler(async ({ input, context }) => {
726
+ await context.users.revokeSessions(input.userId);
727
+ return { ok: true };
728
+ }),
729
+ delete: superadmin.input(z.object({ userId: uuid })).handler(async ({ input, context }) => {
730
+ await context.users.delete(context.principal.userId, input.userId);
731
+ return { ok: true };
732
+ }),
733
+ apiKeys: authed.handler(async ({ context }) => context.apiKeys.list(context.principal.userId)),
734
+ issueApiKey: authed.input(z.object({
735
+ name: z.string().min(1).max(100),
736
+ expiresAt: z.coerce.date().optional(),
737
+ /** Empty or omitted issues an unrestricted key. */
738
+ spaceIds: z.array(uuid).optional(),
739
+ /**
740
+ * Grants the key is confined to, in the roles' vocabulary; omitted leaves the
741
+ * owner's role as the limit. At use the two are intersected, so a grant here
742
+ * never widens the key beyond its owner.
743
+ */
744
+ permissions: z.array(z.string().max(120)).max(500).optional()
745
+ })).handler(async ({ input, context }) => {
746
+ for (const spaceId of input.spaceIds ?? []) assertCan(context.principal, spaceId, "space:read");
747
+ const reachable = input.spaceIds?.length ? input.spaceIds : context.principal.role === "superadmin" ? null : Object.keys(context.principal.spaces);
748
+ const typeIds = new Set((reachable ? reachable.flatMap((spaceId) => context.manablox.contentTypes.forSpace(spaceId)) : context.manablox.contentTypes.all).map((type) => type.id));
749
+ let permissions = null;
750
+ if (input.permissions) {
751
+ const checked = normaliseGrants(input.permissions, typeIds);
752
+ if (checked.unknown.length) throw ManabloxError.validation(checked.unknown.map(({ index, grant }) => ({
753
+ key: "role.permission.unknown",
754
+ path: ["permissions", index],
755
+ params: { permission: grant }
756
+ })), "apiKey.validation.failed");
757
+ permissions = checked.permissions;
758
+ }
759
+ return context.apiKeys.issue(context.principal.userId, input.name, {
760
+ expiresAt: input.expiresAt,
761
+ spaceIds: input.spaceIds,
762
+ permissions
763
+ });
764
+ }),
765
+ revokeApiKey: authed.input(z.object({ id: uuid })).handler(async ({ input, context }) => {
766
+ if (!(await context.apiKeys.list(context.principal.userId)).some((key) => key.id === input.id)) return { ok: false };
767
+ await context.apiKeys.revoke(input.id);
768
+ return { ok: true };
769
+ })
770
+ };
771
+ //#endregion
772
+ //#region src/routers/workflow.ts
773
+ const template = z.string().max(2e4);
774
+ const eventTrigger = z.object({
775
+ kind: z.literal("event"),
776
+ events: z.array(z.enum(WORKFLOW_EVENTS)).max(10),
777
+ typeIds: z.array(z.string().max(120)).max(200).default([]),
778
+ locales: z.array(z.string().max(10)).max(50).default([])
779
+ });
780
+ const selection = z.object({
781
+ typeIds: z.array(z.string().max(120)).max(200).default([]),
782
+ status: z.enum([
783
+ "any",
784
+ "draft",
785
+ "published"
786
+ ]).default("any"),
787
+ changedWithinHours: z.number().int().min(1).max(8760).nullable().default(null),
788
+ locale: z.string().max(10).nullable().default(null)
789
+ });
790
+ const scheduleTrigger = z.object({
791
+ kind: z.literal("schedule"),
792
+ cron: z.string().max(100),
793
+ timezone: z.string().max(60).default("UTC"),
794
+ selection: selection.nullable().default(null),
795
+ perDocument: z.boolean().default(false)
796
+ });
797
+ const stepBase = {
798
+ id: z.string().max(64).default(""),
799
+ name: z.string().max(200).default(""),
800
+ enabled: z.boolean().default(true),
801
+ continueOnError: z.boolean().default(false)
802
+ };
803
+ const emailStep = z.object({
804
+ ...stepBase,
805
+ type: z.literal("email"),
806
+ to: z.array(z.string().max(500)).max(50).default([]),
807
+ toRoles: z.array(z.string().max(64)).max(50).default([]),
808
+ subject: template,
809
+ body: template,
810
+ html: z.boolean().default(false)
811
+ });
812
+ const httpStep = z.object({
813
+ ...stepBase,
814
+ type: z.literal("http"),
815
+ method: z.enum([
816
+ "GET",
817
+ "POST",
818
+ "PUT",
819
+ "PATCH",
820
+ "DELETE"
821
+ ]).default("POST"),
822
+ url: z.string().max(2e3),
823
+ headers: z.array(z.object({
824
+ name: z.string().max(200),
825
+ value: z.string().max(4e3)
826
+ })).max(50).default([]),
827
+ body: z.object({
828
+ mode: z.enum([
829
+ "event",
830
+ "custom",
831
+ "none"
832
+ ]).default("event"),
833
+ template: template.default("")
834
+ }).default({
835
+ mode: "event",
836
+ template: ""
837
+ }),
838
+ secret: z.string().max(500).nullable().default(null),
839
+ timeoutMs: z.number().int().min(1e3).max(12e4).default(1e4)
840
+ });
841
+ const pushStep = z.object({
842
+ ...stepBase,
843
+ type: z.literal("push"),
844
+ roles: z.array(z.string().max(64)).max(50).default([]),
845
+ userIds: z.array(uuid).max(200).default([]),
846
+ title: template,
847
+ body: template.default(""),
848
+ url: z.string().max(2e3).default("")
849
+ });
850
+ const rules = z.array(z.object({
851
+ field: z.string().max(300),
852
+ operator: z.enum(WORKFLOW_CONDITION_OPERATORS),
853
+ value: z.string().max(4e3).default("")
854
+ })).max(50);
855
+ const conditionStep = z.object({
856
+ ...stepBase,
857
+ type: z.literal("condition"),
858
+ match: z.enum(["all", "any"]).default("all"),
859
+ rules
860
+ });
861
+ const delayStep = z.object({
862
+ ...stepBase,
863
+ type: z.literal("delay"),
864
+ minutes: z.number().min(1).max(43200)
865
+ });
866
+ /** A fork carries two chains of its own, so the step schema refers to itself (Zod 4 getters). */
867
+ const branchStep = z.object({
868
+ ...stepBase,
869
+ type: z.literal("branch"),
870
+ match: z.enum(["all", "any"]).default("all"),
871
+ rules,
872
+ get then() {
873
+ return z.array(step).max(50).default([]);
874
+ },
875
+ get else() {
876
+ return z.array(step).max(50).default([]);
877
+ }
878
+ });
879
+ const step = z.discriminatedUnion("type", [
880
+ emailStep,
881
+ httpStep,
882
+ pushStep,
883
+ conditionStep,
884
+ branchStep,
885
+ delayStep
886
+ ]);
887
+ const workflowSchema = z.object({
888
+ spaceId: uuid,
889
+ name: z.string().max(200),
890
+ description: z.string().max(2e3).nullable().optional(),
891
+ enabled: z.boolean().optional(),
892
+ trigger: z.discriminatedUnion("kind", [eventTrigger, scheduleTrigger]),
893
+ steps: z.array(step).max(50)
894
+ });
895
+ const pushSubscription = z.object({
896
+ endpoint: z.string().max(4e3),
897
+ keys: z.object({
898
+ p256dh: z.string().max(500),
899
+ auth: z.string().max(500)
900
+ })
901
+ });
902
+ /**
903
+ * Workflows. The rules — a cron that parses, a step with somewhere to go — live in
904
+ * `WorkflowService`; a procedure here is an input schema, a permission and one call.
905
+ */
906
+ const workflowRouter = {
907
+ /** The events, step types and operators the editor offers, and what the instance can send. */
908
+ catalog: authed.handler(async ({ context }) => context.workflows.catalog()),
909
+ list: scoped("workflow:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.workflows.list(input.spaceId)),
910
+ get: scoped("workflow:read").input(z.object({
911
+ spaceId: uuid,
912
+ id: uuid
913
+ })).handler(async ({ input, context }) => context.workflows.get(input.spaceId, input.id)),
914
+ create: scoped("workflow:write").input(workflowSchema).handler(async ({ input, context }) => {
915
+ const { spaceId, ...data } = input;
916
+ return context.workflows.create(spaceId, data);
917
+ }),
918
+ update: scoped("workflow:write").input(workflowSchema.extend({ id: uuid })).handler(async ({ input, context }) => {
919
+ const { spaceId, id, ...data } = input;
920
+ return context.workflows.update(spaceId, id, data);
921
+ }),
922
+ setEnabled: scoped("workflow:write").input(z.object({
923
+ spaceId: uuid,
924
+ id: uuid,
925
+ enabled: z.boolean()
926
+ })).handler(async ({ input, context }) => context.workflows.setEnabled(input.spaceId, input.id, input.enabled)),
927
+ delete: scoped("workflow:write").input(z.object({
928
+ spaceId: uuid,
929
+ id: uuid
930
+ })).handler(async ({ input, context }) => {
931
+ await context.workflows.delete(input.spaceId, input.id);
932
+ return { ok: true };
933
+ }),
934
+ /** The latest runs of a workflow, newest first, each with its step log. */
935
+ runs: scoped("workflow:read").input(z.object({
936
+ spaceId: uuid,
937
+ id: uuid,
938
+ limit: z.number().int().min(1).max(200).default(50)
939
+ })).handler(async ({ input, context }) => context.workflows.runs(input.spaceId, input.id, input.limit)),
940
+ run: scoped("workflow:read").input(z.object({
941
+ spaceId: uuid,
942
+ id: uuid
943
+ })).handler(async ({ input, context }) => context.workflows.run(input.spaceId, input.id)),
944
+ /** Runs the workflow now, against a document when one is named, and returns the run. */
945
+ runNow: scoped("workflow:write").input(z.object({
946
+ spaceId: uuid,
947
+ id: uuid,
948
+ contentId: uuid.nullable().optional()
949
+ })).handler(async ({ input, context }) => context.workflows.runNow(input.spaceId, input.id, input.contentId ?? null)),
950
+ pushSubscriptions: authed.handler(async ({ context }) => context.workflows.subscriptions(context.principal.userId)),
951
+ pushSubscribe: authed.input(pushSubscription).handler(async ({ input, context }) => context.workflows.subscribe(context.principal.userId, input, context.headers.get("user-agent"))),
952
+ pushUnsubscribe: authed.input(z.object({ endpoint: z.string().max(4e3) })).handler(async ({ input, context }) => {
953
+ await context.workflows.unsubscribe(context.principal.userId, input.endpoint);
954
+ return { ok: true };
955
+ })
956
+ };
957
+ //#endregion
958
+ //#region src/context.ts
959
+ /** The runtime's RPC-facing slice, without whatever else the host keeps on it. */
960
+ function pickRpcRuntime(runtime) {
961
+ const { manablox, repos, auth, apiKeys, media, content, contentTypes, spaces, users, menus, roles, workflows } = runtime;
962
+ return {
963
+ manablox,
964
+ repos,
965
+ auth,
966
+ apiKeys,
967
+ media,
968
+ content,
969
+ contentTypes,
970
+ spaces,
971
+ users,
972
+ menus,
973
+ roles,
974
+ workflows
975
+ };
976
+ }
977
+ //#endregion
978
+ //#region src/index.ts
979
+ /**
980
+ * The management API. The admin imports the *type* of this object and gets end-to-end
981
+ * safety with no codegen step.
982
+ */
983
+ const router = {
984
+ content: contentRouter,
985
+ contentTypes: contentTypeRouter,
986
+ spaces: spaceRouter,
987
+ assets: assetRouter,
988
+ users: userRouter,
989
+ menus: menuRouter,
990
+ roles: roleRouter,
991
+ workflows: workflowRouter
992
+ };
993
+ //#endregion
994
+ export { authed, base, pickRpcRuntime, router, scoped, superadmin, toOrpcError };