@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.d.ts +8010 -0
- package/dist/index.js +1227 -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,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 };
|