@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.d.ts +5971 -0
- package/dist/index.js +994 -0
- package/package.json +18 -11
- package/src/base.ts +0 -59
- package/src/context.ts +0 -70
- package/src/index.ts +0 -28
- package/src/routers/asset.ts +0 -96
- package/src/routers/content-type.ts +0 -117
- package/src/routers/content.ts +0 -266
- package/src/routers/menu.ts +0 -77
- package/src/routers/role.ts +0 -51
- package/src/routers/space.ts +0 -149
- package/src/routers/user.ts +0 -175
- package/src/routers/workflow.ts +0 -253
- package/src/schemas.ts +0 -40
- package/test/asset.test.ts +0 -144
- package/test/content.test.ts +0 -256
- package/test/helpers.ts +0 -163
- package/test/menu.test.ts +0 -192
- package/test/role.test.ts +0 -205
- package/test/space.test.ts +0 -170
- package/test/user.test.ts +0 -212
- package/test/workflow.test.ts +0 -259
- package/tsconfig.json +0 -1
- package/vitest.config.ts +0 -8
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 };
|