@manablox/api-rpc 0.1.0 → 0.2.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/README.md +20 -0
- package/package.json +9 -7
- package/src/base.ts +6 -15
- package/src/context.ts +55 -3
- package/src/index.ts +6 -0
- package/src/routers/asset.ts +46 -17
- package/src/routers/content-type.ts +4 -3
- package/src/routers/content.ts +114 -48
- package/src/routers/menu.ts +77 -0
- package/src/routers/role.ts +51 -0
- package/src/routers/space.ts +76 -194
- package/src/routers/user.ts +117 -10
- package/src/routers/workflow.ts +253 -0
- package/src/schemas.ts +40 -0
- package/test/asset.test.ts +144 -0
- package/test/content.test.ts +256 -0
- package/test/helpers.ts +163 -0
- package/test/menu.test.ts +192 -0
- package/test/role.test.ts +205 -0
- package/test/space.test.ts +170 -0
- package/test/user.test.ts +212 -0
- package/test/workflow.test.ts +259 -0
- package/vitest.config.ts +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# `@manablox/api-rpc`
|
|
2
|
+
|
|
3
|
+
The management API as an oRPC router: input schemas, permission middleware and one service call per procedure. The admin imports `ManabloxRouter` as a type and gets an end-to-end typed client with no code generation; `apps/api` also serves the same router as REST with an OpenAPI document.
|
|
4
|
+
|
|
5
|
+
## Exports
|
|
6
|
+
|
|
7
|
+
- `router` / `ManabloxRouter`
|
|
8
|
+
- `base`, `authed`, `scoped(permission)`, `superadmin` — the procedure builders
|
|
9
|
+
- `RpcContext`, `RpcRuntime`, `pickRpcRuntime`
|
|
10
|
+
- `schemas` — `uuid`, `locale`, `machineName`, `pagination()`, …
|
|
11
|
+
|
|
12
|
+
## Depends on
|
|
13
|
+
|
|
14
|
+
@manablox/services, @manablox/auth, @orpc/server, zod
|
|
15
|
+
|
|
16
|
+
## Test
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pnpm --filter @manablox/api-rpc test
|
|
20
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manablox/api-rpc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
"main": "./src/index.ts",
|
|
12
12
|
"types": "./src/index.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@manablox/auth": "0.
|
|
15
|
-
"@manablox/core": "0.
|
|
16
|
-
"@manablox/db": "0.
|
|
17
|
-
"@manablox/media": "0.
|
|
18
|
-
"@manablox/services": "0.
|
|
14
|
+
"@manablox/auth": "0.2.0",
|
|
15
|
+
"@manablox/core": "0.2.0",
|
|
16
|
+
"@manablox/db": "0.2.0",
|
|
17
|
+
"@manablox/media": "0.2.0",
|
|
18
|
+
"@manablox/services": "0.2.0",
|
|
19
|
+
"@manablox/workflows": "0.2.0",
|
|
19
20
|
"@orpc/server": "^1.15.0",
|
|
20
21
|
"@orpc/openapi": "^1.15.0",
|
|
21
22
|
"zod": "^4.5.4"
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
"vitest": "^5.0.0"
|
|
28
29
|
},
|
|
29
30
|
"scripts": {
|
|
30
|
-
"typecheck": "tsc --noEmit"
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"test": "vitest run"
|
|
31
33
|
}
|
|
32
34
|
}
|
package/src/base.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assertCan, type Permission } from '@manablox/auth';
|
|
2
|
-
import { ManabloxError } from '@manablox/core';
|
|
2
|
+
import { ManabloxError, TRANSPORT_CODE } from '@manablox/core';
|
|
3
3
|
import { ORPCError, os } from '@orpc/server';
|
|
4
4
|
import type { RpcContext } from './context.js';
|
|
5
5
|
|
|
@@ -18,19 +18,7 @@ export const base = os.$context<RpcContext>().use(async ({ next }) => {
|
|
|
18
18
|
export function toOrpcError(error: unknown): unknown {
|
|
19
19
|
if (!ManabloxError.is(error)) return error;
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
{
|
|
23
|
-
validation: 'BAD_REQUEST',
|
|
24
|
-
bad_request: 'BAD_REQUEST',
|
|
25
|
-
not_found: 'NOT_FOUND',
|
|
26
|
-
conflict: 'CONFLICT',
|
|
27
|
-
forbidden: 'FORBIDDEN',
|
|
28
|
-
unauthorized: 'UNAUTHORIZED',
|
|
29
|
-
internal: 'INTERNAL_SERVER_ERROR',
|
|
30
|
-
} as const
|
|
31
|
-
)[error.kind];
|
|
32
|
-
|
|
33
|
-
return new ORPCError(code, {
|
|
21
|
+
return new ORPCError(TRANSPORT_CODE[error.kind], {
|
|
34
22
|
message: error.key,
|
|
35
23
|
// Structured details survive to the client: key, path and params per problem.
|
|
36
24
|
data: { key: error.key, details: error.details },
|
|
@@ -51,7 +39,10 @@ export const authed = base.use(async ({ context, next }) => {
|
|
|
51
39
|
*/
|
|
52
40
|
export function scoped(permission: Permission) {
|
|
53
41
|
return authed.use(async ({ context, next }, input: unknown) => {
|
|
54
|
-
|
|
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;
|
|
55
46
|
assertCan(context.principal, spaceId, permission);
|
|
56
47
|
return next();
|
|
57
48
|
});
|
package/src/context.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import type { ApiKeyService, ManabloxAuth, Principal } from '@manablox/auth';
|
|
1
|
+
import type { ApiKeyService, ManabloxAuth, Principal, UserService } from '@manablox/auth';
|
|
2
2
|
import type { Manablox } from '@manablox/core';
|
|
3
3
|
import type { Repositories } from '@manablox/db';
|
|
4
4
|
import type { MediaService } from '@manablox/media';
|
|
5
|
-
import type {
|
|
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';
|
|
6
14
|
|
|
7
|
-
|
|
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 {
|
|
8
20
|
manablox: Manablox;
|
|
9
21
|
repos: Repositories;
|
|
10
22
|
auth: ManabloxAuth;
|
|
@@ -12,6 +24,46 @@ export interface RpcContext {
|
|
|
12
24
|
media: MediaService;
|
|
13
25
|
content: ContentService;
|
|
14
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 {
|
|
15
67
|
loaders: Loaders;
|
|
16
68
|
principal: Principal | null;
|
|
17
69
|
headers: Headers;
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { assetRouter } from './routers/asset.js';
|
|
2
2
|
import { contentRouter } from './routers/content.js';
|
|
3
3
|
import { contentTypeRouter } from './routers/content-type.js';
|
|
4
|
+
import { menuRouter } from './routers/menu.js';
|
|
5
|
+
import { roleRouter } from './routers/role.js';
|
|
4
6
|
import { spaceRouter } from './routers/space.js';
|
|
5
7
|
import { userRouter } from './routers/user.js';
|
|
8
|
+
import { workflowRouter } from './routers/workflow.js';
|
|
6
9
|
|
|
7
10
|
export * from './base.js';
|
|
8
11
|
export * from './context.js';
|
|
@@ -17,6 +20,9 @@ export const router = {
|
|
|
17
20
|
spaces: spaceRouter,
|
|
18
21
|
assets: assetRouter,
|
|
19
22
|
users: userRouter,
|
|
23
|
+
menus: menuRouter,
|
|
24
|
+
roles: roleRouter,
|
|
25
|
+
workflows: workflowRouter,
|
|
20
26
|
};
|
|
21
27
|
|
|
22
28
|
export type ManabloxRouter = typeof router;
|
package/src/routers/asset.ts
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { scoped } from '../base.js';
|
|
3
|
+
import { pagination, searchTerm, uuid } from '../schemas.js';
|
|
3
4
|
|
|
4
|
-
const
|
|
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) });
|
|
5
12
|
|
|
6
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
|
+
|
|
7
19
|
list: scoped('asset:read')
|
|
8
20
|
.input(
|
|
9
|
-
|
|
21
|
+
pagination({ limit: 40, max: 100 }).extend({
|
|
10
22
|
spaceId: uuid,
|
|
11
23
|
mimeType: z.string().optional(),
|
|
12
|
-
search:
|
|
13
|
-
limit: z.number().int().min(1).max(100).default(40),
|
|
14
|
-
offset: z.number().int().min(0).default(0),
|
|
24
|
+
search: searchTerm.optional(),
|
|
15
25
|
}),
|
|
16
26
|
)
|
|
17
27
|
.handler(async ({ input, context }) => {
|
|
@@ -23,24 +33,22 @@ export const assetRouter = {
|
|
|
23
33
|
},
|
|
24
34
|
{ limit: input.limit, offset: input.offset },
|
|
25
35
|
);
|
|
26
|
-
return {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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));
|
|
36
45
|
}),
|
|
37
46
|
|
|
38
47
|
get: scoped('asset:read')
|
|
39
48
|
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
40
49
|
.handler(async ({ input, context }) => {
|
|
41
50
|
const asset = await context.repos.assets.findById(input.id);
|
|
42
|
-
|
|
43
|
-
return { ...asset, url: context.media.urlFor(asset) };
|
|
51
|
+
return asset ? context.media.present(asset) : null;
|
|
44
52
|
}),
|
|
45
53
|
|
|
46
54
|
update: scoped('asset:write')
|
|
@@ -58,6 +66,27 @@ export const assetRouter = {
|
|
|
58
66
|
return context.repos.assets.update(id, data);
|
|
59
67
|
}),
|
|
60
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
|
+
|
|
61
90
|
delete: scoped('asset:delete')
|
|
62
91
|
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
63
92
|
.handler(async ({ input, context }) => {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { renderContentTypeConfig } from '@manablox/services';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { base, scoped, superadmin } from '../base.js';
|
|
4
|
-
|
|
5
|
-
const uuid = z.string().uuid();
|
|
4
|
+
import { uuid } from '../schemas.js';
|
|
6
5
|
|
|
7
6
|
const fieldSchema = z.object({
|
|
8
7
|
id: z.string().optional(),
|
|
@@ -51,7 +50,9 @@ export const contentTypeRouter = {
|
|
|
51
50
|
|
|
52
51
|
create: scoped('contentType:write')
|
|
53
52
|
.input(contentTypeSchema.extend({ spaceId: uuid }))
|
|
54
|
-
.handler(async ({ input, context }) =>
|
|
53
|
+
.handler(async ({ input, context }) =>
|
|
54
|
+
context.contentTypes.create(input, context.principal.userId),
|
|
55
|
+
),
|
|
55
56
|
|
|
56
57
|
update: scoped('contentType:write')
|
|
57
58
|
.input(contentTypeSchema.extend({ spaceId: uuid, id: uuid }))
|
package/src/routers/content.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import { actorRoles } from '@manablox/auth';
|
|
1
|
+
import { actorRoles, allowedTypeIds, assertCan, type ContentPermission } from '@manablox/auth';
|
|
2
|
+
import { ManabloxError } from '@manablox/core';
|
|
2
3
|
import { z } from 'zod';
|
|
3
|
-
import { base, scoped } from '../base.js';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import { base, scoped, toOrpcError } from '../base.js';
|
|
5
|
+
import type { RpcContext } from '../context.js';
|
|
6
|
+
import { locale, pagination, searchTerm, uuid } from '../schemas.js';
|
|
6
7
|
|
|
7
8
|
const filterSchema = z.object({
|
|
8
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(),
|
|
9
12
|
typeIds: z.array(uuid).optional(),
|
|
10
|
-
locale:
|
|
13
|
+
locale: locale.optional(),
|
|
11
14
|
status: z.enum(['draft', 'published', 'archived']).optional(),
|
|
12
15
|
parentId: uuid.nullable().optional(),
|
|
13
16
|
under: uuid.optional(),
|
|
14
|
-
search:
|
|
15
|
-
visibleInMenu: z.boolean().optional(),
|
|
17
|
+
search: searchTerm.optional(),
|
|
16
18
|
fields: z
|
|
17
19
|
.array(
|
|
18
20
|
z.object({
|
|
@@ -39,10 +41,7 @@ const filterSchema = z.object({
|
|
|
39
41
|
.optional(),
|
|
40
42
|
});
|
|
41
43
|
|
|
42
|
-
const paginationSchema =
|
|
43
|
-
limit: z.number().int().min(1).max(200).default(25),
|
|
44
|
-
offset: z.number().int().min(0).default(0),
|
|
45
|
-
});
|
|
44
|
+
const paginationSchema = pagination({ limit: 25, max: 200 });
|
|
46
45
|
|
|
47
46
|
const sortSchema = z
|
|
48
47
|
.array(
|
|
@@ -56,13 +55,12 @@ const sortSchema = z
|
|
|
56
55
|
const saveSchema = z.object({
|
|
57
56
|
spaceId: uuid,
|
|
58
57
|
typeId: uuid,
|
|
59
|
-
locale:
|
|
58
|
+
locale: locale.default('en'),
|
|
60
59
|
localizationId: uuid.optional(),
|
|
61
60
|
parentId: uuid.nullable().optional(),
|
|
62
61
|
title: z.string().min(1).max(500),
|
|
63
62
|
slug: z.string().max(200).optional(),
|
|
64
63
|
fields: z.record(z.string(), z.unknown()).default({}),
|
|
65
|
-
visibleInMenu: z.boolean().optional(),
|
|
66
64
|
position: z.number().int().optional(),
|
|
67
65
|
expectedVersion: z.number().int().positive().optional(),
|
|
68
66
|
});
|
|
@@ -78,20 +76,27 @@ export const contentRouter = {
|
|
|
78
76
|
})
|
|
79
77
|
.transform((v) => ({ ...v, spaceId: v.filter.spaceId })),
|
|
80
78
|
)
|
|
81
|
-
.handler(async ({ input, context }) =>
|
|
82
|
-
|
|
83
|
-
|
|
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,
|
|
84
89
|
input.pagination ?? { limit: 25, offset: 0 },
|
|
85
90
|
input.sort ?? [],
|
|
86
91
|
{ actor: toActor(context, input.filter.spaceId) },
|
|
87
|
-
)
|
|
88
|
-
),
|
|
92
|
+
);
|
|
93
|
+
}),
|
|
89
94
|
|
|
90
95
|
tree: scoped('content:read')
|
|
91
96
|
.input(
|
|
92
97
|
z.object({
|
|
93
98
|
spaceId: uuid,
|
|
94
|
-
locale:
|
|
99
|
+
locale: locale.default('en'),
|
|
95
100
|
rootId: uuid.nullable().default(null),
|
|
96
101
|
}),
|
|
97
102
|
)
|
|
@@ -101,44 +106,53 @@ export const contentRouter = {
|
|
|
101
106
|
|
|
102
107
|
get: scoped('content:read')
|
|
103
108
|
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
104
|
-
.handler(async ({ input, context }) =>
|
|
105
|
-
context
|
|
106
|
-
|
|
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
|
+
}),
|
|
107
113
|
|
|
108
114
|
/** Field values with defaults filled in — what the editor opens a new document with. */
|
|
109
115
|
blank: scoped('content:read')
|
|
110
116
|
.input(z.object({ spaceId: uuid, typeId: uuid }))
|
|
111
|
-
.handler(async ({ input, context }) =>
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}),
|
|
114
123
|
|
|
115
124
|
create: scoped('content:write')
|
|
116
125
|
.input(saveSchema)
|
|
117
|
-
.handler(async ({ input, context }) =>
|
|
118
|
-
context.
|
|
119
|
-
|
|
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
|
+
}),
|
|
120
130
|
|
|
121
131
|
update: scoped('content:write')
|
|
122
132
|
.input(saveSchema.extend({ id: uuid }))
|
|
123
|
-
.handler(async ({ input, context }) =>
|
|
124
|
-
context
|
|
125
|
-
|
|
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
|
+
}),
|
|
126
137
|
|
|
127
138
|
delete: scoped('content:delete')
|
|
128
139
|
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
129
|
-
.handler(async ({ input, context }) =>
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}),
|
|
132
144
|
|
|
133
145
|
publish: scoped('content:publish')
|
|
134
146
|
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
135
|
-
.handler(async ({ input, context }) =>
|
|
136
|
-
context
|
|
137
|
-
|
|
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
|
+
}),
|
|
138
151
|
|
|
139
152
|
unpublish: scoped('content:publish')
|
|
140
153
|
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
141
154
|
.handler(async ({ input, context }) => {
|
|
155
|
+
await assertOnDocument(context, input.spaceId, 'content:publish', input.id);
|
|
142
156
|
await context.content.unpublish(input.id, toActor(context, input.spaceId));
|
|
143
157
|
return { ok: true };
|
|
144
158
|
}),
|
|
@@ -153,9 +167,10 @@ export const contentRouter = {
|
|
|
153
167
|
position: z.number().int().min(0),
|
|
154
168
|
}),
|
|
155
169
|
)
|
|
156
|
-
.handler(async ({ input, context }) =>
|
|
157
|
-
context
|
|
158
|
-
|
|
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
|
+
}),
|
|
159
174
|
|
|
160
175
|
/** Every locale a document exists in, for the editor's language switcher. */
|
|
161
176
|
translations: scoped('content:read')
|
|
@@ -164,15 +179,16 @@ export const contentRouter = {
|
|
|
164
179
|
|
|
165
180
|
/** Starts a translation of an existing document, in the localization group it shares. */
|
|
166
181
|
createTranslation: scoped('content:write')
|
|
167
|
-
.input(z.object({ spaceId: uuid, id: uuid, locale
|
|
168
|
-
.handler(async ({ input, context }) =>
|
|
169
|
-
context.content.
|
|
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(
|
|
170
186
|
input.spaceId,
|
|
171
187
|
input.id,
|
|
172
188
|
input.locale,
|
|
173
189
|
toActor(context, input.spaceId),
|
|
174
|
-
)
|
|
175
|
-
),
|
|
190
|
+
);
|
|
191
|
+
}),
|
|
176
192
|
|
|
177
193
|
versions: scoped('content:read')
|
|
178
194
|
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
@@ -186,11 +202,61 @@ export const contentRouter = {
|
|
|
186
202
|
|
|
187
203
|
restore: scoped('content:write')
|
|
188
204
|
.input(z.object({ spaceId: uuid, id: uuid, version: z.number().int().positive() }))
|
|
189
|
-
.handler(async ({ input, context }) =>
|
|
190
|
-
context
|
|
191
|
-
|
|
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
|
+
}),
|
|
192
209
|
};
|
|
193
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
|
+
|
|
194
260
|
export { base };
|
|
195
261
|
|
|
196
262
|
function toActor(context: { principal: unknown }, spaceId: string) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { scoped } from '../base.js';
|
|
3
|
+
import { locale, machineName, uuid } from '../schemas.js';
|
|
4
|
+
|
|
5
|
+
const menuSchema = z.object({
|
|
6
|
+
name: z.string().min(1).max(200),
|
|
7
|
+
machineName,
|
|
8
|
+
description: z.string().max(2000).nullable().optional(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/** One entry as the editor hands it over: a document by localization id, or a link. */
|
|
12
|
+
export interface MenuItemInputShape {
|
|
13
|
+
id?: string | undefined;
|
|
14
|
+
localizationId?: string | null | undefined;
|
|
15
|
+
label?: string | null | undefined;
|
|
16
|
+
url?: string | null | undefined;
|
|
17
|
+
children?: MenuItemInputShape[] | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const menuItemSchema: z.ZodType<MenuItemInputShape> = z.lazy(() =>
|
|
21
|
+
z.object({
|
|
22
|
+
id: uuid.optional(),
|
|
23
|
+
localizationId: uuid.nullable().optional(),
|
|
24
|
+
label: z.string().max(200).nullable().optional(),
|
|
25
|
+
url: z.string().max(2000).nullable().optional(),
|
|
26
|
+
children: z.array(menuItemSchema).max(500).optional(),
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Menus. Each rule — the unique machine name, what an entry may point at — lives in
|
|
32
|
+
* `MenuService`; a procedure here is an input schema, a permission and one call.
|
|
33
|
+
*/
|
|
34
|
+
export const menuRouter = {
|
|
35
|
+
list: scoped('menu:read')
|
|
36
|
+
.input(z.object({ spaceId: uuid }))
|
|
37
|
+
.handler(async ({ input, context }) => context.menus.list(input.spaceId)),
|
|
38
|
+
|
|
39
|
+
/** The menu with its entries, each content entry resolved to the document in `locale`. */
|
|
40
|
+
get: scoped('menu:read')
|
|
41
|
+
.input(z.object({ spaceId: uuid, id: uuid, locale }))
|
|
42
|
+
.handler(async ({ input, context }) =>
|
|
43
|
+
context.menus.get(input.spaceId, input.id, input.locale),
|
|
44
|
+
),
|
|
45
|
+
|
|
46
|
+
create: scoped('menu:write')
|
|
47
|
+
.input(menuSchema.extend({ spaceId: uuid }))
|
|
48
|
+
.handler(async ({ input, context }) => context.menus.create(input)),
|
|
49
|
+
|
|
50
|
+
update: scoped('menu:write')
|
|
51
|
+
.input(menuSchema.partial().extend({ spaceId: uuid, id: uuid }))
|
|
52
|
+
.handler(async ({ input, context }) => {
|
|
53
|
+
const { spaceId, id, ...data } = input;
|
|
54
|
+
return context.menus.update(spaceId, id, data);
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
delete: scoped('menu:write')
|
|
58
|
+
.input(z.object({ spaceId: uuid, id: uuid }))
|
|
59
|
+
.handler(async ({ input, context }) => {
|
|
60
|
+
await context.menus.delete(input.spaceId, input.id);
|
|
61
|
+
return { ok: true };
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
/** Replaces the whole entry tree; the editor saves a menu as one document. */
|
|
65
|
+
setItems: scoped('menu:write')
|
|
66
|
+
.input(z.object({ spaceId: uuid, id: uuid, items: z.array(menuItemSchema).max(500) }))
|
|
67
|
+
.handler(async ({ input, context }) =>
|
|
68
|
+
context.menus.setItems(input.spaceId, input.id, input.items),
|
|
69
|
+
),
|
|
70
|
+
|
|
71
|
+
/** Menus a document is linked from, for the editor's hint. */
|
|
72
|
+
usedIn: scoped('menu:read')
|
|
73
|
+
.input(z.object({ spaceId: uuid, localizationId: uuid }))
|
|
74
|
+
.handler(async ({ input, context }) =>
|
|
75
|
+
context.menus.usedIn(input.spaceId, input.localizationId),
|
|
76
|
+
),
|
|
77
|
+
};
|