@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/package.json
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manablox/api-rpc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
|
-
"types": "./
|
|
8
|
-
"default": "./
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
|
-
"main": "./
|
|
12
|
-
"types": "./
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@manablox/auth": "0.
|
|
15
|
-
"@manablox/core": "0.
|
|
16
|
-
"@manablox/db": "0.
|
|
17
|
-
"@manablox/media": "0.
|
|
18
|
-
"@manablox/services": "0.
|
|
19
|
-
"@manablox/workflows": "0.
|
|
14
|
+
"@manablox/auth": "0.3.0",
|
|
15
|
+
"@manablox/core": "0.3.0",
|
|
16
|
+
"@manablox/db": "0.3.0",
|
|
17
|
+
"@manablox/media": "0.3.0",
|
|
18
|
+
"@manablox/services": "0.3.0",
|
|
19
|
+
"@manablox/workflows": "0.3.0",
|
|
20
20
|
"@orpc/server": "^1.15.0",
|
|
21
21
|
"@orpc/openapi": "^1.15.0",
|
|
22
22
|
"zod": "^4.5.4"
|
|
@@ -24,10 +24,17 @@
|
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@manablox/config-typescript": "0.0.0",
|
|
26
26
|
"@types/node": "^26.4.1",
|
|
27
|
+
"tsdown": "^0.23.0",
|
|
27
28
|
"typescript": "^7.0.2",
|
|
28
29
|
"vitest": "^5.0.0"
|
|
29
30
|
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"!dist/**/*.map",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
30
36
|
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
31
38
|
"typecheck": "tsc --noEmit",
|
|
32
39
|
"test": "vitest run"
|
|
33
40
|
}
|
package/src/base.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { assertCan, type Permission } from '@manablox/auth';
|
|
2
|
-
import { ManabloxError, TRANSPORT_CODE } from '@manablox/core';
|
|
3
|
-
import { ORPCError, os } from '@orpc/server';
|
|
4
|
-
import type { RpcContext } from './context.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Base procedure builder. Every management procedure runs through it, so the
|
|
8
|
-
* `ManabloxError` → transport mapping lives in exactly one place.
|
|
9
|
-
*/
|
|
10
|
-
export const base = os.$context<RpcContext>().use(async ({ next }) => {
|
|
11
|
-
try {
|
|
12
|
-
return await next();
|
|
13
|
-
} catch (error) {
|
|
14
|
-
throw toOrpcError(error);
|
|
15
|
-
}
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
export function toOrpcError(error: unknown): unknown {
|
|
19
|
-
if (!ManabloxError.is(error)) return error;
|
|
20
|
-
|
|
21
|
-
return new ORPCError(TRANSPORT_CODE[error.kind], {
|
|
22
|
-
message: error.key,
|
|
23
|
-
// Structured details survive to the client: key, path and params per problem.
|
|
24
|
-
data: { key: error.key, details: error.details },
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Requires an authenticated principal. */
|
|
29
|
-
export const authed = base.use(async ({ context, next }) => {
|
|
30
|
-
if (!context.principal) throw toOrpcError(ManabloxError.unauthorized());
|
|
31
|
-
return next({ context: { ...context, principal: context.principal } });
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Requires a permission in the space named by the input's `spaceId`.
|
|
36
|
-
*
|
|
37
|
-
* Authorisation is a middleware rather than a call at the top of each handler, so a new
|
|
38
|
-
* procedure cannot forget it — the input type makes `spaceId` mandatory.
|
|
39
|
-
*/
|
|
40
|
-
export function scoped(permission: Permission) {
|
|
41
|
-
return authed.use(async ({ context, next }, input: unknown) => {
|
|
42
|
-
// The middleware sees the input before the schema runs, so a procedure whose space
|
|
43
|
-
// sits inside a `filter` object (the listings) is read there too.
|
|
44
|
-
const raw = input as { spaceId?: string; filter?: { spaceId?: string } } | undefined;
|
|
45
|
-
const spaceId = raw?.spaceId ?? raw?.filter?.spaceId ?? null;
|
|
46
|
-
assertCan(context.principal, spaceId, permission);
|
|
47
|
-
return next();
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Requires an instance-wide superadmin, for operations that are not space-scoped. */
|
|
52
|
-
export const superadmin = authed.use(async ({ context, next }) => {
|
|
53
|
-
// A space-restricted API key is confined to those spaces, so it never reaches an
|
|
54
|
-
// instance-wide operation even when its owner is a superadmin.
|
|
55
|
-
if (context.principal?.role !== 'superadmin' || context.principal.allowedSpaceIds) {
|
|
56
|
-
throw toOrpcError(ManabloxError.forbidden('auth.superadminRequired'));
|
|
57
|
-
}
|
|
58
|
-
return next();
|
|
59
|
-
});
|
package/src/context.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import type { ApiKeyService, ManabloxAuth, Principal, UserService } from '@manablox/auth';
|
|
2
|
-
import type { Manablox } from '@manablox/core';
|
|
3
|
-
import type { Repositories } from '@manablox/db';
|
|
4
|
-
import type { MediaService } from '@manablox/media';
|
|
5
|
-
import type {
|
|
6
|
-
ContentService,
|
|
7
|
-
ContentTypeService,
|
|
8
|
-
Loaders,
|
|
9
|
-
MenuService,
|
|
10
|
-
RoleService,
|
|
11
|
-
SpaceService,
|
|
12
|
-
} from '@manablox/services';
|
|
13
|
-
import type { WorkflowService } from '@manablox/workflows';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* The part of the host's runtime the management procedures reach for. The host's own
|
|
17
|
-
* `Runtime` extends this, so adding a service there and here is the whole change.
|
|
18
|
-
*/
|
|
19
|
-
export interface RpcRuntime {
|
|
20
|
-
manablox: Manablox;
|
|
21
|
-
repos: Repositories;
|
|
22
|
-
auth: ManabloxAuth;
|
|
23
|
-
apiKeys: ApiKeyService;
|
|
24
|
-
media: MediaService;
|
|
25
|
-
content: ContentService;
|
|
26
|
-
contentTypes: ContentTypeService;
|
|
27
|
-
spaces: SpaceService;
|
|
28
|
-
users: UserService;
|
|
29
|
-
menus: MenuService;
|
|
30
|
-
roles: RoleService;
|
|
31
|
-
workflows: WorkflowService;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** The runtime's RPC-facing slice, without whatever else the host keeps on it. */
|
|
35
|
-
export function pickRpcRuntime(runtime: RpcRuntime): RpcRuntime {
|
|
36
|
-
const {
|
|
37
|
-
manablox,
|
|
38
|
-
repos,
|
|
39
|
-
auth,
|
|
40
|
-
apiKeys,
|
|
41
|
-
media,
|
|
42
|
-
content,
|
|
43
|
-
contentTypes,
|
|
44
|
-
spaces,
|
|
45
|
-
users,
|
|
46
|
-
menus,
|
|
47
|
-
roles,
|
|
48
|
-
workflows,
|
|
49
|
-
} = runtime;
|
|
50
|
-
return {
|
|
51
|
-
manablox,
|
|
52
|
-
repos,
|
|
53
|
-
auth,
|
|
54
|
-
apiKeys,
|
|
55
|
-
media,
|
|
56
|
-
content,
|
|
57
|
-
contentTypes,
|
|
58
|
-
spaces,
|
|
59
|
-
users,
|
|
60
|
-
menus,
|
|
61
|
-
roles,
|
|
62
|
-
workflows,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface RpcContext extends RpcRuntime {
|
|
67
|
-
loaders: Loaders;
|
|
68
|
-
principal: Principal | null;
|
|
69
|
-
headers: Headers;
|
|
70
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { assetRouter } from './routers/asset.js';
|
|
2
|
-
import { contentRouter } from './routers/content.js';
|
|
3
|
-
import { contentTypeRouter } from './routers/content-type.js';
|
|
4
|
-
import { menuRouter } from './routers/menu.js';
|
|
5
|
-
import { roleRouter } from './routers/role.js';
|
|
6
|
-
import { spaceRouter } from './routers/space.js';
|
|
7
|
-
import { userRouter } from './routers/user.js';
|
|
8
|
-
import { workflowRouter } from './routers/workflow.js';
|
|
9
|
-
|
|
10
|
-
export * from './base.js';
|
|
11
|
-
export * from './context.js';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* The management API. The admin imports the *type* of this object and gets end-to-end
|
|
15
|
-
* safety with no codegen step.
|
|
16
|
-
*/
|
|
17
|
-
export const router = {
|
|
18
|
-
content: contentRouter,
|
|
19
|
-
contentTypes: contentTypeRouter,
|
|
20
|
-
spaces: spaceRouter,
|
|
21
|
-
assets: assetRouter,
|
|
22
|
-
users: userRouter,
|
|
23
|
-
menus: menuRouter,
|
|
24
|
-
roles: roleRouter,
|
|
25
|
-
workflows: workflowRouter,
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export type ManabloxRouter = typeof router;
|
package/src/routers/asset.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import { scoped } from '../base.js';
|
|
3
|
-
import { pagination, searchTerm, uuid } from '../schemas.js';
|
|
4
|
-
|
|
5
|
-
const crop = z.object({
|
|
6
|
-
left: z.number().int().min(0),
|
|
7
|
-
top: z.number().int().min(0),
|
|
8
|
-
width: z.number().int().min(1),
|
|
9
|
-
height: z.number().int().min(1),
|
|
10
|
-
});
|
|
11
|
-
const focalPoint = z.object({ x: z.number().min(0).max(1), y: z.number().min(0).max(1) });
|
|
12
|
-
|
|
13
|
-
export const assetRouter = {
|
|
14
|
-
/** What an upload into this space is held to, and the instance's ceiling above it. */
|
|
15
|
-
limits: scoped('asset:read')
|
|
16
|
-
.input(z.object({ spaceId: uuid }))
|
|
17
|
-
.handler(async ({ input, context }) => context.media.limits(input.spaceId)),
|
|
18
|
-
|
|
19
|
-
list: scoped('asset:read')
|
|
20
|
-
.input(
|
|
21
|
-
pagination({ limit: 40, max: 100 }).extend({
|
|
22
|
-
spaceId: uuid,
|
|
23
|
-
mimeType: z.string().optional(),
|
|
24
|
-
search: searchTerm.optional(),
|
|
25
|
-
}),
|
|
26
|
-
)
|
|
27
|
-
.handler(async ({ input, context }) => {
|
|
28
|
-
const page = await context.repos.assets.list(
|
|
29
|
-
{
|
|
30
|
-
spaceId: input.spaceId,
|
|
31
|
-
...(input.mimeType ? { mimeType: input.mimeType } : {}),
|
|
32
|
-
...(input.search ? { search: input.search } : {}),
|
|
33
|
-
},
|
|
34
|
-
{ limit: input.limit, offset: input.offset },
|
|
35
|
-
);
|
|
36
|
-
return { ...page, items: page.items.map((asset) => context.media.present(asset)) };
|
|
37
|
-
}),
|
|
38
|
-
|
|
39
|
-
/** Several assets in one round trip, for a field that references them; missing ids are absent. */
|
|
40
|
-
getMany: scoped('asset:read')
|
|
41
|
-
.input(z.object({ spaceId: uuid, ids: z.array(uuid).max(200) }))
|
|
42
|
-
.handler(async ({ input, context }) => {
|
|
43
|
-
const rows = await context.repos.assets.findManyByIds(input.ids, input.spaceId);
|
|
44
|
-
return rows.map((asset) => context.media.present(asset));
|
|
45
|
-
}),
|
|
46
|
-
|
|
47
|
-
get: scoped('asset:read')
|
|
48
|
-
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
49
|
-
.handler(async ({ input, context }) => {
|
|
50
|
-
const asset = await context.repos.assets.findById(input.id);
|
|
51
|
-
return asset ? context.media.present(asset) : null;
|
|
52
|
-
}),
|
|
53
|
-
|
|
54
|
-
update: scoped('asset:write')
|
|
55
|
-
.input(
|
|
56
|
-
z.object({
|
|
57
|
-
spaceId: uuid,
|
|
58
|
-
id: uuid,
|
|
59
|
-
name: z.string().max(200).optional(),
|
|
60
|
-
alt: z.string().max(500).nullable().optional(),
|
|
61
|
-
title: z.string().max(500).nullable().optional(),
|
|
62
|
-
}),
|
|
63
|
-
)
|
|
64
|
-
.handler(async ({ input, context }) => {
|
|
65
|
-
const { spaceId: _spaceId, id, ...data } = input;
|
|
66
|
-
return context.repos.assets.update(id, data);
|
|
67
|
-
}),
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* An image's crop and focal point. `null` clears one; omitting it also clears it, so
|
|
71
|
-
* the call always states the whole edit. Every variant re-renders through the result.
|
|
72
|
-
*/
|
|
73
|
-
setImageEdits: scoped('asset:write')
|
|
74
|
-
.input(
|
|
75
|
-
z.object({
|
|
76
|
-
spaceId: uuid,
|
|
77
|
-
id: uuid,
|
|
78
|
-
crop: crop.nullable().optional(),
|
|
79
|
-
focalPoint: focalPoint.nullable().optional(),
|
|
80
|
-
}),
|
|
81
|
-
)
|
|
82
|
-
.handler(async ({ input, context }) => {
|
|
83
|
-
const asset = await context.media.setImageEdits(input.id, {
|
|
84
|
-
crop: input.crop ?? null,
|
|
85
|
-
focalPoint: input.focalPoint ?? null,
|
|
86
|
-
});
|
|
87
|
-
return context.media.present(asset);
|
|
88
|
-
}),
|
|
89
|
-
|
|
90
|
-
delete: scoped('asset:delete')
|
|
91
|
-
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
92
|
-
.handler(async ({ input, context }) => {
|
|
93
|
-
await context.media.delete(input.id);
|
|
94
|
-
return { ok: true };
|
|
95
|
-
}),
|
|
96
|
-
};
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { renderContentTypeConfig } from '@manablox/services';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import { base, scoped, superadmin } from '../base.js';
|
|
4
|
-
import { uuid } from '../schemas.js';
|
|
5
|
-
|
|
6
|
-
const fieldSchema = z.object({
|
|
7
|
-
id: z.string().optional(),
|
|
8
|
-
name: z.string().min(1).max(64),
|
|
9
|
-
label: z.string().optional(),
|
|
10
|
-
type: z.string(),
|
|
11
|
-
settings: z.record(z.string(), z.unknown()).default({}),
|
|
12
|
-
required: z.boolean().default(false),
|
|
13
|
-
localized: z.boolean().default(false),
|
|
14
|
-
unique: z.boolean().default(false),
|
|
15
|
-
readRoles: z.array(z.string()).optional(),
|
|
16
|
-
writeRoles: z.array(z.string()).optional(),
|
|
17
|
-
admin: z
|
|
18
|
-
.object({
|
|
19
|
-
zone: z.enum(['main', 'sidebar']).default('main'),
|
|
20
|
-
width: z.number().int().min(25).max(100).default(100),
|
|
21
|
-
position: z.number().int().default(0),
|
|
22
|
-
help: z.string().optional(),
|
|
23
|
-
placeholder: z.string().optional(),
|
|
24
|
-
})
|
|
25
|
-
.optional(),
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const contentTypeSchema = z.object({
|
|
29
|
-
name: z.string().min(1).max(64),
|
|
30
|
-
label: z.string().optional(),
|
|
31
|
-
description: z.string().optional(),
|
|
32
|
-
icon: z.string().optional(),
|
|
33
|
-
kind: z.enum(['content', 'block']).default('content'),
|
|
34
|
-
spaceId: uuid.nullable().default(null),
|
|
35
|
-
hasSlug: z.boolean().optional(),
|
|
36
|
-
isPublishable: z.boolean().optional(),
|
|
37
|
-
isVisibleInTree: z.boolean().optional(),
|
|
38
|
-
canBeVisibleInMenu: z.boolean().optional(),
|
|
39
|
-
fields: z.array(fieldSchema).default([]),
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
export const contentTypeRouter = {
|
|
43
|
-
list: scoped('contentType:read')
|
|
44
|
-
.input(z.object({ spaceId: uuid }))
|
|
45
|
-
.handler(async ({ input, context }) => context.contentTypes.list(input.spaceId)),
|
|
46
|
-
|
|
47
|
-
get: scoped('contentType:read')
|
|
48
|
-
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
49
|
-
.handler(async ({ input, context }) => context.contentTypes.get(input.id)),
|
|
50
|
-
|
|
51
|
-
create: scoped('contentType:write')
|
|
52
|
-
.input(contentTypeSchema.extend({ spaceId: uuid }))
|
|
53
|
-
.handler(async ({ input, context }) =>
|
|
54
|
-
context.contentTypes.create(input, context.principal.userId),
|
|
55
|
-
),
|
|
56
|
-
|
|
57
|
-
update: scoped('contentType:write')
|
|
58
|
-
.input(contentTypeSchema.extend({ spaceId: uuid, id: uuid }))
|
|
59
|
-
.handler(async ({ input, context }) => context.contentTypes.update(input.id, input)),
|
|
60
|
-
|
|
61
|
-
delete: scoped('contentType:delete')
|
|
62
|
-
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
63
|
-
.handler(async ({ input, context }) => {
|
|
64
|
-
await context.contentTypes.delete(input.id);
|
|
65
|
-
return { ok: true };
|
|
66
|
-
}),
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* The field-type catalogue the admin's "add field" menu is built from. Derived from
|
|
70
|
-
* the registry, so a plugin's field type appears in the menu with no admin change.
|
|
71
|
-
*/
|
|
72
|
-
fieldTypes: base.handler(async ({ context }) =>
|
|
73
|
-
context.manablox.fieldTypes.all.map((type) => ({
|
|
74
|
-
name: type.name,
|
|
75
|
-
label: type.label,
|
|
76
|
-
icon: type.icon ?? null,
|
|
77
|
-
description: type.description ?? null,
|
|
78
|
-
nested: type.nested ?? false,
|
|
79
|
-
filters: type.filters,
|
|
80
|
-
admin: type.admin,
|
|
81
|
-
})),
|
|
82
|
-
),
|
|
83
|
-
|
|
84
|
-
/** JSON Schema for one field type's settings, so the admin renders its form generically. */
|
|
85
|
-
fieldTypeSettingsSchema: base
|
|
86
|
-
.input(z.object({ name: z.string() }))
|
|
87
|
-
.handler(async ({ input, context }) => {
|
|
88
|
-
const type = context.manablox.fieldTypes.get(input.name);
|
|
89
|
-
const schema = type.settingsSchema as unknown as { toJSONSchema?: () => unknown };
|
|
90
|
-
return {
|
|
91
|
-
name: type.name,
|
|
92
|
-
jsonSchema: typeof schema.toJSONSchema === 'function' ? schema.toJSONSchema() : null,
|
|
93
|
-
};
|
|
94
|
-
}),
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* The space's runtime types rendered as `manablox.config.ts` source, for moving a type
|
|
98
|
-
* built in the admin into code where it can be reviewed and versioned.
|
|
99
|
-
*/
|
|
100
|
-
config: scoped('contentType:read')
|
|
101
|
-
.input(z.object({ spaceId: uuid, ids: z.array(uuid).optional() }))
|
|
102
|
-
.handler(async ({ input, context }) => {
|
|
103
|
-
const wanted = input.ids?.length ? new Set(input.ids) : null;
|
|
104
|
-
const types = context.manablox.contentTypes
|
|
105
|
-
.forSpace(input.spaceId)
|
|
106
|
-
// Code-defined types already live in a config file; re-emitting them would invite
|
|
107
|
-
// a second, diverging definition of the same type.
|
|
108
|
-
.filter((type) => type.source !== 'code' && (!wanted || wanted.has(type.id)));
|
|
109
|
-
|
|
110
|
-
return { code: renderContentTypeConfig(types), count: types.length };
|
|
111
|
-
}),
|
|
112
|
-
|
|
113
|
-
reload: superadmin.handler(async ({ context }) => {
|
|
114
|
-
await context.manablox.reload(await context.repos.contentTypes.all());
|
|
115
|
-
return { schemaVersion: context.manablox.contentTypes.schemaVersion };
|
|
116
|
-
}),
|
|
117
|
-
};
|
package/src/routers/content.ts
DELETED
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
import { actorRoles, allowedTypeIds, assertCan, type ContentPermission } from '@manablox/auth';
|
|
2
|
-
import { ManabloxError } from '@manablox/core';
|
|
3
|
-
import { z } from 'zod';
|
|
4
|
-
import { base, scoped, toOrpcError } from '../base.js';
|
|
5
|
-
import type { RpcContext } from '../context.js';
|
|
6
|
-
import { locale, pagination, searchTerm, uuid } from '../schemas.js';
|
|
7
|
-
|
|
8
|
-
const filterSchema = z.object({
|
|
9
|
-
spaceId: uuid,
|
|
10
|
-
/** Specific documents, for a relation field's chips: one request instead of one per id. */
|
|
11
|
-
ids: z.array(uuid).max(200).optional(),
|
|
12
|
-
typeIds: z.array(uuid).optional(),
|
|
13
|
-
locale: locale.optional(),
|
|
14
|
-
status: z.enum(['draft', 'published', 'archived']).optional(),
|
|
15
|
-
parentId: uuid.nullable().optional(),
|
|
16
|
-
under: uuid.optional(),
|
|
17
|
-
search: searchTerm.optional(),
|
|
18
|
-
fields: z
|
|
19
|
-
.array(
|
|
20
|
-
z.object({
|
|
21
|
-
name: z.string(),
|
|
22
|
-
op: z.enum([
|
|
23
|
-
'eq',
|
|
24
|
-
'neq',
|
|
25
|
-
'lt',
|
|
26
|
-
'lte',
|
|
27
|
-
'gt',
|
|
28
|
-
'gte',
|
|
29
|
-
'in',
|
|
30
|
-
'notIn',
|
|
31
|
-
'contains',
|
|
32
|
-
'startsWith',
|
|
33
|
-
'endsWith',
|
|
34
|
-
'isNull',
|
|
35
|
-
'isNotNull',
|
|
36
|
-
]),
|
|
37
|
-
value: z.unknown().optional(),
|
|
38
|
-
}),
|
|
39
|
-
)
|
|
40
|
-
.max(10)
|
|
41
|
-
.optional(),
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
const paginationSchema = pagination({ limit: 25, max: 200 });
|
|
45
|
-
|
|
46
|
-
const sortSchema = z
|
|
47
|
-
.array(
|
|
48
|
-
z.object({
|
|
49
|
-
by: z.enum(['position', 'title', 'createdAt', 'updatedAt', 'publishedAt', 'slug']),
|
|
50
|
-
direction: z.enum(['asc', 'desc']).default('asc'),
|
|
51
|
-
}),
|
|
52
|
-
)
|
|
53
|
-
.max(3);
|
|
54
|
-
|
|
55
|
-
const saveSchema = z.object({
|
|
56
|
-
spaceId: uuid,
|
|
57
|
-
typeId: uuid,
|
|
58
|
-
locale: locale.default('en'),
|
|
59
|
-
localizationId: uuid.optional(),
|
|
60
|
-
parentId: uuid.nullable().optional(),
|
|
61
|
-
title: z.string().min(1).max(500),
|
|
62
|
-
slug: z.string().max(200).optional(),
|
|
63
|
-
fields: z.record(z.string(), z.unknown()).default({}),
|
|
64
|
-
position: z.number().int().optional(),
|
|
65
|
-
expectedVersion: z.number().int().positive().optional(),
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
export const contentRouter = {
|
|
69
|
-
list: scoped('content:read')
|
|
70
|
-
.input(
|
|
71
|
-
z
|
|
72
|
-
.object({
|
|
73
|
-
filter: filterSchema,
|
|
74
|
-
pagination: paginationSchema.optional(),
|
|
75
|
-
sort: sortSchema.optional(),
|
|
76
|
-
})
|
|
77
|
-
.transform((v) => ({ ...v, spaceId: v.filter.spaceId })),
|
|
78
|
-
)
|
|
79
|
-
.handler(async ({ input, context }) => {
|
|
80
|
-
// A role that reads type by type sees only those types; asked for one it may not
|
|
81
|
-
// read, the answer is an empty page rather than a refusal.
|
|
82
|
-
const filter = narrowToAllowed(context, input.filter);
|
|
83
|
-
if (!filter) {
|
|
84
|
-
const pagination = input.pagination ?? { limit: 25, offset: 0 };
|
|
85
|
-
return { items: [], total: 0, ...pagination };
|
|
86
|
-
}
|
|
87
|
-
return context.content.list(
|
|
88
|
-
filter,
|
|
89
|
-
input.pagination ?? { limit: 25, offset: 0 },
|
|
90
|
-
input.sort ?? [],
|
|
91
|
-
{ actor: toActor(context, input.filter.spaceId) },
|
|
92
|
-
);
|
|
93
|
-
}),
|
|
94
|
-
|
|
95
|
-
tree: scoped('content:read')
|
|
96
|
-
.input(
|
|
97
|
-
z.object({
|
|
98
|
-
spaceId: uuid,
|
|
99
|
-
locale: locale.default('en'),
|
|
100
|
-
rootId: uuid.nullable().default(null),
|
|
101
|
-
}),
|
|
102
|
-
)
|
|
103
|
-
.handler(async ({ input, context }) =>
|
|
104
|
-
context.content.tree(input.spaceId, input.locale, input.rootId),
|
|
105
|
-
),
|
|
106
|
-
|
|
107
|
-
get: scoped('content:read')
|
|
108
|
-
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
109
|
-
.handler(async ({ input, context }) => {
|
|
110
|
-
await assertOnDocument(context, input.spaceId, 'content:read', input.id);
|
|
111
|
-
return context.content.get(input.id, { actor: toActor(context, input.spaceId) });
|
|
112
|
-
}),
|
|
113
|
-
|
|
114
|
-
/** Field values with defaults filled in — what the editor opens a new document with. */
|
|
115
|
-
blank: scoped('content:read')
|
|
116
|
-
.input(z.object({ spaceId: uuid, typeId: uuid }))
|
|
117
|
-
.handler(async ({ input, context }) => {
|
|
118
|
-
assertOnType(context, input.spaceId, 'content:read', input.typeId);
|
|
119
|
-
return {
|
|
120
|
-
fields: await context.content.initFields(context.manablox.contentTypes.get(input.typeId)),
|
|
121
|
-
};
|
|
122
|
-
}),
|
|
123
|
-
|
|
124
|
-
create: scoped('content:write')
|
|
125
|
-
.input(saveSchema)
|
|
126
|
-
.handler(async ({ input, context }) => {
|
|
127
|
-
assertOnType(context, input.spaceId, 'content:write', input.typeId);
|
|
128
|
-
return context.content.create(input, toActor(context, input.spaceId));
|
|
129
|
-
}),
|
|
130
|
-
|
|
131
|
-
update: scoped('content:write')
|
|
132
|
-
.input(saveSchema.extend({ id: uuid }))
|
|
133
|
-
.handler(async ({ input, context }) => {
|
|
134
|
-
assertOnType(context, input.spaceId, 'content:write', input.typeId);
|
|
135
|
-
return context.content.update(input.id, input, toActor(context, input.spaceId));
|
|
136
|
-
}),
|
|
137
|
-
|
|
138
|
-
delete: scoped('content:delete')
|
|
139
|
-
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
140
|
-
.handler(async ({ input, context }) => {
|
|
141
|
-
await assertOnDocument(context, input.spaceId, 'content:delete', input.id);
|
|
142
|
-
return { deleted: await context.content.delete(input.id, toActor(context, input.spaceId)) };
|
|
143
|
-
}),
|
|
144
|
-
|
|
145
|
-
publish: scoped('content:publish')
|
|
146
|
-
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
147
|
-
.handler(async ({ input, context }) => {
|
|
148
|
-
await assertOnDocument(context, input.spaceId, 'content:publish', input.id);
|
|
149
|
-
return context.content.publish(input.id, toActor(context, input.spaceId));
|
|
150
|
-
}),
|
|
151
|
-
|
|
152
|
-
unpublish: scoped('content:publish')
|
|
153
|
-
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
154
|
-
.handler(async ({ input, context }) => {
|
|
155
|
-
await assertOnDocument(context, input.spaceId, 'content:publish', input.id);
|
|
156
|
-
await context.content.unpublish(input.id, toActor(context, input.spaceId));
|
|
157
|
-
return { ok: true };
|
|
158
|
-
}),
|
|
159
|
-
|
|
160
|
-
/** Reparent or reorder a document in the tree — a drag in the admin's tree panel. */
|
|
161
|
-
move: scoped('content:write')
|
|
162
|
-
.input(
|
|
163
|
-
z.object({
|
|
164
|
-
spaceId: uuid,
|
|
165
|
-
id: uuid,
|
|
166
|
-
parentId: uuid.nullable(),
|
|
167
|
-
position: z.number().int().min(0),
|
|
168
|
-
}),
|
|
169
|
-
)
|
|
170
|
-
.handler(async ({ input, context }) => {
|
|
171
|
-
await assertOnDocument(context, input.spaceId, 'content:write', input.id);
|
|
172
|
-
return context.content.move(input.spaceId, input.id, input.parentId, input.position);
|
|
173
|
-
}),
|
|
174
|
-
|
|
175
|
-
/** Every locale a document exists in, for the editor's language switcher. */
|
|
176
|
-
translations: scoped('content:read')
|
|
177
|
-
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
178
|
-
.handler(async ({ input, context }) => context.content.translations(input.spaceId, input.id)),
|
|
179
|
-
|
|
180
|
-
/** Starts a translation of an existing document, in the localization group it shares. */
|
|
181
|
-
createTranslation: scoped('content:write')
|
|
182
|
-
.input(z.object({ spaceId: uuid, id: uuid, locale }))
|
|
183
|
-
.handler(async ({ input, context }) => {
|
|
184
|
-
await assertOnDocument(context, input.spaceId, 'content:write', input.id);
|
|
185
|
-
return context.content.createTranslation(
|
|
186
|
-
input.spaceId,
|
|
187
|
-
input.id,
|
|
188
|
-
input.locale,
|
|
189
|
-
toActor(context, input.spaceId),
|
|
190
|
-
);
|
|
191
|
-
}),
|
|
192
|
-
|
|
193
|
-
versions: scoped('content:read')
|
|
194
|
-
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
195
|
-
.handler(async ({ input, context }) => context.repos.content.versions(input.id)),
|
|
196
|
-
|
|
197
|
-
versionSnapshot: scoped('content:read')
|
|
198
|
-
.input(z.object({ spaceId: uuid, id: uuid, version: z.number().int().positive() }))
|
|
199
|
-
.handler(async ({ input, context }) =>
|
|
200
|
-
context.repos.content.versionSnapshot(input.id, input.version),
|
|
201
|
-
),
|
|
202
|
-
|
|
203
|
-
restore: scoped('content:write')
|
|
204
|
-
.input(z.object({ spaceId: uuid, id: uuid, version: z.number().int().positive() }))
|
|
205
|
-
.handler(async ({ input, context }) => {
|
|
206
|
-
await assertOnDocument(context, input.spaceId, 'content:write', input.id);
|
|
207
|
-
return context.content.restore(input.id, input.version, toActor(context, input.spaceId));
|
|
208
|
-
}),
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
// ---------------------------------------------------------------------------
|
|
212
|
-
// Per-type permissions
|
|
213
|
-
//
|
|
214
|
-
// `scoped('content:…')` admits anyone whose role holds the action for *some* type; which
|
|
215
|
-
// type is only known once the input, or the document it names, is in hand. These finish
|
|
216
|
-
// the check.
|
|
217
|
-
// ---------------------------------------------------------------------------
|
|
218
|
-
|
|
219
|
-
function assertOnType(
|
|
220
|
-
context: RpcContext,
|
|
221
|
-
spaceId: string,
|
|
222
|
-
permission: ContentPermission,
|
|
223
|
-
typeId: string,
|
|
224
|
-
): void {
|
|
225
|
-
try {
|
|
226
|
-
assertCan(context.principal, spaceId, permission, typeId);
|
|
227
|
-
} catch (error) {
|
|
228
|
-
throw toOrpcError(error);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
async function assertOnDocument(
|
|
233
|
-
context: RpcContext,
|
|
234
|
-
spaceId: string,
|
|
235
|
-
permission: ContentPermission,
|
|
236
|
-
id: string,
|
|
237
|
-
): Promise<void> {
|
|
238
|
-
// Only when the role narrows the action: the common case costs no extra read.
|
|
239
|
-
if (allowedTypeIds(context.principal, spaceId, permission) === null) return;
|
|
240
|
-
const row = await context.repos.content.findById(id);
|
|
241
|
-
if (!row || row.spaceId !== spaceId) {
|
|
242
|
-
throw toOrpcError(ManabloxError.notFound('content.notFound', { id }));
|
|
243
|
-
}
|
|
244
|
-
assertOnType(context, spaceId, permission, row.typeId);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** The filter narrowed to the types the caller may read; `null` when that leaves none. */
|
|
248
|
-
function narrowToAllowed<T extends { spaceId: string; typeIds?: string[] | undefined }>(
|
|
249
|
-
context: RpcContext,
|
|
250
|
-
filter: T,
|
|
251
|
-
): T | null {
|
|
252
|
-
const allowed = allowedTypeIds(context.principal, filter.spaceId, 'content:read');
|
|
253
|
-
if (allowed === null) return filter;
|
|
254
|
-
const typeIds = filter.typeIds?.length
|
|
255
|
-
? filter.typeIds.filter((typeId) => allowed.includes(typeId))
|
|
256
|
-
: allowed;
|
|
257
|
-
return typeIds.length ? { ...filter, typeIds } : null;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
export { base };
|
|
261
|
-
|
|
262
|
-
function toActor(context: { principal: unknown }, spaceId: string) {
|
|
263
|
-
const principal = context.principal as { userId: string } | null;
|
|
264
|
-
if (!principal) return null;
|
|
265
|
-
return { userId: principal.userId, roles: actorRoles(principal as never, spaceId) };
|
|
266
|
-
}
|