@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.
@@ -0,0 +1,51 @@
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,230 +1,125 @@
1
- import { ManabloxError } from '@manablox/core';
2
- import { SpaceTransferService } from '@manablox/services';
3
1
  import { z } from 'zod';
4
2
  import { authed, base, scoped, superadmin } from '../base.js';
5
- import type { RpcContext } from '../context.js';
6
-
7
- const uuid = z.string().uuid();
3
+ import {
4
+ locale,
5
+ localeList,
6
+ machineName,
7
+ mimeTypePattern,
8
+ searchTerm,
9
+ spaceRole,
10
+ uuid,
11
+ } from '../schemas.js';
8
12
 
9
13
  const spaceSchema = z.object({
10
14
  name: z.string().min(1).max(200),
11
- machineName: z
12
- .string()
13
- .regex(/^[a-z][a-z0-9_-]*$/)
14
- .max(64),
15
+ machineName,
15
16
  description: z.string().nullable().optional(),
16
17
  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']),
18
+ defaultLocale: locale.default('en'),
19
+ locales: localeList.default(['en']),
19
20
  settings: z.record(z.string(), z.unknown()).optional(),
20
21
  });
21
22
 
22
23
  /**
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`.
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.
26
27
  */
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
28
  export const spaceRouter = {
82
- /** Only the spaces the caller is a member of — a superadmin sees all. */
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
+ */
83
34
  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]));
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;
89
42
  }),
90
43
 
91
44
  get: scoped('space:read')
92
45
  .input(z.object({ spaceId: uuid }))
93
46
  .handler(async ({ input, context }) => context.repos.spaces.findById(input.spaceId)),
94
47
 
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
- }),
48
+ create: superadmin
49
+ .input(spaceSchema)
50
+ .handler(async ({ input, context }) =>
51
+ context.spaces.create(input, context.principal?.userId ?? null),
52
+ ),
106
53
 
107
54
  update: scoped('space:write')
108
55
  .input(spaceSchema.partial().extend({ spaceId: uuid }))
109
56
  .handler(async ({ input, context }) => {
110
57
  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));
58
+ return context.spaces.update(spaceId, data);
126
59
  }),
127
60
 
128
61
  delete: scoped('space:delete')
129
62
  .input(z.object({ spaceId: uuid }))
130
63
  .handler(async ({ input, context }) => {
131
- await context.repos.spaces.delete(input.spaceId);
64
+ await context.spaces.delete(input.spaceId);
132
65
  return { ok: true };
133
66
  }),
134
67
 
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
- */
68
+ /** Nominates one document as the space's root; `null` clears it. */
143
69
  setHome: scoped('space:write')
144
70
  .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
- }),
71
+ .handler(async ({ input, context }) => context.spaces.setHome(input.spaceId, input.contentId)),
162
72
 
163
73
  /**
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.
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".
167
76
  */
168
- export: scoped('space:write')
169
- .input(z.object({ spaceId: uuid }))
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
+ )
170
85
  .handler(async ({ input, context }) =>
171
- new SpaceTransferService(context.manablox, context.repos).export(input.spaceId),
86
+ context.spaces.setAssetSettings(input.spaceId, {
87
+ ...(input.allowedMimeTypes?.length ? { allowedMimeTypes: input.allowedMimeTypes } : {}),
88
+ ...(input.maxFileSize ? { maxFileSize: input.maxFileSize } : {}),
89
+ }),
172
90
  ),
173
91
 
174
92
  /**
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`.
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.
177
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. */
178
102
  import: superadmin
179
103
  .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
- }),
104
+ .handler(async ({ input, context }) =>
105
+ context.spaces.import(input.payload, context.principal.userId),
106
+ ),
189
107
 
190
108
  members: scoped('user:read')
191
109
  .input(z.object({ spaceId: uuid }))
192
- .handler(async ({ input, context }) => context.repos.users.membersOf(input.spaceId)),
110
+ .handler(async ({ input, context }) => context.spaces.members(input.spaceId)),
193
111
 
194
112
  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
- )
113
+ .input(z.object({ spaceId: uuid, userId: uuid, role: spaceRole }))
202
114
  .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);
115
+ await context.spaces.grant(input.spaceId, input.userId, input.role);
208
116
  return { ok: true };
209
117
  }),
210
118
 
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
- */
119
+ /** Users who are not yet members, for the add-member picker. */
216
120
  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
- }),
121
+ .input(z.object({ spaceId: uuid, search: searchTerm.optional() }))
122
+ .handler(async ({ input, context }) => context.spaces.candidates(input.spaceId, input.search)),
228
123
 
229
124
  /** Grants the same role to several users at once, as the picker hands them over. */
230
125
  addMembers: scoped('user:write')
@@ -232,36 +127,23 @@ export const spaceRouter = {
232
127
  z.object({
233
128
  spaceId: uuid,
234
129
  userIds: z.array(uuid).min(1).max(100),
235
- role: z.enum(['owner', 'admin', 'editor', 'author', 'viewer']).default('editor'),
130
+ role: spaceRole.default('editor'),
236
131
  }),
237
132
  )
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
- }),
133
+ .handler(async ({ input, context }) => ({
134
+ ok: true,
135
+ added: await context.spaces.addMembers(input.spaceId, input.userIds, input.role),
136
+ })),
250
137
 
251
138
  revoke: scoped('user:write')
252
139
  .input(z.object({ spaceId: uuid, userId: uuid }))
253
140
  .handler(async ({ input, context }) => {
254
- await assertNotLastOwner(context, input.spaceId, input.userId);
255
- await context.repos.users.revoke(input.userId, input.spaceId);
141
+ await context.spaces.revoke(input.spaceId, input.userId);
256
142
  return { ok: true };
257
143
  }),
258
144
 
259
145
  /** 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
- }),
146
+ locales: base
147
+ .input(z.object({ spaceId: uuid }))
148
+ .handler(async ({ input, context }) => context.spaces.locales(input.spaceId)),
267
149
  };
@@ -1,9 +1,19 @@
1
- import { assertCan } from '@manablox/auth';
1
+ import { assertCan, effectiveGrants, MIN_PASSWORD_LENGTH, normaliseGrants } from '@manablox/auth';
2
+ import { ManabloxError } from '@manablox/core';
2
3
  import { z } from 'zod';
3
- import { authed, superadmin } from '../base.js';
4
+ import { authed, base, superadmin } from '../base.js';
5
+ import { pagination, searchTerm, uuid } from '../schemas.js';
4
6
 
5
- const uuid = z.string().uuid();
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);
6
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
+ */
7
17
  export const userRouter = {
8
18
  me: authed.handler(async ({ context }) => {
9
19
  const user = await context.repos.users.findById(context.principal.userId);
@@ -15,25 +25,88 @@ export const userRouter = {
15
25
  image: user.image,
16
26
  role: user.role,
17
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
+ ),
18
36
  }
19
37
  : null;
20
38
  }),
21
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
+
22
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
23
60
  .input(
24
61
  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),
62
+ name: displayName,
63
+ email,
64
+ password,
65
+ role: instanceRole.default('editor'),
28
66
  }),
29
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() }))
30
91
  .handler(async ({ input, context }) =>
31
- context.repos.users.list({ limit: input.limit, offset: input.offset }, input.search),
92
+ context.users.ban(context.principal.userId, input.userId, input.reason || null),
32
93
  ),
33
94
 
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)),
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
+ }),
37
110
 
38
111
  apiKeys: authed.handler(async ({ context }) => context.apiKeys.list(context.principal.userId)),
39
112
 
@@ -44,6 +117,12 @@ export const userRouter = {
44
117
  expiresAt: z.coerce.date().optional(),
45
118
  /** Empty or omitted issues an unrestricted key. */
46
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(),
47
126
  }),
48
127
  )
49
128
  .handler(async ({ input, context }) => {
@@ -52,9 +131,37 @@ export const userRouter = {
52
131
  for (const spaceId of input.spaceIds ?? []) {
53
132
  assertCan(context.principal, spaceId, 'space:read');
54
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
+ }
55
161
  return context.apiKeys.issue(context.principal.userId, input.name, {
56
162
  expiresAt: input.expiresAt,
57
163
  spaceIds: input.spaceIds,
164
+ permissions,
58
165
  });
59
166
  }),
60
167