@manablox/api-rpc 0.1.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/README.md +20 -0
- package/dist/index.d.ts +5971 -0
- package/dist/index.js +994 -0
- package/package.json +20 -11
- package/src/base.ts +0 -68
- package/src/context.ts +0 -18
- package/src/index.ts +0 -22
- package/src/routers/asset.ts +0 -67
- package/src/routers/content-type.ts +0 -116
- package/src/routers/content.ts +0 -200
- package/src/routers/space.ts +0 -267
- package/src/routers/user.ts +0 -68
- package/tsconfig.json +0 -1
package/src/routers/space.ts
DELETED
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
import { ManabloxError } from '@manablox/core';
|
|
2
|
-
import { SpaceTransferService } from '@manablox/services';
|
|
3
|
-
import { z } from 'zod';
|
|
4
|
-
import { authed, base, scoped, superadmin } from '../base.js';
|
|
5
|
-
import type { RpcContext } from '../context.js';
|
|
6
|
-
|
|
7
|
-
const uuid = z.string().uuid();
|
|
8
|
-
|
|
9
|
-
const spaceSchema = z.object({
|
|
10
|
-
name: z.string().min(1).max(200),
|
|
11
|
-
machineName: z
|
|
12
|
-
.string()
|
|
13
|
-
.regex(/^[a-z][a-z0-9_-]*$/)
|
|
14
|
-
.max(64),
|
|
15
|
-
description: z.string().nullable().optional(),
|
|
16
|
-
url: z.string().url(),
|
|
17
|
-
defaultLocale: z.string().min(2).max(10).default('en'),
|
|
18
|
-
locales: z.array(z.string().min(2).max(10)).min(1).default(['en']),
|
|
19
|
-
settings: z.record(z.string(), z.unknown()).optional(),
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* `spaces_machine_name_key` is enforced in the database, so a taken machine name arrives
|
|
24
|
-
* as a Postgres unique violation and would surface as an opaque 500. Drizzle wraps the
|
|
25
|
-
* driver error, so the SQLSTATE is on `cause`.
|
|
26
|
-
*/
|
|
27
|
-
function rethrowMachineNameConflict(error: unknown, machineName: string | undefined): never {
|
|
28
|
-
const cause = (error as { cause?: { code?: string; message?: string } }).cause;
|
|
29
|
-
const code = (error as { code?: string }).code ?? cause?.code;
|
|
30
|
-
const detail = `${cause?.message ?? ''} ${(error as { message?: string }).message ?? ''}`;
|
|
31
|
-
|
|
32
|
-
if (code === '23505' && detail.includes('machine_name')) {
|
|
33
|
-
throw ManabloxError.validation(
|
|
34
|
-
[
|
|
35
|
-
{
|
|
36
|
-
key: 'space.machineName.taken',
|
|
37
|
-
path: ['machineName'],
|
|
38
|
-
params: { machineName: machineName ?? '' },
|
|
39
|
-
},
|
|
40
|
-
],
|
|
41
|
-
'space.validation.failed',
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* A space with no owner can never be granted one again: `user:write` is an owner's and an
|
|
49
|
-
* admin's permission, and an admin cannot promote themselves past their own role.
|
|
50
|
-
*/
|
|
51
|
-
async function assertNotLastOwner(
|
|
52
|
-
context: Pick<RpcContext, 'repos'>,
|
|
53
|
-
spaceId: string,
|
|
54
|
-
userId: string,
|
|
55
|
-
): Promise<void> {
|
|
56
|
-
if ((await context.repos.users.roleIn(userId, spaceId)) !== 'owner') return;
|
|
57
|
-
const owners = (await context.repos.users.membersOf(spaceId)).filter(
|
|
58
|
-
(member) => member.role === 'owner',
|
|
59
|
-
);
|
|
60
|
-
if (owners.length <= 1) throw ManabloxError.badRequest('space.member.lastOwner', { spaceId });
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The default locale names the column content falls back to, so a default outside the
|
|
65
|
-
* space's own locale set would leave every document with no readable fallback.
|
|
66
|
-
*/
|
|
67
|
-
function assertDefaultIsALocale(defaultLocale: string, locales: readonly string[]): void {
|
|
68
|
-
if (locales.includes(defaultLocale)) return;
|
|
69
|
-
throw ManabloxError.validation(
|
|
70
|
-
[
|
|
71
|
-
{
|
|
72
|
-
key: 'space.defaultLocale.notInLocales',
|
|
73
|
-
path: ['defaultLocale'],
|
|
74
|
-
params: { defaultLocale, locales: locales.join(', ') },
|
|
75
|
-
},
|
|
76
|
-
],
|
|
77
|
-
'space.validation.failed',
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export const spaceRouter = {
|
|
82
|
-
/** Only the spaces the caller is a member of — a superadmin sees all. */
|
|
83
|
-
list: authed.handler(async ({ context }) => {
|
|
84
|
-
const all = await context.repos.spaces.all();
|
|
85
|
-
const allowed = context.principal?.allowedSpaceIds;
|
|
86
|
-
const visible = allowed ? all.filter((space) => allowed.includes(space.id)) : all;
|
|
87
|
-
if (context.principal?.role === 'superadmin') return visible;
|
|
88
|
-
return visible.filter((space) => Boolean(context.principal?.spaces[space.id]));
|
|
89
|
-
}),
|
|
90
|
-
|
|
91
|
-
get: scoped('space:read')
|
|
92
|
-
.input(z.object({ spaceId: uuid }))
|
|
93
|
-
.handler(async ({ input, context }) => context.repos.spaces.findById(input.spaceId)),
|
|
94
|
-
|
|
95
|
-
create: superadmin.input(spaceSchema).handler(async ({ input, context }) => {
|
|
96
|
-
assertDefaultIsALocale(input.defaultLocale, input.locales);
|
|
97
|
-
const space = await context.repos.spaces
|
|
98
|
-
.create(input)
|
|
99
|
-
.catch((error) => rethrowMachineNameConflict(error, input.machineName));
|
|
100
|
-
// Whoever creates a space owns it, or they would immediately lock themselves out.
|
|
101
|
-
if (context.principal) {
|
|
102
|
-
await context.repos.users.grant(context.principal.userId, space.id, 'owner');
|
|
103
|
-
}
|
|
104
|
-
return space;
|
|
105
|
-
}),
|
|
106
|
-
|
|
107
|
-
update: scoped('space:write')
|
|
108
|
-
.input(spaceSchema.partial().extend({ spaceId: uuid }))
|
|
109
|
-
.handler(async ({ input, context }) => {
|
|
110
|
-
const { spaceId, ...data } = input;
|
|
111
|
-
|
|
112
|
-
// A partial update may move either half of the pair, so the check runs against the
|
|
113
|
-
// stored values for whichever half this call leaves alone.
|
|
114
|
-
if (data.defaultLocale || data.locales) {
|
|
115
|
-
const current = await context.repos.spaces.findById(spaceId);
|
|
116
|
-
if (!current) throw ManabloxError.notFound('space.notFound', { spaceId });
|
|
117
|
-
assertDefaultIsALocale(
|
|
118
|
-
data.defaultLocale ?? current.defaultLocale,
|
|
119
|
-
data.locales ?? current.locales,
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return context.repos.spaces
|
|
124
|
-
.update(spaceId, data)
|
|
125
|
-
.catch((error) => rethrowMachineNameConflict(error, data.machineName));
|
|
126
|
-
}),
|
|
127
|
-
|
|
128
|
-
delete: scoped('space:delete')
|
|
129
|
-
.input(z.object({ spaceId: uuid }))
|
|
130
|
-
.handler(async ({ input, context }) => {
|
|
131
|
-
await context.repos.spaces.delete(input.spaceId);
|
|
132
|
-
return { ok: true };
|
|
133
|
-
}),
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Nominates one document as the space's root. Stored in `settings` rather than on the
|
|
137
|
-
* content row so it survives the document being renamed, re-slugged or moved, and so a
|
|
138
|
-
* space has exactly one by construction.
|
|
139
|
-
*
|
|
140
|
-
* The delivery APIs resolve the empty path through it, matching the requested locale by
|
|
141
|
-
* localization group — pass `null` to clear.
|
|
142
|
-
*/
|
|
143
|
-
setHome: scoped('space:write')
|
|
144
|
-
.input(z.object({ spaceId: uuid, contentId: uuid.nullable() }))
|
|
145
|
-
.handler(async ({ input, context }) => {
|
|
146
|
-
const space = await context.repos.spaces.findById(input.spaceId);
|
|
147
|
-
if (!space) throw ManabloxError.notFound('space.notFound', { spaceId: input.spaceId });
|
|
148
|
-
|
|
149
|
-
if (input.contentId) {
|
|
150
|
-
const row = await context.repos.content.findById(input.contentId);
|
|
151
|
-
if (!row || row.spaceId !== input.spaceId) {
|
|
152
|
-
throw ManabloxError.badRequest('content.notInSpace', { id: input.contentId });
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const settings = { ...space.settings };
|
|
157
|
-
if (input.contentId) settings.homeContentId = input.contentId;
|
|
158
|
-
else delete settings.homeContentId;
|
|
159
|
-
|
|
160
|
-
return context.repos.spaces.update(input.spaceId, { settings });
|
|
161
|
-
}),
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* The whole space as one JSON document: its settings, its runtime content types, every
|
|
165
|
-
* document and the asset metadata. `space:write` rather than `space:read` because an
|
|
166
|
-
* export is every field of every document in one file, regardless of who may read what.
|
|
167
|
-
*/
|
|
168
|
-
export: scoped('space:write')
|
|
169
|
-
.input(z.object({ spaceId: uuid }))
|
|
170
|
-
.handler(async ({ input, context }) =>
|
|
171
|
-
new SpaceTransferService(context.manablox, context.repos).export(input.spaceId),
|
|
172
|
-
),
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Restores such a document, ids and all, into an instance that does not hold the space
|
|
176
|
-
* yet. Superadmin, because it creates a space — the same bar as `create`.
|
|
177
|
-
*/
|
|
178
|
-
import: superadmin
|
|
179
|
-
.input(z.object({ payload: z.unknown() }))
|
|
180
|
-
.handler(async ({ input, context }) => {
|
|
181
|
-
const result = await new SpaceTransferService(context.manablox, context.repos).import(
|
|
182
|
-
input.payload,
|
|
183
|
-
context.principal.userId,
|
|
184
|
-
);
|
|
185
|
-
// Whoever imports a space owns it, exactly as if they had created it by hand.
|
|
186
|
-
await context.repos.users.grant(context.principal.userId, result.spaceId, 'owner');
|
|
187
|
-
return result;
|
|
188
|
-
}),
|
|
189
|
-
|
|
190
|
-
members: scoped('user:read')
|
|
191
|
-
.input(z.object({ spaceId: uuid }))
|
|
192
|
-
.handler(async ({ input, context }) => context.repos.users.membersOf(input.spaceId)),
|
|
193
|
-
|
|
194
|
-
grant: scoped('user:write')
|
|
195
|
-
.input(
|
|
196
|
-
z.object({
|
|
197
|
-
spaceId: uuid,
|
|
198
|
-
userId: uuid,
|
|
199
|
-
role: z.enum(['owner', 'admin', 'editor', 'author', 'viewer']),
|
|
200
|
-
}),
|
|
201
|
-
)
|
|
202
|
-
.handler(async ({ input, context }) => {
|
|
203
|
-
// Demoting the last owner locks the space out of ownership just as removing them does.
|
|
204
|
-
if (input.role !== 'owner') {
|
|
205
|
-
await assertNotLastOwner(context, input.spaceId, input.userId);
|
|
206
|
-
}
|
|
207
|
-
await context.repos.users.grant(input.userId, input.spaceId, input.role);
|
|
208
|
-
return { ok: true };
|
|
209
|
-
}),
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Users who are not yet members, for the add-member picker. Scoped to `user:write` and
|
|
213
|
-
* returning only a name, an email and an id: a space admin needs to pick a colleague
|
|
214
|
-
* without being handed the instance's user directory, which is superadmin-only.
|
|
215
|
-
*/
|
|
216
|
-
candidates: scoped('user:write')
|
|
217
|
-
.input(z.object({ spaceId: uuid, search: z.string().max(200).optional() }))
|
|
218
|
-
.handler(async ({ input, context }) => {
|
|
219
|
-
const [members, all] = await Promise.all([
|
|
220
|
-
context.repos.users.membersOf(input.spaceId),
|
|
221
|
-
context.repos.users.list({ limit: 100, offset: 0 }, input.search),
|
|
222
|
-
]);
|
|
223
|
-
const taken = new Set(members.map((member) => member.userId));
|
|
224
|
-
return all.items
|
|
225
|
-
.filter((user) => !taken.has(user.id))
|
|
226
|
-
.map((user) => ({ id: user.id, name: user.name, email: user.email }));
|
|
227
|
-
}),
|
|
228
|
-
|
|
229
|
-
/** Grants the same role to several users at once, as the picker hands them over. */
|
|
230
|
-
addMembers: scoped('user:write')
|
|
231
|
-
.input(
|
|
232
|
-
z.object({
|
|
233
|
-
spaceId: uuid,
|
|
234
|
-
userIds: z.array(uuid).min(1).max(100),
|
|
235
|
-
role: z.enum(['owner', 'admin', 'editor', 'author', 'viewer']).default('editor'),
|
|
236
|
-
}),
|
|
237
|
-
)
|
|
238
|
-
.handler(async ({ input, context }) => {
|
|
239
|
-
// Already-members are skipped rather than rejected: the picker offers only
|
|
240
|
-
// non-members, so an overlap means the list went stale, not that the caller erred.
|
|
241
|
-
const members = await context.repos.users.membersOf(input.spaceId);
|
|
242
|
-
const taken = new Set(members.map((member) => member.userId));
|
|
243
|
-
const added = input.userIds.filter((userId) => !taken.has(userId));
|
|
244
|
-
|
|
245
|
-
for (const userId of added) {
|
|
246
|
-
await context.repos.users.grant(userId, input.spaceId, input.role);
|
|
247
|
-
}
|
|
248
|
-
return { ok: true, added: added.length };
|
|
249
|
-
}),
|
|
250
|
-
|
|
251
|
-
revoke: scoped('user:write')
|
|
252
|
-
.input(z.object({ spaceId: uuid, userId: uuid }))
|
|
253
|
-
.handler(async ({ input, context }) => {
|
|
254
|
-
await assertNotLastOwner(context, input.spaceId, input.userId);
|
|
255
|
-
await context.repos.users.revoke(input.userId, input.spaceId);
|
|
256
|
-
return { ok: true };
|
|
257
|
-
}),
|
|
258
|
-
|
|
259
|
-
/** Locales available for a space, for the editor's language switcher. */
|
|
260
|
-
locales: base.input(z.object({ spaceId: uuid })).handler(async ({ input, context }) => {
|
|
261
|
-
const space = await context.repos.spaces.findById(input.spaceId);
|
|
262
|
-
return {
|
|
263
|
-
available: space?.locales ?? context.manablox.config.locales.available,
|
|
264
|
-
default: space?.defaultLocale ?? context.manablox.config.locales.default,
|
|
265
|
-
};
|
|
266
|
-
}),
|
|
267
|
-
};
|
package/src/routers/user.ts
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { assertCan } from '@manablox/auth';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import { authed, superadmin } from '../base.js';
|
|
4
|
-
|
|
5
|
-
const uuid = z.string().uuid();
|
|
6
|
-
|
|
7
|
-
export const userRouter = {
|
|
8
|
-
me: authed.handler(async ({ context }) => {
|
|
9
|
-
const user = await context.repos.users.findById(context.principal.userId);
|
|
10
|
-
return user
|
|
11
|
-
? {
|
|
12
|
-
id: user.id,
|
|
13
|
-
email: user.email,
|
|
14
|
-
name: user.name,
|
|
15
|
-
image: user.image,
|
|
16
|
-
role: user.role,
|
|
17
|
-
spaces: context.principal.spaces,
|
|
18
|
-
}
|
|
19
|
-
: null;
|
|
20
|
-
}),
|
|
21
|
-
|
|
22
|
-
list: superadmin
|
|
23
|
-
.input(
|
|
24
|
-
z.object({
|
|
25
|
-
search: z.string().max(200).optional(),
|
|
26
|
-
limit: z.number().int().min(1).max(100).default(25),
|
|
27
|
-
offset: z.number().int().min(0).default(0),
|
|
28
|
-
}),
|
|
29
|
-
)
|
|
30
|
-
.handler(async ({ input, context }) =>
|
|
31
|
-
context.repos.users.list({ limit: input.limit, offset: input.offset }, input.search),
|
|
32
|
-
),
|
|
33
|
-
|
|
34
|
-
setRole: superadmin
|
|
35
|
-
.input(z.object({ userId: uuid, role: z.enum(['superadmin', 'editor']) }))
|
|
36
|
-
.handler(async ({ input, context }) => context.repos.users.setRole(input.userId, input.role)),
|
|
37
|
-
|
|
38
|
-
apiKeys: authed.handler(async ({ context }) => context.apiKeys.list(context.principal.userId)),
|
|
39
|
-
|
|
40
|
-
issueApiKey: authed
|
|
41
|
-
.input(
|
|
42
|
-
z.object({
|
|
43
|
-
name: z.string().min(1).max(100),
|
|
44
|
-
expiresAt: z.coerce.date().optional(),
|
|
45
|
-
/** Empty or omitted issues an unrestricted key. */
|
|
46
|
-
spaceIds: z.array(uuid).optional(),
|
|
47
|
-
}),
|
|
48
|
-
)
|
|
49
|
-
.handler(async ({ input, context }) => {
|
|
50
|
-
// A key may only be confined to spaces the issuer can already reach, so a
|
|
51
|
-
// restriction cannot be used to name a space the caller has no business knowing.
|
|
52
|
-
for (const spaceId of input.spaceIds ?? []) {
|
|
53
|
-
assertCan(context.principal, spaceId, 'space:read');
|
|
54
|
-
}
|
|
55
|
-
return context.apiKeys.issue(context.principal.userId, input.name, {
|
|
56
|
-
expiresAt: input.expiresAt,
|
|
57
|
-
spaceIds: input.spaceIds,
|
|
58
|
-
});
|
|
59
|
-
}),
|
|
60
|
-
|
|
61
|
-
revokeApiKey: authed.input(z.object({ id: uuid })).handler(async ({ input, context }) => {
|
|
62
|
-
// Scope the revoke to the caller's own keys so an id from elsewhere is inert.
|
|
63
|
-
const own = await context.apiKeys.list(context.principal.userId);
|
|
64
|
-
if (!own.some((key) => key.id === input.id)) return { ok: false };
|
|
65
|
-
await context.apiKeys.revoke(input.id);
|
|
66
|
-
return { ok: true };
|
|
67
|
-
}),
|
|
68
|
-
};
|
package/tsconfig.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{ "extends": "@manablox/config-typescript/library.json", "include": ["src", "test"] }
|