@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.
@@ -1,77 +0,0 @@
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
- };
@@ -1,51 +0,0 @@
1
- import { z } from 'zod';
2
- import { authed, scoped } from '../base.js';
3
- import { machineName, uuid } from '../schemas.js';
4
-
5
- const roleSchema = z.object({
6
- spaceId: uuid,
7
- name: z.string().trim().min(1).max(100),
8
- machineName,
9
- description: z.string().max(500).nullable().optional(),
10
- /** `space:write`, `content:read` for every type, `content:read:<typeId>` for one. */
11
- permissions: z.array(z.string().max(120)).max(500),
12
- });
13
-
14
- /**
15
- * The roles of a space. The rules — reserved names, grants that exist, a role nobody
16
- * holds before it goes — live in `RoleService`; a procedure here is an input schema, a
17
- * permission and one call.
18
- */
19
- export const roleRouter = {
20
- /** The permission catalogue, grouped the way the role editor lays it out. */
21
- catalog: authed.handler(async ({ context }) => context.roles.catalog()),
22
-
23
- list: scoped('role:read')
24
- .input(z.object({ spaceId: uuid }))
25
- .handler(async ({ input, context }) => context.roles.list(input.spaceId)),
26
-
27
- get: scoped('role:read')
28
- .input(z.object({ spaceId: uuid, id: uuid }))
29
- .handler(async ({ input, context }) => context.roles.get(input.spaceId, input.id)),
30
-
31
- create: scoped('role:write')
32
- .input(roleSchema)
33
- .handler(async ({ input, context }) => {
34
- const { spaceId, ...data } = input;
35
- return context.roles.create(spaceId, data);
36
- }),
37
-
38
- update: scoped('role:write')
39
- .input(roleSchema.extend({ id: uuid }))
40
- .handler(async ({ input, context }) => {
41
- const { spaceId, id, ...data } = input;
42
- return context.roles.update(spaceId, id, data);
43
- }),
44
-
45
- delete: scoped('role:write')
46
- .input(z.object({ spaceId: uuid, id: uuid }))
47
- .handler(async ({ input, context }) => {
48
- await context.roles.delete(input.spaceId, input.id);
49
- return { ok: true };
50
- }),
51
- };
@@ -1,149 +0,0 @@
1
- import { z } from 'zod';
2
- import { authed, base, scoped, superadmin } from '../base.js';
3
- import {
4
- locale,
5
- localeList,
6
- machineName,
7
- mimeTypePattern,
8
- searchTerm,
9
- spaceRole,
10
- uuid,
11
- } from '../schemas.js';
12
-
13
- const spaceSchema = z.object({
14
- name: z.string().min(1).max(200),
15
- machineName,
16
- description: z.string().nullable().optional(),
17
- url: z.string().url(),
18
- defaultLocale: locale.default('en'),
19
- locales: localeList.default(['en']),
20
- settings: z.record(z.string(), z.unknown()).optional(),
21
- });
22
-
23
- /**
24
- * Spaces and membership. Every rule — the locale invariant, the last-owner guard, the
25
- * creator-owns-it grant — lives in `SpaceService`; a procedure here is an input schema,
26
- * a permission and one call.
27
- */
28
- export const spaceRouter = {
29
- /**
30
- * Only the spaces the caller is a member of — a superadmin sees all. A member's query
31
- * is bounded by their memberships rather than by the instance, so a large multi-tenant
32
- * install does not load every space to show someone their two.
33
- */
34
- list: authed.handler(async ({ context }) => {
35
- const { principal } = context;
36
- const all =
37
- principal.role === 'superadmin'
38
- ? await context.repos.spaces.all()
39
- : await context.repos.spaces.findManyByIds(Object.keys(principal.spaces));
40
- const allowed = principal.allowedSpaceIds;
41
- return allowed ? all.filter((space) => allowed.includes(space.id)) : all;
42
- }),
43
-
44
- get: scoped('space:read')
45
- .input(z.object({ spaceId: uuid }))
46
- .handler(async ({ input, context }) => context.repos.spaces.findById(input.spaceId)),
47
-
48
- create: superadmin
49
- .input(spaceSchema)
50
- .handler(async ({ input, context }) =>
51
- context.spaces.create(input, context.principal?.userId ?? null),
52
- ),
53
-
54
- update: scoped('space:write')
55
- .input(spaceSchema.partial().extend({ spaceId: uuid }))
56
- .handler(async ({ input, context }) => {
57
- const { spaceId, ...data } = input;
58
- return context.spaces.update(spaceId, data);
59
- }),
60
-
61
- delete: scoped('space:delete')
62
- .input(z.object({ spaceId: uuid }))
63
- .handler(async ({ input, context }) => {
64
- await context.spaces.delete(input.spaceId);
65
- return { ok: true };
66
- }),
67
-
68
- /** Nominates one document as the space's root; `null` clears it. */
69
- setHome: scoped('space:write')
70
- .input(z.object({ spaceId: uuid, contentId: uuid.nullable() }))
71
- .handler(async ({ input, context }) => context.spaces.setHome(input.spaceId, input.contentId)),
72
-
73
- /**
74
- * The space's upload limits, each narrowing the instance's. `allowedMimeTypes` absent
75
- * means the instance's list; empty means the same thing rather than "nothing".
76
- */
77
- setAssetSettings: scoped('space:write')
78
- .input(
79
- z.object({
80
- spaceId: uuid,
81
- allowedMimeTypes: z.array(mimeTypePattern).max(50).optional(),
82
- maxFileSize: z.number().int().positive().nullable().optional(),
83
- }),
84
- )
85
- .handler(async ({ input, context }) =>
86
- context.spaces.setAssetSettings(input.spaceId, {
87
- ...(input.allowedMimeTypes?.length ? { allowedMimeTypes: input.allowedMimeTypes } : {}),
88
- ...(input.maxFileSize ? { maxFileSize: input.maxFileSize } : {}),
89
- }),
90
- ),
91
-
92
- /**
93
- * The whole space as one JSON document. `space:write` rather than `space:read` because
94
- * an export is every field of every document in one file, regardless of who may read
95
- * what.
96
- */
97
- export: scoped('space:write')
98
- .input(z.object({ spaceId: uuid }))
99
- .handler(async ({ input, context }) => context.spaces.export(input.spaceId)),
100
-
101
- /** Restores such a document into an instance that does not hold the space yet. Superadmin, because it creates a space. */
102
- import: superadmin
103
- .input(z.object({ payload: z.unknown() }))
104
- .handler(async ({ input, context }) =>
105
- context.spaces.import(input.payload, context.principal.userId),
106
- ),
107
-
108
- members: scoped('user:read')
109
- .input(z.object({ spaceId: uuid }))
110
- .handler(async ({ input, context }) => context.spaces.members(input.spaceId)),
111
-
112
- grant: scoped('user:write')
113
- .input(z.object({ spaceId: uuid, userId: uuid, role: spaceRole }))
114
- .handler(async ({ input, context }) => {
115
- await context.spaces.grant(input.spaceId, input.userId, input.role);
116
- return { ok: true };
117
- }),
118
-
119
- /** Users who are not yet members, for the add-member picker. */
120
- candidates: scoped('user:write')
121
- .input(z.object({ spaceId: uuid, search: searchTerm.optional() }))
122
- .handler(async ({ input, context }) => context.spaces.candidates(input.spaceId, input.search)),
123
-
124
- /** Grants the same role to several users at once, as the picker hands them over. */
125
- addMembers: scoped('user:write')
126
- .input(
127
- z.object({
128
- spaceId: uuid,
129
- userIds: z.array(uuid).min(1).max(100),
130
- role: spaceRole.default('editor'),
131
- }),
132
- )
133
- .handler(async ({ input, context }) => ({
134
- ok: true,
135
- added: await context.spaces.addMembers(input.spaceId, input.userIds, input.role),
136
- })),
137
-
138
- revoke: scoped('user:write')
139
- .input(z.object({ spaceId: uuid, userId: uuid }))
140
- .handler(async ({ input, context }) => {
141
- await context.spaces.revoke(input.spaceId, input.userId);
142
- return { ok: true };
143
- }),
144
-
145
- /** Locales available for a space, for the editor's language switcher. */
146
- locales: base
147
- .input(z.object({ spaceId: uuid }))
148
- .handler(async ({ input, context }) => context.spaces.locales(input.spaceId)),
149
- };
@@ -1,175 +0,0 @@
1
- import { assertCan, effectiveGrants, MIN_PASSWORD_LENGTH, normaliseGrants } from '@manablox/auth';
2
- import { ManabloxError } from '@manablox/core';
3
- import { z } from 'zod';
4
- import { authed, base, superadmin } from '../base.js';
5
- import { pagination, searchTerm, uuid } from '../schemas.js';
6
-
7
- const instanceRole = z.enum(['superadmin', 'editor']);
8
- const password = z.string().min(MIN_PASSWORD_LENGTH).max(200);
9
- const email = z.string().email().max(320);
10
- const displayName = z.string().trim().min(1).max(200);
11
-
12
- /**
13
- * The caller's own account and keys, and — for a superadmin — every account on the
14
- * instance. The rules (no locking yourself out, one superadmin always remains) live in
15
- * `UserService`; a procedure here is an input schema, a permission and one call.
16
- */
17
- export const userRouter = {
18
- me: authed.handler(async ({ context }) => {
19
- const user = await context.repos.users.findById(context.principal.userId);
20
- return user
21
- ? {
22
- id: user.id,
23
- email: user.email,
24
- name: user.name,
25
- image: user.image,
26
- role: user.role,
27
- spaces: context.principal.spaces,
28
- // Space id → every grant held there, built-in roles resolved too, so the admin
29
- // shows and hides controls from the same table the server enforces.
30
- permissions: Object.fromEntries(
31
- Object.keys(context.principal.spaces).map((spaceId) => [
32
- spaceId,
33
- effectiveGrants(context.principal, spaceId),
34
- ]),
35
- ),
36
- }
37
- : null;
38
- }),
39
-
40
- /**
41
- * Whether the instance still has no account at all. Public, because the login page
42
- * needs it before anyone is signed in: it decides whether to offer "create the first
43
- * account". Once one exists, sign-up is closed and every account is created here.
44
- */
45
- setupNeeded: base.handler(async ({ context }) => ({
46
- setupNeeded: (await context.repos.users.count()) === 0,
47
- })),
48
-
49
- list: superadmin
50
- .input(pagination({ limit: 25, max: 100 }).extend({ search: searchTerm.optional() }))
51
- .handler(async ({ input, context }) =>
52
- context.users.list({ limit: input.limit, offset: input.offset }, input.search),
53
- ),
54
-
55
- get: superadmin
56
- .input(z.object({ userId: uuid }))
57
- .handler(async ({ input, context }) => context.users.get(input.userId)),
58
-
59
- create: superadmin
60
- .input(
61
- z.object({
62
- name: displayName,
63
- email,
64
- password,
65
- role: instanceRole.default('editor'),
66
- }),
67
- )
68
- .handler(async ({ input, context }) => context.users.create(input)),
69
-
70
- update: superadmin
71
- .input(z.object({ userId: uuid, name: displayName.optional(), email: email.optional() }))
72
- .handler(async ({ input, context }) => {
73
- const { userId, ...data } = input;
74
- return context.users.update(userId, data);
75
- }),
76
-
77
- setRole: superadmin
78
- .input(z.object({ userId: uuid, role: instanceRole }))
79
- .handler(async ({ input, context }) => context.users.setRole(input.userId, input.role)),
80
-
81
- /** Resets a password and signs the account out everywhere. */
82
- setPassword: superadmin
83
- .input(z.object({ userId: uuid, password }))
84
- .handler(async ({ input, context }) => {
85
- await context.users.setPassword(input.userId, input.password);
86
- return { ok: true };
87
- }),
88
-
89
- ban: superadmin
90
- .input(z.object({ userId: uuid, reason: z.string().trim().max(500).optional() }))
91
- .handler(async ({ input, context }) =>
92
- context.users.ban(context.principal.userId, input.userId, input.reason || null),
93
- ),
94
-
95
- unban: superadmin
96
- .input(z.object({ userId: uuid }))
97
- .handler(async ({ input, context }) => context.users.unban(input.userId)),
98
-
99
- revokeSessions: superadmin
100
- .input(z.object({ userId: uuid }))
101
- .handler(async ({ input, context }) => {
102
- await context.users.revokeSessions(input.userId);
103
- return { ok: true };
104
- }),
105
-
106
- delete: superadmin.input(z.object({ userId: uuid })).handler(async ({ input, context }) => {
107
- await context.users.delete(context.principal.userId, input.userId);
108
- return { ok: true };
109
- }),
110
-
111
- apiKeys: authed.handler(async ({ context }) => context.apiKeys.list(context.principal.userId)),
112
-
113
- issueApiKey: authed
114
- .input(
115
- z.object({
116
- name: z.string().min(1).max(100),
117
- expiresAt: z.coerce.date().optional(),
118
- /** Empty or omitted issues an unrestricted key. */
119
- spaceIds: z.array(uuid).optional(),
120
- /**
121
- * Grants the key is confined to, in the roles' vocabulary; omitted leaves the
122
- * owner's role as the limit. At use the two are intersected, so a grant here
123
- * never widens the key beyond its owner.
124
- */
125
- permissions: z.array(z.string().max(120)).max(500).optional(),
126
- }),
127
- )
128
- .handler(async ({ input, context }) => {
129
- // A key may only be confined to spaces the issuer can already reach, so a
130
- // restriction cannot be used to name a space the caller has no business knowing.
131
- for (const spaceId of input.spaceIds ?? []) {
132
- assertCan(context.principal, spaceId, 'space:read');
133
- }
134
- // A typed grant may name a type of any space the key will reach.
135
- const reachable = input.spaceIds?.length
136
- ? input.spaceIds
137
- : context.principal.role === 'superadmin'
138
- ? null
139
- : Object.keys(context.principal.spaces);
140
- const typeIds = new Set(
141
- (reachable
142
- ? reachable.flatMap((spaceId) => context.manablox.contentTypes.forSpace(spaceId))
143
- : context.manablox.contentTypes.all
144
- ).map((type) => type.id),
145
- );
146
- let permissions: string[] | null = null;
147
- if (input.permissions) {
148
- const checked = normaliseGrants(input.permissions, typeIds);
149
- if (checked.unknown.length) {
150
- throw ManabloxError.validation(
151
- checked.unknown.map(({ index, grant }) => ({
152
- key: 'role.permission.unknown' as const,
153
- path: ['permissions', index],
154
- params: { permission: grant },
155
- })),
156
- 'apiKey.validation.failed',
157
- );
158
- }
159
- permissions = checked.permissions;
160
- }
161
- return context.apiKeys.issue(context.principal.userId, input.name, {
162
- expiresAt: input.expiresAt,
163
- spaceIds: input.spaceIds,
164
- permissions,
165
- });
166
- }),
167
-
168
- revokeApiKey: authed.input(z.object({ id: uuid })).handler(async ({ input, context }) => {
169
- // Scope the revoke to the caller's own keys so an id from elsewhere is inert.
170
- const own = await context.apiKeys.list(context.principal.userId);
171
- if (!own.some((key) => key.id === input.id)) return { ok: false };
172
- await context.apiKeys.revoke(input.id);
173
- return { ok: true };
174
- }),
175
- };