@manablox/api-rpc 0.1.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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@manablox/api-rpc",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./src/index.ts",
8
+ "default": "./src/index.ts"
9
+ }
10
+ },
11
+ "main": "./src/index.ts",
12
+ "types": "./src/index.ts",
13
+ "dependencies": {
14
+ "@manablox/auth": "0.1.0",
15
+ "@manablox/core": "0.1.0",
16
+ "@manablox/db": "0.1.0",
17
+ "@manablox/media": "0.1.0",
18
+ "@manablox/services": "0.1.0",
19
+ "@orpc/server": "^1.15.0",
20
+ "@orpc/openapi": "^1.15.0",
21
+ "zod": "^4.5.4"
22
+ },
23
+ "devDependencies": {
24
+ "@manablox/config-typescript": "0.0.0",
25
+ "@types/node": "^26.4.1",
26
+ "typescript": "^7.0.2",
27
+ "vitest": "^5.0.0"
28
+ },
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit"
31
+ }
32
+ }
package/src/base.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { assertCan, type Permission } from '@manablox/auth';
2
+ import { ManabloxError } 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
+ const code = (
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, {
34
+ message: error.key,
35
+ // Structured details survive to the client: key, path and params per problem.
36
+ data: { key: error.key, details: error.details },
37
+ });
38
+ }
39
+
40
+ /** Requires an authenticated principal. */
41
+ export const authed = base.use(async ({ context, next }) => {
42
+ if (!context.principal) throw toOrpcError(ManabloxError.unauthorized());
43
+ return next({ context: { ...context, principal: context.principal } });
44
+ });
45
+
46
+ /**
47
+ * Requires a permission in the space named by the input's `spaceId`.
48
+ *
49
+ * Authorisation is a middleware rather than a call at the top of each handler, so a new
50
+ * procedure cannot forget it — the input type makes `spaceId` mandatory.
51
+ */
52
+ export function scoped(permission: Permission) {
53
+ return authed.use(async ({ context, next }, input: unknown) => {
54
+ const spaceId = (input as { spaceId?: string } | undefined)?.spaceId ?? null;
55
+ assertCan(context.principal, spaceId, permission);
56
+ return next();
57
+ });
58
+ }
59
+
60
+ /** Requires an instance-wide superadmin, for operations that are not space-scoped. */
61
+ export const superadmin = authed.use(async ({ context, next }) => {
62
+ // A space-restricted API key is confined to those spaces, so it never reaches an
63
+ // instance-wide operation even when its owner is a superadmin.
64
+ if (context.principal?.role !== 'superadmin' || context.principal.allowedSpaceIds) {
65
+ throw toOrpcError(ManabloxError.forbidden('auth.superadminRequired'));
66
+ }
67
+ return next();
68
+ });
package/src/context.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { ApiKeyService, ManabloxAuth, Principal } 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 { ContentService, ContentTypeService, Loaders } from '@manablox/services';
6
+
7
+ export interface RpcContext {
8
+ manablox: Manablox;
9
+ repos: Repositories;
10
+ auth: ManabloxAuth;
11
+ apiKeys: ApiKeyService;
12
+ media: MediaService;
13
+ content: ContentService;
14
+ contentTypes: ContentTypeService;
15
+ loaders: Loaders;
16
+ principal: Principal | null;
17
+ headers: Headers;
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { assetRouter } from './routers/asset.js';
2
+ import { contentRouter } from './routers/content.js';
3
+ import { contentTypeRouter } from './routers/content-type.js';
4
+ import { spaceRouter } from './routers/space.js';
5
+ import { userRouter } from './routers/user.js';
6
+
7
+ export * from './base.js';
8
+ export * from './context.js';
9
+
10
+ /**
11
+ * The management API. The admin imports the *type* of this object and gets end-to-end
12
+ * safety with no codegen step.
13
+ */
14
+ export const router = {
15
+ content: contentRouter,
16
+ contentTypes: contentTypeRouter,
17
+ spaces: spaceRouter,
18
+ assets: assetRouter,
19
+ users: userRouter,
20
+ };
21
+
22
+ export type ManabloxRouter = typeof router;
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod';
2
+ import { scoped } from '../base.js';
3
+
4
+ const uuid = z.string().uuid();
5
+
6
+ export const assetRouter = {
7
+ list: scoped('asset:read')
8
+ .input(
9
+ z.object({
10
+ spaceId: uuid,
11
+ mimeType: z.string().optional(),
12
+ search: z.string().max(200).optional(),
13
+ limit: z.number().int().min(1).max(100).default(40),
14
+ offset: z.number().int().min(0).default(0),
15
+ }),
16
+ )
17
+ .handler(async ({ input, context }) => {
18
+ const page = await context.repos.assets.list(
19
+ {
20
+ spaceId: input.spaceId,
21
+ ...(input.mimeType ? { mimeType: input.mimeType } : {}),
22
+ ...(input.search ? { search: input.search } : {}),
23
+ },
24
+ { limit: input.limit, offset: input.offset },
25
+ );
26
+ return {
27
+ ...page,
28
+ items: page.items.map((asset) => ({
29
+ ...asset,
30
+ url: context.media.urlFor(asset),
31
+ thumbnailUrl: asset.mimeType.startsWith('image/')
32
+ ? context.media.urlFor(asset, 'thumb', 'webp')
33
+ : null,
34
+ })),
35
+ };
36
+ }),
37
+
38
+ get: scoped('asset:read')
39
+ .input(z.object({ spaceId: uuid, id: uuid }))
40
+ .handler(async ({ input, context }) => {
41
+ const asset = await context.repos.assets.findById(input.id);
42
+ if (!asset) return null;
43
+ return { ...asset, url: context.media.urlFor(asset) };
44
+ }),
45
+
46
+ update: scoped('asset:write')
47
+ .input(
48
+ z.object({
49
+ spaceId: uuid,
50
+ id: uuid,
51
+ name: z.string().max(200).optional(),
52
+ alt: z.string().max(500).nullable().optional(),
53
+ title: z.string().max(500).nullable().optional(),
54
+ }),
55
+ )
56
+ .handler(async ({ input, context }) => {
57
+ const { spaceId: _spaceId, id, ...data } = input;
58
+ return context.repos.assets.update(id, data);
59
+ }),
60
+
61
+ delete: scoped('asset:delete')
62
+ .input(z.object({ spaceId: uuid, id: uuid }))
63
+ .handler(async ({ input, context }) => {
64
+ await context.media.delete(input.id);
65
+ return { ok: true };
66
+ }),
67
+ };
@@ -0,0 +1,116 @@
1
+ import { renderContentTypeConfig } from '@manablox/services';
2
+ import { z } from 'zod';
3
+ import { base, scoped, superadmin } from '../base.js';
4
+
5
+ const uuid = z.string().uuid();
6
+
7
+ const fieldSchema = z.object({
8
+ id: z.string().optional(),
9
+ name: z.string().min(1).max(64),
10
+ label: z.string().optional(),
11
+ type: z.string(),
12
+ settings: z.record(z.string(), z.unknown()).default({}),
13
+ required: z.boolean().default(false),
14
+ localized: z.boolean().default(false),
15
+ unique: z.boolean().default(false),
16
+ readRoles: z.array(z.string()).optional(),
17
+ writeRoles: z.array(z.string()).optional(),
18
+ admin: z
19
+ .object({
20
+ zone: z.enum(['main', 'sidebar']).default('main'),
21
+ width: z.number().int().min(25).max(100).default(100),
22
+ position: z.number().int().default(0),
23
+ help: z.string().optional(),
24
+ placeholder: z.string().optional(),
25
+ })
26
+ .optional(),
27
+ });
28
+
29
+ const contentTypeSchema = z.object({
30
+ name: z.string().min(1).max(64),
31
+ label: z.string().optional(),
32
+ description: z.string().optional(),
33
+ icon: z.string().optional(),
34
+ kind: z.enum(['content', 'block']).default('content'),
35
+ spaceId: uuid.nullable().default(null),
36
+ hasSlug: z.boolean().optional(),
37
+ isPublishable: z.boolean().optional(),
38
+ isVisibleInTree: z.boolean().optional(),
39
+ canBeVisibleInMenu: z.boolean().optional(),
40
+ fields: z.array(fieldSchema).default([]),
41
+ });
42
+
43
+ export const contentTypeRouter = {
44
+ list: scoped('contentType:read')
45
+ .input(z.object({ spaceId: uuid }))
46
+ .handler(async ({ input, context }) => context.contentTypes.list(input.spaceId)),
47
+
48
+ get: scoped('contentType:read')
49
+ .input(z.object({ spaceId: uuid, id: uuid }))
50
+ .handler(async ({ input, context }) => context.contentTypes.get(input.id)),
51
+
52
+ create: scoped('contentType:write')
53
+ .input(contentTypeSchema.extend({ spaceId: uuid }))
54
+ .handler(async ({ input, context }) => context.contentTypes.create(input)),
55
+
56
+ update: scoped('contentType:write')
57
+ .input(contentTypeSchema.extend({ spaceId: uuid, id: uuid }))
58
+ .handler(async ({ input, context }) => context.contentTypes.update(input.id, input)),
59
+
60
+ delete: scoped('contentType:delete')
61
+ .input(z.object({ spaceId: uuid, id: uuid }))
62
+ .handler(async ({ input, context }) => {
63
+ await context.contentTypes.delete(input.id);
64
+ return { ok: true };
65
+ }),
66
+
67
+ /**
68
+ * The field-type catalogue the admin's "add field" menu is built from. Derived from
69
+ * the registry, so a plugin's field type appears in the menu with no admin change.
70
+ */
71
+ fieldTypes: base.handler(async ({ context }) =>
72
+ context.manablox.fieldTypes.all.map((type) => ({
73
+ name: type.name,
74
+ label: type.label,
75
+ icon: type.icon ?? null,
76
+ description: type.description ?? null,
77
+ nested: type.nested ?? false,
78
+ filters: type.filters,
79
+ admin: type.admin,
80
+ })),
81
+ ),
82
+
83
+ /** JSON Schema for one field type's settings, so the admin renders its form generically. */
84
+ fieldTypeSettingsSchema: base
85
+ .input(z.object({ name: z.string() }))
86
+ .handler(async ({ input, context }) => {
87
+ const type = context.manablox.fieldTypes.get(input.name);
88
+ const schema = type.settingsSchema as unknown as { toJSONSchema?: () => unknown };
89
+ return {
90
+ name: type.name,
91
+ jsonSchema: typeof schema.toJSONSchema === 'function' ? schema.toJSONSchema() : null,
92
+ };
93
+ }),
94
+
95
+ /**
96
+ * The space's runtime types rendered as `manablox.config.ts` source, for moving a type
97
+ * built in the admin into code where it can be reviewed and versioned.
98
+ */
99
+ config: scoped('contentType:read')
100
+ .input(z.object({ spaceId: uuid, ids: z.array(uuid).optional() }))
101
+ .handler(async ({ input, context }) => {
102
+ const wanted = input.ids?.length ? new Set(input.ids) : null;
103
+ const types = context.manablox.contentTypes
104
+ .forSpace(input.spaceId)
105
+ // Code-defined types already live in a config file; re-emitting them would invite
106
+ // a second, diverging definition of the same type.
107
+ .filter((type) => type.source !== 'code' && (!wanted || wanted.has(type.id)));
108
+
109
+ return { code: renderContentTypeConfig(types), count: types.length };
110
+ }),
111
+
112
+ reload: superadmin.handler(async ({ context }) => {
113
+ await context.manablox.reload(await context.repos.contentTypes.all());
114
+ return { schemaVersion: context.manablox.contentTypes.schemaVersion };
115
+ }),
116
+ };
@@ -0,0 +1,200 @@
1
+ import { actorRoles } from '@manablox/auth';
2
+ import { z } from 'zod';
3
+ import { base, scoped } from '../base.js';
4
+
5
+ const uuid = z.string().uuid();
6
+
7
+ const filterSchema = z.object({
8
+ spaceId: uuid,
9
+ typeIds: z.array(uuid).optional(),
10
+ locale: z.string().optional(),
11
+ status: z.enum(['draft', 'published', 'archived']).optional(),
12
+ parentId: uuid.nullable().optional(),
13
+ under: uuid.optional(),
14
+ search: z.string().max(200).optional(),
15
+ visibleInMenu: z.boolean().optional(),
16
+ fields: z
17
+ .array(
18
+ z.object({
19
+ name: z.string(),
20
+ op: z.enum([
21
+ 'eq',
22
+ 'neq',
23
+ 'lt',
24
+ 'lte',
25
+ 'gt',
26
+ 'gte',
27
+ 'in',
28
+ 'notIn',
29
+ 'contains',
30
+ 'startsWith',
31
+ 'endsWith',
32
+ 'isNull',
33
+ 'isNotNull',
34
+ ]),
35
+ value: z.unknown().optional(),
36
+ }),
37
+ )
38
+ .max(10)
39
+ .optional(),
40
+ });
41
+
42
+ const paginationSchema = z.object({
43
+ limit: z.number().int().min(1).max(200).default(25),
44
+ offset: z.number().int().min(0).default(0),
45
+ });
46
+
47
+ const sortSchema = z
48
+ .array(
49
+ z.object({
50
+ by: z.enum(['position', 'title', 'createdAt', 'updatedAt', 'publishedAt', 'slug']),
51
+ direction: z.enum(['asc', 'desc']).default('asc'),
52
+ }),
53
+ )
54
+ .max(3);
55
+
56
+ const saveSchema = z.object({
57
+ spaceId: uuid,
58
+ typeId: uuid,
59
+ locale: z.string().min(2).max(10).default('en'),
60
+ localizationId: uuid.optional(),
61
+ parentId: uuid.nullable().optional(),
62
+ title: z.string().min(1).max(500),
63
+ slug: z.string().max(200).optional(),
64
+ fields: z.record(z.string(), z.unknown()).default({}),
65
+ visibleInMenu: z.boolean().optional(),
66
+ position: z.number().int().optional(),
67
+ expectedVersion: z.number().int().positive().optional(),
68
+ });
69
+
70
+ export const contentRouter = {
71
+ list: scoped('content:read')
72
+ .input(
73
+ z
74
+ .object({
75
+ filter: filterSchema,
76
+ pagination: paginationSchema.optional(),
77
+ sort: sortSchema.optional(),
78
+ })
79
+ .transform((v) => ({ ...v, spaceId: v.filter.spaceId })),
80
+ )
81
+ .handler(async ({ input, context }) =>
82
+ context.content.list(
83
+ input.filter,
84
+ input.pagination ?? { limit: 25, offset: 0 },
85
+ input.sort ?? [],
86
+ { actor: toActor(context, input.filter.spaceId) },
87
+ ),
88
+ ),
89
+
90
+ tree: scoped('content:read')
91
+ .input(
92
+ z.object({
93
+ spaceId: uuid,
94
+ locale: z.string().default('en'),
95
+ rootId: uuid.nullable().default(null),
96
+ }),
97
+ )
98
+ .handler(async ({ input, context }) =>
99
+ context.content.tree(input.spaceId, input.locale, input.rootId),
100
+ ),
101
+
102
+ get: scoped('content:read')
103
+ .input(z.object({ spaceId: uuid, id: uuid }))
104
+ .handler(async ({ input, context }) =>
105
+ context.content.get(input.id, { actor: toActor(context, input.spaceId) }),
106
+ ),
107
+
108
+ /** Field values with defaults filled in — what the editor opens a new document with. */
109
+ blank: scoped('content:read')
110
+ .input(z.object({ spaceId: uuid, typeId: uuid }))
111
+ .handler(async ({ input, context }) => ({
112
+ fields: await context.content.initFields(context.manablox.contentTypes.get(input.typeId)),
113
+ })),
114
+
115
+ create: scoped('content:write')
116
+ .input(saveSchema)
117
+ .handler(async ({ input, context }) =>
118
+ context.content.create(input, toActor(context, input.spaceId)),
119
+ ),
120
+
121
+ update: scoped('content:write')
122
+ .input(saveSchema.extend({ id: uuid }))
123
+ .handler(async ({ input, context }) =>
124
+ context.content.update(input.id, input, toActor(context, input.spaceId)),
125
+ ),
126
+
127
+ delete: scoped('content:delete')
128
+ .input(z.object({ spaceId: uuid, id: uuid }))
129
+ .handler(async ({ input, context }) => ({
130
+ deleted: await context.content.delete(input.id, toActor(context, input.spaceId)),
131
+ })),
132
+
133
+ publish: scoped('content:publish')
134
+ .input(z.object({ spaceId: uuid, id: uuid }))
135
+ .handler(async ({ input, context }) =>
136
+ context.content.publish(input.id, toActor(context, input.spaceId)),
137
+ ),
138
+
139
+ unpublish: scoped('content:publish')
140
+ .input(z.object({ spaceId: uuid, id: uuid }))
141
+ .handler(async ({ input, context }) => {
142
+ await context.content.unpublish(input.id, toActor(context, input.spaceId));
143
+ return { ok: true };
144
+ }),
145
+
146
+ /** Reparent or reorder a document in the tree — a drag in the admin's tree panel. */
147
+ move: scoped('content:write')
148
+ .input(
149
+ z.object({
150
+ spaceId: uuid,
151
+ id: uuid,
152
+ parentId: uuid.nullable(),
153
+ position: z.number().int().min(0),
154
+ }),
155
+ )
156
+ .handler(async ({ input, context }) =>
157
+ context.content.move(input.spaceId, input.id, input.parentId, input.position),
158
+ ),
159
+
160
+ /** Every locale a document exists in, for the editor's language switcher. */
161
+ translations: scoped('content:read')
162
+ .input(z.object({ spaceId: uuid, id: uuid }))
163
+ .handler(async ({ input, context }) => context.content.translations(input.spaceId, input.id)),
164
+
165
+ /** Starts a translation of an existing document, in the localization group it shares. */
166
+ createTranslation: scoped('content:write')
167
+ .input(z.object({ spaceId: uuid, id: uuid, locale: z.string().min(2).max(10) }))
168
+ .handler(async ({ input, context }) =>
169
+ context.content.createTranslation(
170
+ input.spaceId,
171
+ input.id,
172
+ input.locale,
173
+ toActor(context, input.spaceId),
174
+ ),
175
+ ),
176
+
177
+ versions: scoped('content:read')
178
+ .input(z.object({ spaceId: uuid, id: uuid }))
179
+ .handler(async ({ input, context }) => context.repos.content.versions(input.id)),
180
+
181
+ versionSnapshot: scoped('content:read')
182
+ .input(z.object({ spaceId: uuid, id: uuid, version: z.number().int().positive() }))
183
+ .handler(async ({ input, context }) =>
184
+ context.repos.content.versionSnapshot(input.id, input.version),
185
+ ),
186
+
187
+ restore: scoped('content:write')
188
+ .input(z.object({ spaceId: uuid, id: uuid, version: z.number().int().positive() }))
189
+ .handler(async ({ input, context }) =>
190
+ context.content.restore(input.id, input.version, toActor(context, input.spaceId)),
191
+ ),
192
+ };
193
+
194
+ export { base };
195
+
196
+ function toActor(context: { principal: unknown }, spaceId: string) {
197
+ const principal = context.principal as { userId: string } | null;
198
+ if (!principal) return null;
199
+ return { userId: principal.userId, roles: actorRoles(principal as never, spaceId) };
200
+ }
@@ -0,0 +1,267 @@
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
+ };
@@ -0,0 +1,68 @@
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 ADDED
@@ -0,0 +1 @@
1
+ { "extends": "@manablox/config-typescript/library.json", "include": ["src", "test"] }