@manablox/api-rpc 0.2.0 → 0.4.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,1227 @@
1
+ import { z } from "zod";
2
+ import { MIN_PASSWORD_LENGTH, actorRoles, allowedTypeIds, assertCan, can, effectiveGrants, normaliseGrants } from "@manablox/auth";
3
+ import { AUDIT_ACTIONS, ManabloxError, NOTIFICATION_CHANNELS, NOTIFICATION_KINDS, TRANSPORT_CODE, WORKFLOW_CONDITION_OPERATORS, WORKFLOW_EVENTS } from "@manablox/core";
4
+ import { ORPCError, os } from "@orpc/server";
5
+ import { SPACE_EXPORT_SECTIONS, applySpaceStarter, 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.media.present(await context.media.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/audit.ts
167
+ const actorKind = z.enum([
168
+ "user",
169
+ "apikey",
170
+ "workflow",
171
+ "system"
172
+ ]);
173
+ const targetKind = z.enum([
174
+ "content",
175
+ "contentType",
176
+ "space",
177
+ "member",
178
+ "user",
179
+ "apiKey",
180
+ "menu",
181
+ "role",
182
+ "workflow",
183
+ "workflowRun",
184
+ "asset",
185
+ "session"
186
+ ]);
187
+ const filterSchema$1 = z.object({
188
+ actorKind: actorKind.optional(),
189
+ actorId: z.string().max(200).optional(),
190
+ actions: z.array(z.enum(AUDIT_ACTIONS)).max(60).optional(),
191
+ targetKind: targetKind.optional(),
192
+ targetId: z.string().max(200).optional(),
193
+ from: z.coerce.date().optional(),
194
+ to: z.coerce.date().optional(),
195
+ search: searchTerm.optional()
196
+ });
197
+ const sortSchema$1 = z.object({
198
+ by: z.enum([
199
+ "at",
200
+ "action",
201
+ "actorLabel",
202
+ "targetKind",
203
+ "targetLabel"
204
+ ]).default("at"),
205
+ direction: z.enum(["asc", "desc"]).default("desc")
206
+ });
207
+ const paginationSchema$1 = pagination({
208
+ limit: 50,
209
+ max: 200
210
+ });
211
+ /**
212
+ * The activity log. Reading a space's log takes `audit:read` there; the instance-wide
213
+ * view, which includes actions outside any space (accounts, keys, creating spaces), is a
214
+ * superadmin's. Nothing here writes: the log is appended where the actions happen.
215
+ */
216
+ const auditRouter = {
217
+ /** The actions, actor kinds and target kinds the filter can name. */
218
+ catalog: authed.handler(async ({ context }) => context.audit.catalog()),
219
+ list: scoped("audit:read").input(z.object({
220
+ spaceId: uuid,
221
+ filter: filterSchema$1.default({}),
222
+ sort: sortSchema$1.default({
223
+ by: "at",
224
+ direction: "desc"
225
+ }),
226
+ pagination: paginationSchema$1.default({
227
+ limit: 50,
228
+ offset: 0
229
+ })
230
+ })).handler(async ({ input, context }) => context.audit.list(input.spaceId, input.filter, input.sort, input.pagination)),
231
+ /**
232
+ * Every entry on the instance, or with `instanceOnly` just those outside any space.
233
+ * Superadmin, because it crosses spaces.
234
+ */
235
+ listInstance: superadmin.input(z.object({
236
+ instanceOnly: z.boolean().default(false),
237
+ filter: filterSchema$1.default({}),
238
+ sort: sortSchema$1.default({
239
+ by: "at",
240
+ direction: "desc"
241
+ }),
242
+ pagination: paginationSchema$1.default({
243
+ limit: 50,
244
+ offset: 0
245
+ })
246
+ })).handler(async ({ input, context }) => context.audit.listInstance({
247
+ ...input.filter,
248
+ ...input.instanceOnly ? { spaceId: null } : {}
249
+ }, input.sort, input.pagination)),
250
+ get: scoped("audit:read").input(z.object({
251
+ spaceId: uuid,
252
+ id: uuid
253
+ })).handler(async ({ input, context }) => context.audit.get(input.spaceId, input.id)),
254
+ /** What has been recorded about one thing in the space: a document's history. */
255
+ forTarget: scoped("audit:read").input(z.object({
256
+ spaceId: uuid,
257
+ targetKind,
258
+ targetId: z.string().max(200),
259
+ limit: z.number().int().min(1).max(200).default(100)
260
+ })).handler(async ({ input, context }) => context.audit.forTarget(input.spaceId, input.targetKind, input.targetId, input.limit)),
261
+ /** Walks the whole chain and says whether every entry still hashes to what it claims. */
262
+ verify: superadmin.handler(async ({ context }) => context.audit.verify())
263
+ };
264
+ //#endregion
265
+ //#region src/routers/content.ts
266
+ const filterSchema = z.object({
267
+ spaceId: uuid,
268
+ /** Specific documents, for a relation field's chips: one request instead of one per id. */
269
+ ids: z.array(uuid).max(200).optional(),
270
+ typeIds: z.array(uuid).optional(),
271
+ locale: locale.optional(),
272
+ status: z.enum([
273
+ "draft",
274
+ "published",
275
+ "archived"
276
+ ]).optional(),
277
+ parentId: uuid.nullable().optional(),
278
+ under: uuid.optional(),
279
+ search: searchTerm.optional(),
280
+ fields: z.array(z.object({
281
+ name: z.string(),
282
+ op: z.enum([
283
+ "eq",
284
+ "neq",
285
+ "lt",
286
+ "lte",
287
+ "gt",
288
+ "gte",
289
+ "in",
290
+ "notIn",
291
+ "contains",
292
+ "startsWith",
293
+ "endsWith",
294
+ "isNull",
295
+ "isNotNull"
296
+ ]),
297
+ value: z.unknown().optional()
298
+ })).max(10).optional()
299
+ });
300
+ const paginationSchema = pagination({
301
+ limit: 25,
302
+ max: 200
303
+ });
304
+ /** What an author says when asking, or a reviewer when answering. */
305
+ const note = z.string().max(2e3);
306
+ const sortSchema = z.array(z.object({
307
+ by: z.enum([
308
+ "position",
309
+ "title",
310
+ "createdAt",
311
+ "updatedAt",
312
+ "publishedAt",
313
+ "slug"
314
+ ]),
315
+ direction: z.enum(["asc", "desc"]).default("asc")
316
+ })).max(3);
317
+ const saveSchema = z.object({
318
+ spaceId: uuid,
319
+ typeId: uuid,
320
+ locale: locale.default("en"),
321
+ localizationId: uuid.optional(),
322
+ parentId: uuid.nullable().optional(),
323
+ title: z.string().min(1).max(500),
324
+ slug: z.string().max(200).optional(),
325
+ fields: z.record(z.string(), z.unknown()).default({}),
326
+ position: z.number().int().optional(),
327
+ expectedVersion: z.number().int().positive().optional()
328
+ });
329
+ const contentRouter = {
330
+ list: scoped("content:read").input(z.object({
331
+ filter: filterSchema,
332
+ pagination: paginationSchema.optional(),
333
+ sort: sortSchema.optional()
334
+ }).transform((v) => ({
335
+ ...v,
336
+ spaceId: v.filter.spaceId
337
+ }))).handler(async ({ input, context }) => {
338
+ const filter = narrowToAllowed(context, input.filter);
339
+ if (!filter) return {
340
+ items: [],
341
+ total: 0,
342
+ ...input.pagination ?? {
343
+ limit: 25,
344
+ offset: 0
345
+ }
346
+ };
347
+ return context.content.list(filter, input.pagination ?? {
348
+ limit: 25,
349
+ offset: 0
350
+ }, input.sort ?? [], { actor: toActor(context, input.filter.spaceId) });
351
+ }),
352
+ tree: scoped("content:read").input(z.object({
353
+ spaceId: uuid,
354
+ locale: locale.default("en"),
355
+ rootId: uuid.nullable().default(null)
356
+ })).handler(async ({ input, context }) => context.content.tree(input.spaceId, input.locale, input.rootId)),
357
+ get: scoped("content:read").input(z.object({
358
+ spaceId: uuid,
359
+ id: uuid
360
+ })).handler(async ({ input, context }) => {
361
+ await assertOnDocument(context, input.spaceId, "content:read", input.id);
362
+ return context.content.get(input.id, { actor: toActor(context, input.spaceId) });
363
+ }),
364
+ /** Field values with defaults filled in — what the editor opens a new document with. */
365
+ blank: scoped("content:read").input(z.object({
366
+ spaceId: uuid,
367
+ typeId: uuid
368
+ })).handler(async ({ input, context }) => {
369
+ assertOnType(context, input.spaceId, "content:read", input.typeId);
370
+ return { fields: await context.content.initFields(context.manablox.contentTypes.get(input.typeId)) };
371
+ }),
372
+ create: scoped("content:write").input(saveSchema).handler(async ({ input, context }) => {
373
+ assertOnType(context, input.spaceId, "content:write", input.typeId);
374
+ const actor = toActor(context, input.spaceId);
375
+ const row = await context.content.create(input, actor);
376
+ if (context.manablox.contentTypes.get(row.typeId).requiresApproval && !input.localizationId && !can(context.principal, input.spaceId, "content:publish", row.typeId)) await context.approvals.request(input.spaceId, row.id, actor);
377
+ return row;
378
+ }),
379
+ update: scoped("content:write").input(saveSchema.extend({ id: uuid })).handler(async ({ input, context }) => {
380
+ assertOnType(context, input.spaceId, "content:write", input.typeId);
381
+ return context.content.update(input.id, input, toActor(context, input.spaceId));
382
+ }),
383
+ delete: scoped("content:delete").input(z.object({
384
+ spaceId: uuid,
385
+ id: uuid
386
+ })).handler(async ({ input, context }) => {
387
+ await assertOnDocument(context, input.spaceId, "content:delete", input.id);
388
+ return { deleted: await context.content.delete(input.id, toActor(context, input.spaceId)) };
389
+ }),
390
+ publish: scoped("content:publish").input(z.object({
391
+ spaceId: uuid,
392
+ id: uuid
393
+ })).handler(async ({ input, context }) => {
394
+ await assertOnDocument(context, input.spaceId, "content:publish", input.id);
395
+ return context.content.publish(input.id, toActor(context, input.spaceId));
396
+ }),
397
+ unpublish: scoped("content:publish").input(z.object({
398
+ spaceId: uuid,
399
+ id: uuid
400
+ })).handler(async ({ input, context }) => {
401
+ await assertOnDocument(context, input.spaceId, "content:publish", input.id);
402
+ await context.content.unpublish(input.id, toActor(context, input.spaceId));
403
+ return { ok: true };
404
+ }),
405
+ /** Reparent or reorder a document in the tree — a drag in the admin's tree panel. */
406
+ move: scoped("content:write").input(z.object({
407
+ spaceId: uuid,
408
+ id: uuid,
409
+ parentId: uuid.nullable(),
410
+ position: z.number().int().min(0)
411
+ })).handler(async ({ input, context }) => {
412
+ await assertOnDocument(context, input.spaceId, "content:write", input.id);
413
+ return context.content.move(input.spaceId, input.id, input.parentId, input.position);
414
+ }),
415
+ /** Every locale a document exists in, for the editor's language switcher. */
416
+ translations: scoped("content:read").input(z.object({
417
+ spaceId: uuid,
418
+ id: uuid
419
+ })).handler(async ({ input, context }) => context.content.translations(input.spaceId, input.id)),
420
+ /** Starts a translation of an existing document, in the localization group it shares. */
421
+ createTranslation: scoped("content:write").input(z.object({
422
+ spaceId: uuid,
423
+ id: uuid,
424
+ locale
425
+ })).handler(async ({ input, context }) => {
426
+ await assertOnDocument(context, input.spaceId, "content:write", input.id);
427
+ return context.content.createTranslation(input.spaceId, input.id, input.locale, toActor(context, input.spaceId));
428
+ }),
429
+ /** Where a document stands in review: the open request, the last decision, the history. */
430
+ approval: scoped("content:read").input(z.object({
431
+ spaceId: uuid,
432
+ id: uuid
433
+ })).handler(async ({ input, context }) => {
434
+ await assertOnDocument(context, input.spaceId, "content:read", input.id);
435
+ return context.approvals.state(input.spaceId, input.id);
436
+ }),
437
+ /** The open requests the caller may decide on: those of the types they can publish. */
438
+ pendingApprovals: scoped("content:publish").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.approvals.pending(input.spaceId, allowedTypeIds(context.principal, input.spaceId, "content:publish"))),
439
+ requestApproval: scoped("content:write").input(z.object({
440
+ spaceId: uuid,
441
+ id: uuid,
442
+ note: note.optional()
443
+ })).handler(async ({ input, context }) => {
444
+ await assertOnDocument(context, input.spaceId, "content:write", input.id);
445
+ return context.approvals.request(input.spaceId, input.id, toActor(context, input.spaceId), input.note?.trim() || null);
446
+ }),
447
+ withdrawApproval: scoped("content:write").input(z.object({
448
+ spaceId: uuid,
449
+ id: uuid
450
+ })).handler(async ({ input, context }) => {
451
+ await assertOnDocument(context, input.spaceId, "content:write", input.id);
452
+ return context.approvals.withdraw(input.spaceId, input.id, toActor(context, input.spaceId));
453
+ }),
454
+ /** Approves and publishes; the requester is told. */
455
+ approve: scoped("content:publish").input(z.object({
456
+ spaceId: uuid,
457
+ id: uuid,
458
+ note: note.optional()
459
+ })).handler(async ({ input, context }) => {
460
+ await assertOnDocument(context, input.spaceId, "content:publish", input.id);
461
+ return context.approvals.approve(input.spaceId, input.id, toActor(context, input.spaceId), input.note?.trim() || null);
462
+ }),
463
+ /** Sends the document back with a note; the requester is told. */
464
+ reject: scoped("content:publish").input(z.object({
465
+ spaceId: uuid,
466
+ id: uuid,
467
+ note: note.optional()
468
+ })).handler(async ({ input, context }) => {
469
+ await assertOnDocument(context, input.spaceId, "content:publish", input.id);
470
+ return context.approvals.reject(input.spaceId, input.id, toActor(context, input.spaceId), input.note?.trim() || null);
471
+ }),
472
+ versions: scoped("content:read").input(z.object({
473
+ spaceId: uuid,
474
+ id: uuid
475
+ })).handler(async ({ input, context }) => context.repos.content.versions(input.id)),
476
+ versionSnapshot: scoped("content:read").input(z.object({
477
+ spaceId: uuid,
478
+ id: uuid,
479
+ version: z.number().int().positive()
480
+ })).handler(async ({ input, context }) => context.repos.content.versionSnapshot(input.id, input.version)),
481
+ restore: scoped("content:write").input(z.object({
482
+ spaceId: uuid,
483
+ id: uuid,
484
+ version: z.number().int().positive()
485
+ })).handler(async ({ input, context }) => {
486
+ await assertOnDocument(context, input.spaceId, "content:write", input.id);
487
+ return context.content.restore(input.id, input.version, toActor(context, input.spaceId));
488
+ })
489
+ };
490
+ function assertOnType(context, spaceId, permission, typeId) {
491
+ try {
492
+ assertCan(context.principal, spaceId, permission, typeId);
493
+ } catch (error) {
494
+ throw toOrpcError(error);
495
+ }
496
+ }
497
+ async function assertOnDocument(context, spaceId, permission, id) {
498
+ if (allowedTypeIds(context.principal, spaceId, permission) === null) return;
499
+ const row = await context.repos.content.findById(id);
500
+ if (!row || row.spaceId !== spaceId) throw toOrpcError(ManabloxError.notFound("content.notFound", { id }));
501
+ assertOnType(context, spaceId, permission, row.typeId);
502
+ }
503
+ /** The filter narrowed to the types the caller may read; `null` when that leaves none. */
504
+ function narrowToAllowed(context, filter) {
505
+ const allowed = allowedTypeIds(context.principal, filter.spaceId, "content:read");
506
+ if (allowed === null) return filter;
507
+ const typeIds = filter.typeIds?.length ? filter.typeIds.filter((typeId) => allowed.includes(typeId)) : allowed;
508
+ return typeIds.length ? {
509
+ ...filter,
510
+ typeIds
511
+ } : null;
512
+ }
513
+ function toActor(context, spaceId) {
514
+ const principal = context.principal;
515
+ if (!principal) return null;
516
+ return {
517
+ userId: principal.userId,
518
+ roles: actorRoles(principal, spaceId)
519
+ };
520
+ }
521
+ //#endregion
522
+ //#region src/routers/content-type.ts
523
+ const fieldSchema = z.object({
524
+ id: z.string().optional(),
525
+ name: z.string().min(1).max(64),
526
+ label: z.string().optional(),
527
+ type: z.string(),
528
+ settings: z.record(z.string(), z.unknown()).default({}),
529
+ required: z.boolean().default(false),
530
+ localized: z.boolean().default(false),
531
+ unique: z.boolean().default(false),
532
+ readRoles: z.array(z.string()).optional(),
533
+ writeRoles: z.array(z.string()).optional(),
534
+ admin: z.object({
535
+ zone: z.enum(["main", "sidebar"]).default("main"),
536
+ width: z.number().int().min(25).max(100).default(100),
537
+ position: z.number().int().default(0),
538
+ help: z.string().optional(),
539
+ placeholder: z.string().optional()
540
+ }).optional()
541
+ });
542
+ const contentTypeSchema = z.object({
543
+ name: z.string().min(1).max(64),
544
+ label: z.string().optional(),
545
+ description: z.string().optional(),
546
+ icon: z.string().optional(),
547
+ kind: z.enum(["content", "block"]).default("content"),
548
+ spaceId: uuid.nullable().default(null),
549
+ hasSlug: z.boolean().optional(),
550
+ isPublishable: z.boolean().optional(),
551
+ isVisibleInTree: z.boolean().optional(),
552
+ canBeVisibleInMenu: z.boolean().optional(),
553
+ requiresApproval: z.boolean().optional(),
554
+ fields: z.array(fieldSchema).default([])
555
+ });
556
+ const contentTypeRouter = {
557
+ list: scoped("contentType:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.contentTypes.list(input.spaceId)),
558
+ get: scoped("contentType:read").input(z.object({
559
+ spaceId: uuid,
560
+ id: uuid
561
+ })).handler(async ({ input, context }) => context.contentTypes.get(input.id)),
562
+ create: scoped("contentType:write").input(contentTypeSchema.extend({ spaceId: uuid })).handler(async ({ input, context }) => context.contentTypes.create(input, context.principal.userId)),
563
+ update: scoped("contentType:write").input(contentTypeSchema.extend({
564
+ spaceId: uuid,
565
+ id: uuid
566
+ })).handler(async ({ input, context }) => context.contentTypes.update(input.id, input)),
567
+ delete: scoped("contentType:delete").input(z.object({
568
+ spaceId: uuid,
569
+ id: uuid
570
+ })).handler(async ({ input, context }) => {
571
+ await context.contentTypes.delete(input.id);
572
+ return { ok: true };
573
+ }),
574
+ /**
575
+ * The field-type catalogue the admin's "add field" menu is built from. Derived from
576
+ * the registry, so a plugin's field type appears in the menu with no admin change.
577
+ */
578
+ fieldTypes: base.handler(async ({ context }) => context.manablox.fieldTypes.all.map((type) => ({
579
+ name: type.name,
580
+ label: type.label,
581
+ icon: type.icon ?? null,
582
+ description: type.description ?? null,
583
+ nested: type.nested ?? false,
584
+ filters: type.filters,
585
+ admin: type.admin
586
+ }))),
587
+ /** JSON Schema for one field type's settings, so the admin renders its form generically. */
588
+ fieldTypeSettingsSchema: base.input(z.object({ name: z.string() })).handler(async ({ input, context }) => {
589
+ const type = context.manablox.fieldTypes.get(input.name);
590
+ const schema = type.settingsSchema;
591
+ return {
592
+ name: type.name,
593
+ jsonSchema: typeof schema.toJSONSchema === "function" ? schema.toJSONSchema() : null
594
+ };
595
+ }),
596
+ /**
597
+ * The space's runtime types rendered as `manablox.config.ts` source, for moving a type
598
+ * built in the admin into code where it can be reviewed and versioned.
599
+ */
600
+ config: scoped("contentType:read").input(z.object({
601
+ spaceId: uuid,
602
+ ids: z.array(uuid).optional()
603
+ })).handler(async ({ input, context }) => {
604
+ const wanted = input.ids?.length ? new Set(input.ids) : null;
605
+ const types = context.manablox.contentTypes.forSpace(input.spaceId).filter((type) => type.source !== "code" && (!wanted || wanted.has(type.id)));
606
+ return {
607
+ code: renderContentTypeConfig(types),
608
+ count: types.length
609
+ };
610
+ }),
611
+ reload: superadmin.handler(async ({ context }) => {
612
+ await context.manablox.reload(await context.repos.contentTypes.all());
613
+ return { schemaVersion: context.manablox.contentTypes.schemaVersion };
614
+ })
615
+ };
616
+ //#endregion
617
+ //#region src/routers/menu.ts
618
+ const menuSchema = z.object({
619
+ name: z.string().min(1).max(200),
620
+ machineName,
621
+ description: z.string().max(2e3).nullable().optional()
622
+ });
623
+ const menuItemSchema = z.lazy(() => z.object({
624
+ id: uuid.optional(),
625
+ localizationId: uuid.nullable().optional(),
626
+ label: z.string().max(200).nullable().optional(),
627
+ url: z.string().max(2e3).nullable().optional(),
628
+ children: z.array(menuItemSchema).max(500).optional()
629
+ }));
630
+ /**
631
+ * Menus. Each rule — the unique machine name, what an entry may point at — lives in
632
+ * `MenuService`; a procedure here is an input schema, a permission and one call.
633
+ */
634
+ const menuRouter = {
635
+ list: scoped("menu:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.menus.list(input.spaceId)),
636
+ /** The menu with its entries, each content entry resolved to the document in `locale`. */
637
+ get: scoped("menu:read").input(z.object({
638
+ spaceId: uuid,
639
+ id: uuid,
640
+ locale
641
+ })).handler(async ({ input, context }) => context.menus.get(input.spaceId, input.id, input.locale)),
642
+ create: scoped("menu:write").input(menuSchema.extend({ spaceId: uuid })).handler(async ({ input, context }) => context.menus.create(input)),
643
+ update: scoped("menu:write").input(menuSchema.partial().extend({
644
+ spaceId: uuid,
645
+ id: uuid
646
+ })).handler(async ({ input, context }) => {
647
+ const { spaceId, id, ...data } = input;
648
+ return context.menus.update(spaceId, id, data);
649
+ }),
650
+ delete: scoped("menu:write").input(z.object({
651
+ spaceId: uuid,
652
+ id: uuid
653
+ })).handler(async ({ input, context }) => {
654
+ await context.menus.delete(input.spaceId, input.id);
655
+ return { ok: true };
656
+ }),
657
+ /** Replaces the whole entry tree; the editor saves a menu as one document. */
658
+ setItems: scoped("menu:write").input(z.object({
659
+ spaceId: uuid,
660
+ id: uuid,
661
+ items: z.array(menuItemSchema).max(500)
662
+ })).handler(async ({ input, context }) => context.menus.setItems(input.spaceId, input.id, input.items)),
663
+ /** Menus a document is linked from, for the editor's hint. */
664
+ usedIn: scoped("menu:read").input(z.object({
665
+ spaceId: uuid,
666
+ localizationId: uuid
667
+ })).handler(async ({ input, context }) => context.menus.usedIn(input.spaceId, input.localizationId))
668
+ };
669
+ //#endregion
670
+ //#region src/routers/notification.ts
671
+ const kind = z.enum(NOTIFICATION_KINDS);
672
+ const ids = z.array(uuid).min(1).max(200);
673
+ /**
674
+ * Each kind's channels, every one optional: absent means the default. A partial record,
675
+ * so a kind not named keeps its default; strict objects, so a misspelt channel is an
676
+ * error rather than a silently dropped choice.
677
+ */
678
+ const preferencesSchema = z.partialRecord(kind, z.strictObject(Object.fromEntries(NOTIFICATION_CHANNELS.map((channel) => [channel, z.boolean().optional()]))));
679
+ /**
680
+ * The caller's own inbox and preferences. Nothing here takes a `userId`: every
681
+ * procedure acts as the signed-in account, so there is no way to read or change what
682
+ * belongs to someone else.
683
+ */
684
+ const notificationRouter = {
685
+ /** The kinds, the channels, and which channels this instance can actually send on. */
686
+ catalog: authed.handler(async ({ context }) => context.notifications.catalog()),
687
+ list: authed.input(z.object({
688
+ unreadOnly: z.boolean().default(false),
689
+ kinds: z.array(kind).optional(),
690
+ /** A space's notifications, `null` for instance-wide ones, absent for all. */
691
+ spaceId: uuid.nullable().optional(),
692
+ pagination: pagination({
693
+ limit: 25,
694
+ max: 100
695
+ }).optional()
696
+ }).default({ unreadOnly: false })).handler(async ({ input, context }) => context.notifications.list(context.principal.userId, {
697
+ unreadOnly: input.unreadOnly,
698
+ kinds: input.kinds,
699
+ spaceId: input.spaceId
700
+ }, input.pagination ?? {
701
+ limit: 25,
702
+ offset: 0
703
+ })),
704
+ unreadCount: authed.handler(async ({ context }) => ({ count: await context.notifications.unreadCount(context.principal.userId) })),
705
+ markRead: authed.input(z.object({ ids })).handler(async ({ input, context }) => ({ changed: await context.notifications.markRead(context.principal.userId, input.ids) })),
706
+ markUnread: authed.input(z.object({ ids })).handler(async ({ input, context }) => ({ changed: await context.notifications.markUnread(context.principal.userId, input.ids) })),
707
+ markAllRead: authed.handler(async ({ context }) => ({ changed: await context.notifications.markAllRead(context.principal.userId) })),
708
+ delete: authed.input(z.object({ ids })).handler(async ({ input, context }) => ({ deleted: await context.notifications.delete(context.principal.userId, input.ids) })),
709
+ deleteRead: authed.handler(async ({ context }) => ({ deleted: await context.notifications.deleteRead(context.principal.userId) })),
710
+ /** Every kind with the caller's effective choice per channel, defaults filled in. */
711
+ preferences: authed.handler(async ({ context }) => context.notifications.preferences(context.principal.userId)),
712
+ setPreferences: authed.input(z.object({ preferences: preferencesSchema })).handler(async ({ input, context }) => context.notifications.setPreferences(context.principal.userId, input.preferences))
713
+ };
714
+ //#endregion
715
+ //#region src/routers/role.ts
716
+ const roleSchema = z.object({
717
+ spaceId: uuid,
718
+ name: z.string().trim().min(1).max(100),
719
+ machineName,
720
+ description: z.string().max(500).nullable().optional(),
721
+ /** `space:write`, `content:read` for every type, `content:read:<typeId>` for one. */
722
+ permissions: z.array(z.string().max(120)).max(500)
723
+ });
724
+ /**
725
+ * The roles of a space. The rules — reserved names, grants that exist, a role nobody
726
+ * holds before it goes — live in `RoleService`; a procedure here is an input schema, a
727
+ * permission and one call.
728
+ */
729
+ const roleRouter = {
730
+ /** The permission catalogue, grouped the way the role editor lays it out. */
731
+ catalog: authed.handler(async ({ context }) => context.roles.catalog()),
732
+ list: scoped("role:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.roles.list(input.spaceId)),
733
+ get: scoped("role:read").input(z.object({
734
+ spaceId: uuid,
735
+ id: uuid
736
+ })).handler(async ({ input, context }) => context.roles.get(input.spaceId, input.id)),
737
+ create: scoped("role:write").input(roleSchema).handler(async ({ input, context }) => {
738
+ const { spaceId, ...data } = input;
739
+ return context.roles.create(spaceId, data);
740
+ }),
741
+ update: scoped("role:write").input(roleSchema.extend({ id: uuid })).handler(async ({ input, context }) => {
742
+ const { spaceId, id, ...data } = input;
743
+ return context.roles.update(spaceId, id, data);
744
+ }),
745
+ delete: scoped("role:write").input(z.object({
746
+ spaceId: uuid,
747
+ id: uuid
748
+ })).handler(async ({ input, context }) => {
749
+ await context.roles.delete(input.spaceId, input.id);
750
+ return { ok: true };
751
+ })
752
+ };
753
+ //#endregion
754
+ //#region src/routers/space.ts
755
+ /** The parts of a space an export carries or an import restores; all of them when absent. */
756
+ const exportSections = z.array(z.enum(SPACE_EXPORT_SECTIONS)).min(1);
757
+ const spaceSchema = z.object({
758
+ name: z.string().min(1).max(200),
759
+ machineName,
760
+ description: z.string().nullable().optional(),
761
+ url: z.string().url(),
762
+ defaultLocale: locale.default("en"),
763
+ locales: localeList.default(["en"]),
764
+ settings: z.record(z.string(), z.unknown()).optional()
765
+ });
766
+ /**
767
+ * Spaces and membership. Every rule — the locale invariant, the last-owner guard, the
768
+ * creator-owns-it grant — lives in `SpaceService`; a procedure here is an input schema,
769
+ * a permission and one call.
770
+ */
771
+ const spaceRouter = {
772
+ /**
773
+ * Only the spaces the caller is a member of — a superadmin sees all. A member's query
774
+ * is bounded by their memberships rather than by the instance, so a large multi-tenant
775
+ * install does not load every space to show someone their two.
776
+ */
777
+ list: authed.handler(async ({ context }) => {
778
+ const { principal } = context;
779
+ const all = principal.role === "superadmin" ? await context.repos.spaces.all() : await context.repos.spaces.findManyByIds(Object.keys(principal.spaces));
780
+ const allowed = principal.allowedSpaceIds;
781
+ return allowed ? all.filter((space) => allowed.includes(space.id)) : all;
782
+ }),
783
+ get: scoped("space:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.repos.spaces.findById(input.spaceId)),
784
+ /**
785
+ * `starter` fills the new space with the basic setup, a content model, a few
786
+ * published pages and a main menu, so the first thing its owner sees is a site rather
787
+ * than an empty tree. See `applySpaceStarter`.
788
+ */
789
+ create: superadmin.input(spaceSchema.extend({ starter: z.boolean().default(false) })).handler(async ({ input, context }) => {
790
+ const { starter, ...data } = input;
791
+ const { principal } = context;
792
+ const space = await context.spaces.create(data, principal.userId);
793
+ if (starter) await applySpaceStarter(context, space, {
794
+ userId: principal.userId,
795
+ roles: actorRoles(principal, space.id)
796
+ });
797
+ return space;
798
+ }),
799
+ update: scoped("space:write").input(spaceSchema.partial().extend({ spaceId: uuid })).handler(async ({ input, context }) => {
800
+ const { spaceId, ...data } = input;
801
+ return context.spaces.update(spaceId, data);
802
+ }),
803
+ delete: scoped("space:delete").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => {
804
+ await context.spaces.delete(input.spaceId);
805
+ return { ok: true };
806
+ }),
807
+ /** Nominates one document as the space's root; `null` clears it. */
808
+ setHome: scoped("space:write").input(z.object({
809
+ spaceId: uuid,
810
+ contentId: uuid.nullable()
811
+ })).handler(async ({ input, context }) => context.spaces.setHome(input.spaceId, input.contentId)),
812
+ /**
813
+ * The space's upload limits, each narrowing the instance's. `allowedMimeTypes` absent
814
+ * means the instance's list; empty means the same thing rather than "nothing".
815
+ */
816
+ setAssetSettings: scoped("space:write").input(z.object({
817
+ spaceId: uuid,
818
+ allowedMimeTypes: z.array(mimeTypePattern).max(50).optional(),
819
+ maxFileSize: z.number().int().positive().nullable().optional()
820
+ })).handler(async ({ input, context }) => context.spaces.setAssetSettings(input.spaceId, {
821
+ ...input.allowedMimeTypes?.length ? { allowedMimeTypes: input.allowedMimeTypes } : {},
822
+ ...input.maxFileSize ? { maxFileSize: input.maxFileSize } : {}
823
+ })),
824
+ /**
825
+ * The whole space as one JSON document. `space:write` rather than `space:read` because
826
+ * an export is every field of every document in one file, regardless of who may read
827
+ * what.
828
+ */
829
+ export: scoped("space:write").input(z.object({
830
+ spaceId: uuid,
831
+ sections: exportSections.optional()
832
+ })).handler(async ({ input, context }) => context.spaces.export(input.spaceId, input.sections)),
833
+ /** Restores such a document into an instance that does not hold the space yet. Superadmin, because it creates a space. */
834
+ import: superadmin.input(z.object({
835
+ payload: z.unknown(),
836
+ sections: exportSections.optional()
837
+ })).handler(async ({ input, context }) => context.spaces.import(input.payload, context.principal.userId, input.sections)),
838
+ members: scoped("user:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.spaces.members(input.spaceId)),
839
+ grant: scoped("user:write").input(z.object({
840
+ spaceId: uuid,
841
+ userId: uuid,
842
+ role: spaceRole
843
+ })).handler(async ({ input, context }) => {
844
+ await context.spaces.grant(input.spaceId, input.userId, input.role);
845
+ return { ok: true };
846
+ }),
847
+ /** Users who are not yet members, for the add-member picker. */
848
+ candidates: scoped("user:write").input(z.object({
849
+ spaceId: uuid,
850
+ search: searchTerm.optional()
851
+ })).handler(async ({ input, context }) => context.spaces.candidates(input.spaceId, input.search)),
852
+ /** Grants the same role to several users at once, as the picker hands them over. */
853
+ addMembers: scoped("user:write").input(z.object({
854
+ spaceId: uuid,
855
+ userIds: z.array(uuid).min(1).max(100),
856
+ role: spaceRole.default("editor")
857
+ })).handler(async ({ input, context }) => ({
858
+ ok: true,
859
+ added: await context.spaces.addMembers(input.spaceId, input.userIds, input.role)
860
+ })),
861
+ revoke: scoped("user:write").input(z.object({
862
+ spaceId: uuid,
863
+ userId: uuid
864
+ })).handler(async ({ input, context }) => {
865
+ await context.spaces.revoke(input.spaceId, input.userId);
866
+ return { ok: true };
867
+ }),
868
+ /** Locales available for a space, for the editor's language switcher. */
869
+ locales: base.input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.spaces.locales(input.spaceId))
870
+ };
871
+ //#endregion
872
+ //#region src/routers/user.ts
873
+ const instanceRole = z.enum(["superadmin", "editor"]);
874
+ const password = z.string().min(MIN_PASSWORD_LENGTH).max(200);
875
+ const email = z.string().email().max(320);
876
+ const displayName = z.string().trim().min(1).max(200);
877
+ /**
878
+ * The caller's own account and keys, and — for a superadmin — every account on the
879
+ * instance. The rules (no locking yourself out, one superadmin always remains) live in
880
+ * `UserService`; a procedure here is an input schema, a permission and one call.
881
+ */
882
+ const userRouter = {
883
+ me: authed.handler(async ({ context }) => {
884
+ const user = await context.repos.users.findById(context.principal.userId);
885
+ return user ? {
886
+ id: user.id,
887
+ email: user.email,
888
+ name: user.name,
889
+ image: user.image,
890
+ role: user.role,
891
+ spaces: context.principal.spaces,
892
+ permissions: Object.fromEntries(Object.keys(context.principal.spaces).map((spaceId) => [spaceId, effectiveGrants(context.principal, spaceId)]))
893
+ } : null;
894
+ }),
895
+ /**
896
+ * Whether the instance still has no account at all. Public, because the login page
897
+ * needs it before anyone is signed in: it decides whether to offer "create the first
898
+ * account". Once one exists, sign-up is closed and every account is created here.
899
+ */
900
+ setupNeeded: base.handler(async ({ context }) => ({ setupNeeded: await context.repos.users.count() === 0 })),
901
+ list: superadmin.input(pagination({
902
+ limit: 25,
903
+ max: 100
904
+ }).extend({ search: searchTerm.optional() })).handler(async ({ input, context }) => context.users.list({
905
+ limit: input.limit,
906
+ offset: input.offset
907
+ }, input.search)),
908
+ get: superadmin.input(z.object({ userId: uuid })).handler(async ({ input, context }) => context.users.get(input.userId)),
909
+ create: superadmin.input(z.object({
910
+ name: displayName,
911
+ email,
912
+ password,
913
+ role: instanceRole.default("editor")
914
+ })).handler(async ({ input, context }) => context.users.create(input)),
915
+ update: superadmin.input(z.object({
916
+ userId: uuid,
917
+ name: displayName.optional(),
918
+ email: email.optional()
919
+ })).handler(async ({ input, context }) => {
920
+ const { userId, ...data } = input;
921
+ return context.users.update(userId, data);
922
+ }),
923
+ setRole: superadmin.input(z.object({
924
+ userId: uuid,
925
+ role: instanceRole
926
+ })).handler(async ({ input, context }) => context.users.setRole(input.userId, input.role)),
927
+ /** Resets a password and signs the account out everywhere. */
928
+ setPassword: superadmin.input(z.object({
929
+ userId: uuid,
930
+ password
931
+ })).handler(async ({ input, context }) => {
932
+ await context.users.setPassword(input.userId, input.password);
933
+ return { ok: true };
934
+ }),
935
+ ban: superadmin.input(z.object({
936
+ userId: uuid,
937
+ reason: z.string().trim().max(500).optional()
938
+ })).handler(async ({ input, context }) => context.users.ban(context.principal.userId, input.userId, input.reason || null)),
939
+ unban: superadmin.input(z.object({ userId: uuid })).handler(async ({ input, context }) => context.users.unban(input.userId)),
940
+ revokeSessions: superadmin.input(z.object({ userId: uuid })).handler(async ({ input, context }) => {
941
+ await context.users.revokeSessions(input.userId);
942
+ return { ok: true };
943
+ }),
944
+ delete: superadmin.input(z.object({ userId: uuid })).handler(async ({ input, context }) => {
945
+ await context.users.delete(context.principal.userId, input.userId);
946
+ return { ok: true };
947
+ }),
948
+ /** The caller's own profile: name and email. The instance role is not theirs to set. */
949
+ updateProfile: authed.input(z.object({
950
+ name: displayName.optional(),
951
+ email: email.optional()
952
+ })).handler(async ({ input, context }) => context.users.update(context.principal.userId, input)),
953
+ /** A new password for the caller, given the current one. Other sessions stay signed in. */
954
+ changePassword: authed.input(z.object({
955
+ currentPassword: z.string().min(1).max(200),
956
+ password
957
+ })).handler(async ({ input, context }) => {
958
+ await context.users.changePassword(context.principal.userId, input.currentPassword, input.password);
959
+ return { ok: true };
960
+ }),
961
+ apiKeys: authed.handler(async ({ context }) => context.apiKeys.list(context.principal.userId)),
962
+ issueApiKey: authed.input(z.object({
963
+ name: z.string().min(1).max(100),
964
+ expiresAt: z.coerce.date().optional(),
965
+ /** Empty or omitted issues an unrestricted key. */
966
+ spaceIds: z.array(uuid).optional(),
967
+ /**
968
+ * Grants the key is confined to, in the roles' vocabulary; omitted leaves the
969
+ * owner's role as the limit. At use the two are intersected, so a grant here
970
+ * never widens the key beyond its owner.
971
+ */
972
+ permissions: z.array(z.string().max(120)).max(500).optional()
973
+ })).handler(async ({ input, context }) => {
974
+ for (const spaceId of input.spaceIds ?? []) assertCan(context.principal, spaceId, "space:read");
975
+ const reachable = input.spaceIds?.length ? input.spaceIds : context.principal.role === "superadmin" ? null : Object.keys(context.principal.spaces);
976
+ const typeIds = new Set((reachable ? reachable.flatMap((spaceId) => context.manablox.contentTypes.forSpace(spaceId)) : context.manablox.contentTypes.all).map((type) => type.id));
977
+ let permissions = null;
978
+ if (input.permissions) {
979
+ const checked = normaliseGrants(input.permissions, typeIds);
980
+ if (checked.unknown.length) throw ManabloxError.validation(checked.unknown.map(({ index, grant }) => ({
981
+ key: "role.permission.unknown",
982
+ path: ["permissions", index],
983
+ params: { permission: grant }
984
+ })), "apiKey.validation.failed");
985
+ permissions = checked.permissions;
986
+ }
987
+ return context.apiKeys.issue(context.principal.userId, input.name, {
988
+ expiresAt: input.expiresAt,
989
+ spaceIds: input.spaceIds,
990
+ permissions
991
+ });
992
+ }),
993
+ revokeApiKey: authed.input(z.object({ id: uuid })).handler(async ({ input, context }) => {
994
+ if (!(await context.apiKeys.list(context.principal.userId)).some((key) => key.id === input.id)) return { ok: false };
995
+ await context.apiKeys.revoke(input.id);
996
+ return { ok: true };
997
+ })
998
+ };
999
+ //#endregion
1000
+ //#region src/routers/workflow.ts
1001
+ const template = z.string().max(2e4);
1002
+ const eventTrigger = z.object({
1003
+ kind: z.literal("event"),
1004
+ events: z.array(z.enum(WORKFLOW_EVENTS)).max(10),
1005
+ typeIds: z.array(z.string().max(120)).max(200).default([]),
1006
+ locales: z.array(z.string().max(10)).max(50).default([])
1007
+ });
1008
+ const selection = z.object({
1009
+ typeIds: z.array(z.string().max(120)).max(200).default([]),
1010
+ status: z.enum([
1011
+ "any",
1012
+ "draft",
1013
+ "published"
1014
+ ]).default("any"),
1015
+ changedWithinHours: z.number().int().min(1).max(8760).nullable().default(null),
1016
+ locale: z.string().max(10).nullable().default(null)
1017
+ });
1018
+ const scheduleTrigger = z.object({
1019
+ kind: z.literal("schedule"),
1020
+ cron: z.string().max(100),
1021
+ timezone: z.string().max(60).default("UTC"),
1022
+ selection: selection.nullable().default(null),
1023
+ perDocument: z.boolean().default(false)
1024
+ });
1025
+ const stepBase = {
1026
+ id: z.string().max(64).default(""),
1027
+ name: z.string().max(200).default(""),
1028
+ enabled: z.boolean().default(true),
1029
+ continueOnError: z.boolean().default(false)
1030
+ };
1031
+ const emailStep = z.object({
1032
+ ...stepBase,
1033
+ type: z.literal("email"),
1034
+ to: z.array(z.string().max(500)).max(50).default([]),
1035
+ toRoles: z.array(z.string().max(64)).max(50).default([]),
1036
+ subject: template,
1037
+ body: template,
1038
+ html: z.boolean().default(false)
1039
+ });
1040
+ const httpStep = z.object({
1041
+ ...stepBase,
1042
+ type: z.literal("http"),
1043
+ method: z.enum([
1044
+ "GET",
1045
+ "POST",
1046
+ "PUT",
1047
+ "PATCH",
1048
+ "DELETE"
1049
+ ]).default("POST"),
1050
+ url: z.string().max(2e3),
1051
+ headers: z.array(z.object({
1052
+ name: z.string().max(200),
1053
+ value: z.string().max(4e3)
1054
+ })).max(50).default([]),
1055
+ body: z.object({
1056
+ mode: z.enum([
1057
+ "event",
1058
+ "custom",
1059
+ "none"
1060
+ ]).default("event"),
1061
+ template: template.default("")
1062
+ }).default({
1063
+ mode: "event",
1064
+ template: ""
1065
+ }),
1066
+ secret: z.string().max(500).nullable().default(null),
1067
+ timeoutMs: z.number().int().min(1e3).max(12e4).default(1e4)
1068
+ });
1069
+ const pushStep = z.object({
1070
+ ...stepBase,
1071
+ type: z.literal("push"),
1072
+ roles: z.array(z.string().max(64)).max(50).default([]),
1073
+ userIds: z.array(uuid).max(200).default([]),
1074
+ title: template,
1075
+ body: template.default(""),
1076
+ url: z.string().max(2e3).default("")
1077
+ });
1078
+ const rules = z.array(z.object({
1079
+ field: z.string().max(300),
1080
+ operator: z.enum(WORKFLOW_CONDITION_OPERATORS),
1081
+ value: z.string().max(4e3).default("")
1082
+ })).max(50);
1083
+ const conditionStep = z.object({
1084
+ ...stepBase,
1085
+ type: z.literal("condition"),
1086
+ match: z.enum(["all", "any"]).default("all"),
1087
+ rules
1088
+ });
1089
+ const delayStep = z.object({
1090
+ ...stepBase,
1091
+ type: z.literal("delay"),
1092
+ minutes: z.number().min(1).max(43200)
1093
+ });
1094
+ /** A fork carries two chains of its own, so the step schema refers to itself (Zod 4 getters). */
1095
+ const branchStep = z.object({
1096
+ ...stepBase,
1097
+ type: z.literal("branch"),
1098
+ match: z.enum(["all", "any"]).default("all"),
1099
+ rules,
1100
+ get then() {
1101
+ return z.array(step).max(50).default([]);
1102
+ },
1103
+ get else() {
1104
+ return z.array(step).max(50).default([]);
1105
+ }
1106
+ });
1107
+ const step = z.discriminatedUnion("type", [
1108
+ emailStep,
1109
+ httpStep,
1110
+ pushStep,
1111
+ conditionStep,
1112
+ branchStep,
1113
+ delayStep
1114
+ ]);
1115
+ const workflowSchema = z.object({
1116
+ spaceId: uuid,
1117
+ name: z.string().max(200),
1118
+ description: z.string().max(2e3).nullable().optional(),
1119
+ enabled: z.boolean().optional(),
1120
+ trigger: z.discriminatedUnion("kind", [eventTrigger, scheduleTrigger]),
1121
+ steps: z.array(step).max(50)
1122
+ });
1123
+ const pushSubscription = z.object({
1124
+ endpoint: z.string().max(4e3),
1125
+ keys: z.object({
1126
+ p256dh: z.string().max(500),
1127
+ auth: z.string().max(500)
1128
+ })
1129
+ });
1130
+ /**
1131
+ * Workflows. The rules — a cron that parses, a step with somewhere to go — live in
1132
+ * `WorkflowService`; a procedure here is an input schema, a permission and one call.
1133
+ */
1134
+ const workflowRouter = {
1135
+ /** The events, step types and operators the editor offers, and what the instance can send. */
1136
+ catalog: authed.handler(async ({ context }) => context.workflows.catalog()),
1137
+ list: scoped("workflow:read").input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => context.workflows.list(input.spaceId)),
1138
+ get: scoped("workflow:read").input(z.object({
1139
+ spaceId: uuid,
1140
+ id: uuid
1141
+ })).handler(async ({ input, context }) => context.workflows.get(input.spaceId, input.id)),
1142
+ create: scoped("workflow:write").input(workflowSchema).handler(async ({ input, context }) => {
1143
+ const { spaceId, ...data } = input;
1144
+ return context.workflows.create(spaceId, data);
1145
+ }),
1146
+ update: scoped("workflow:write").input(workflowSchema.extend({ id: uuid })).handler(async ({ input, context }) => {
1147
+ const { spaceId, id, ...data } = input;
1148
+ return context.workflows.update(spaceId, id, data);
1149
+ }),
1150
+ setEnabled: scoped("workflow:write").input(z.object({
1151
+ spaceId: uuid,
1152
+ id: uuid,
1153
+ enabled: z.boolean()
1154
+ })).handler(async ({ input, context }) => context.workflows.setEnabled(input.spaceId, input.id, input.enabled)),
1155
+ delete: scoped("workflow:write").input(z.object({
1156
+ spaceId: uuid,
1157
+ id: uuid
1158
+ })).handler(async ({ input, context }) => {
1159
+ await context.workflows.delete(input.spaceId, input.id);
1160
+ return { ok: true };
1161
+ }),
1162
+ /** The latest runs of a workflow, newest first, each with its step log. */
1163
+ runs: scoped("workflow:read").input(z.object({
1164
+ spaceId: uuid,
1165
+ id: uuid,
1166
+ limit: z.number().int().min(1).max(200).default(50)
1167
+ })).handler(async ({ input, context }) => context.workflows.runs(input.spaceId, input.id, input.limit)),
1168
+ run: scoped("workflow:read").input(z.object({
1169
+ spaceId: uuid,
1170
+ id: uuid
1171
+ })).handler(async ({ input, context }) => context.workflows.run(input.spaceId, input.id)),
1172
+ /** Runs the workflow now, against a document when one is named, and returns the run. */
1173
+ runNow: scoped("workflow:write").input(z.object({
1174
+ spaceId: uuid,
1175
+ id: uuid,
1176
+ contentId: uuid.nullable().optional()
1177
+ })).handler(async ({ input, context }) => context.workflows.runNow(input.spaceId, input.id, input.contentId ?? null)),
1178
+ pushSubscriptions: authed.handler(async ({ context }) => context.workflows.subscriptions(context.principal.userId)),
1179
+ pushSubscribe: authed.input(pushSubscription).handler(async ({ input, context }) => context.workflows.subscribe(context.principal.userId, input, context.headers.get("user-agent"))),
1180
+ pushUnsubscribe: authed.input(z.object({ endpoint: z.string().max(4e3) })).handler(async ({ input, context }) => {
1181
+ await context.workflows.unsubscribe(context.principal.userId, input.endpoint);
1182
+ return { ok: true };
1183
+ })
1184
+ };
1185
+ //#endregion
1186
+ //#region src/context.ts
1187
+ /** The runtime's RPC-facing slice, without whatever else the host keeps on it. */
1188
+ function pickRpcRuntime(runtime) {
1189
+ const { manablox, repos, auth, apiKeys, media, content, contentTypes, spaces, users, menus, roles, workflows, audit, notifications, approvals } = runtime;
1190
+ return {
1191
+ manablox,
1192
+ repos,
1193
+ auth,
1194
+ apiKeys,
1195
+ media,
1196
+ content,
1197
+ contentTypes,
1198
+ spaces,
1199
+ users,
1200
+ menus,
1201
+ roles,
1202
+ workflows,
1203
+ audit,
1204
+ notifications,
1205
+ approvals
1206
+ };
1207
+ }
1208
+ //#endregion
1209
+ //#region src/index.ts
1210
+ /**
1211
+ * The management API. The admin imports the *type* of this object and gets end-to-end
1212
+ * safety with no codegen step.
1213
+ */
1214
+ const router = {
1215
+ content: contentRouter,
1216
+ contentTypes: contentTypeRouter,
1217
+ spaces: spaceRouter,
1218
+ assets: assetRouter,
1219
+ users: userRouter,
1220
+ menus: menuRouter,
1221
+ roles: roleRouter,
1222
+ workflows: workflowRouter,
1223
+ audit: auditRouter,
1224
+ notifications: notificationRouter
1225
+ };
1226
+ //#endregion
1227
+ export { authed, base, pickRpcRuntime, router, scoped, superadmin, toOrpcError };