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