@crombolo/server 0.0.0-stage → 0.9.1

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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +432 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. package/README.md +0 -3
@@ -0,0 +1,963 @@
1
+ /**
2
+ * Users, user groups and user data over the identity tables, with Umbraco's
3
+ * rules about who may see and change whom: only admins see or touch admins,
4
+ * a non-admin may only hand out groups and start nodes they hold themselves,
5
+ * and nobody deletes or disables their own account.
6
+ */
7
+ import type {
8
+ NodeLookup,
9
+ Principal,
10
+ Result,
11
+ UserDataPort,
12
+ UserGroupInput,
13
+ UserGroupPort,
14
+ UserGroupResponse,
15
+ UserGroupStatus,
16
+ UserPort,
17
+ UserResponse,
18
+ UserStatus,
19
+ } from '@crombolo/api-management'
20
+ import {
21
+ generateToken,
22
+ hashPassword,
23
+ hashToken,
24
+ passwordConfigJson,
25
+ verifyPassword,
26
+ } from '@crombolo/auth'
27
+ import {
28
+ DEFAULT_PASSWORD_CONFIGURATION,
29
+ generateUserPassword,
30
+ hasPathAccess,
31
+ isValidEmail,
32
+ isValidPassword,
33
+ normaliseUuid,
34
+ type Page,
35
+ permissionsForPath,
36
+ type StartNodes,
37
+ sectionAppAlias,
38
+ sectionKey,
39
+ sectionName,
40
+ } from '@crombolo/core'
41
+ import {
42
+ type Db,
43
+ DbDate,
44
+ type GranularPermission,
45
+ SUPER_USER_KEY,
46
+ UserDataRepository,
47
+ UserGroupRepository,
48
+ type UserRecord,
49
+ UserRepository,
50
+ userState,
51
+ } from '@crombolo/data'
52
+ import { loadAccess } from '../access.ts'
53
+ import type { CromboloConfig } from '../config.ts'
54
+ import type { MediaFileStore } from '../media-files.ts'
55
+
56
+ const INVITE_HOURS = 72
57
+ const RESET_HOURS = 24
58
+ const AVATAR_SIZES = [30, 60, 90, 150, 300]
59
+ const AVATAR_EXTENSIONS = new Set(['jpeg', 'jpg', 'gif', 'bmp', 'png', 'tiff', 'tif', 'webp'])
60
+ /** Umbraco's system groups: their alias is fixed and they cannot be deleted. */
61
+ const SYSTEM_GROUPS = new Set(['admin', 'sensitiveData', 'translator'])
62
+ /**
63
+ * Namespaces an API user's client ID away from the backoffice client's own,
64
+ * which is `umbraco-back-office` and stays as it is — the vendored client sends
65
+ * it. Migration 020 renamed the credentials that carried the old prefix.
66
+ */
67
+ const CLIENT_ID_PREFIX = 'crombolo-back-office-'
68
+ const ISO_CODE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/i
69
+
70
+ export function avatarUrls(avatar: string | null): string[] {
71
+ return avatar ? AVATAR_SIZES.map((s) => `${avatar}?rmode=crop&width=${s}&height=${s}`) : []
72
+ }
73
+
74
+ const iso = (date: Date | undefined) => (date ? date.toISOString() : null)
75
+ const refs = (keys: readonly string[]) => keys.map((id) => ({ id }))
76
+
77
+ function toUserResponse(user: UserRecord, adminKey: string | undefined): UserResponse {
78
+ return {
79
+ id: user.key,
80
+ languageIsoCode: user.languageIsoCode,
81
+ documentStartNodeIds: refs(user.startNodes.document.keys),
82
+ hasDocumentRootAccess: user.startNodes.document.root,
83
+ mediaStartNodeIds: refs(user.startNodes.media.keys),
84
+ hasMediaRootAccess: user.startNodes.media.root,
85
+ elementStartNodeIds: refs(user.startNodes.element.keys),
86
+ hasElementRootAccess: user.startNodes.element.root,
87
+ avatarUrls: avatarUrls(user.avatar),
88
+ state: userState(user),
89
+ failedLoginAttempts: user.failedLoginAttempts,
90
+ createDate: user.createDate.toISOString(),
91
+ updateDate: user.updateDate.toISOString(),
92
+ lastLoginDate: iso(user.lastLoginDate),
93
+ lastLockoutDate: iso(user.lastLockoutDate),
94
+ lastPasswordChangeDate: iso(user.lastPasswordChangeDate),
95
+ isAdmin: adminKey !== undefined && user.groupKeys.includes(adminKey),
96
+ kind: user.kind,
97
+ email: user.email,
98
+ userName: user.userName,
99
+ name: user.name,
100
+ userGroupIds: refs(user.groupKeys),
101
+ }
102
+ }
103
+
104
+ type Tree = 'document' | 'media' | 'element'
105
+
106
+ export interface UserPortOptions {
107
+ config: Pick<
108
+ CromboloConfig,
109
+ 'usernameIsEmail' | 'applicationUrl' | 'sendUserLink' | 'allowPasswordReset' | 'backOfficePath'
110
+ >
111
+ mediaFiles: MediaFileStore
112
+ nodes: NodeLookup
113
+ /** Signs a user out everywhere, as a password change set by someone else does. */
114
+ endSessions: (userId: number) => Promise<void>
115
+ }
116
+
117
+ export function createUserPorts(
118
+ db: Db,
119
+ options: UserPortOptions,
120
+ ): { users: UserPort; groups: UserGroupPort; userData: UserDataPort } {
121
+ const users = new UserRepository(db)
122
+ const groups = new UserGroupRepository(db)
123
+ const { config, nodes } = options
124
+ const adminKey = async () => (await groups.byAlias('admin'))?.key
125
+ const isAdmin = (user: UserRecord, admin: string | undefined) =>
126
+ admin !== undefined && user.groupKeys.includes(admin)
127
+
128
+ /** Umbraco's `UserPermissionByResource`: only an admin may act on an admin. */
129
+ const mayActOn = async (principal: Principal, targets: readonly UserRecord[]) => {
130
+ if (principal.isAdmin) return true
131
+ const admin = await adminKey()
132
+ return !targets.some((t) => isAdmin(t, admin))
133
+ }
134
+
135
+ const chainsFor = async (tree: Tree, keys: readonly string[]) =>
136
+ tree === 'media' ? nodes.media(keys) : nodes.documents(keys)
137
+
138
+ /** Newly granted start nodes must lie where the performing user may work; root needs root. */
139
+ const mayGrantStartNodes = async (
140
+ performing: StartNodes,
141
+ tree: Tree,
142
+ granted: { root: boolean; keys: readonly string[] },
143
+ had: { root: boolean; keys: readonly string[] },
144
+ ) => {
145
+ if (granted.root && !had.root && !performing.root) return false
146
+ const added = granted.keys.filter((k) => !had.keys.includes(normaliseUuid(k)))
147
+ if (added.length === 0 || tree === 'element') return true
148
+ const found = await chainsFor(tree, added)
149
+ return added.every((key) => {
150
+ const node = found.get(normaliseUuid(key))
151
+ return !node || hasPathAccess(performing, node.chain, node.trashed)
152
+ })
153
+ }
154
+
155
+ /** Umbraco's `UserEditorAuthorizationHelper`: who may give which groups to whom. */
156
+ const mayAssignGroups = async (
157
+ principal: Principal,
158
+ target: UserRecord | undefined,
159
+ groupKeys: readonly string[],
160
+ ) => {
161
+ if (principal.isAdmin) return true
162
+ const admin = await adminKey()
163
+ if (target && isAdmin(target, admin)) return false
164
+ const wanted = groupKeys.map(normaliseUuid)
165
+ if (admin && wanted.includes(admin)) return false
166
+ const had = new Set(target?.groupKeys ?? [])
167
+ const mine = new Set(principal.groupKeys.map(normaliseUuid))
168
+ return wanted.every((k) => had.has(k) || mine.has(k))
169
+ }
170
+
171
+ const visibleTo = async (principal: Principal) => {
172
+ const admin = await adminKey()
173
+ return (user: UserRecord) =>
174
+ (normaliseUuid(principal.id) === SUPER_USER_KEY || user.key !== SUPER_USER_KEY) &&
175
+ (principal.isAdmin || !isAdmin(user, admin))
176
+ }
177
+
178
+ const validateIdentity = async (
179
+ input: { email: string; userName: string; name: string },
180
+ self?: UserRecord,
181
+ ): Promise<UserStatus> => {
182
+ if (!isValidEmail(input.email)) return 'InvalidEmail'
183
+ if (!input.userName) return 'InvalidUserName'
184
+ if (config.usernameIsEmail && input.userName.toLowerCase() !== input.email.toLowerCase())
185
+ return 'UserNameIsNotEmail'
186
+ const byName = await users.byUserName(input.userName)
187
+ if (byName && byName.key !== self?.key) return 'DuplicateUserName'
188
+ const byEmail = await users.byEmail(input.email)
189
+ if (byEmail && byEmail.key !== self?.key) return 'DuplicateEmail'
190
+ return 'Success'
191
+ }
192
+
193
+ const sendLink = async (user: UserRecord, kind: 'invite' | 'reset', message: string | null) => {
194
+ if (!config.sendUserLink) return false
195
+ const token = generateToken(32)
196
+ const hours = kind === 'invite' ? INVITE_HOURS : RESET_HOURS
197
+ await users.saveToken(user.id, kind, hashToken(token), new Date(Date.now() + hours * 3_600_000))
198
+ const flow =
199
+ kind === 'invite'
200
+ ? `flow=invite-user&userId=${user.key}&inviteCode=${encodeURIComponent(token)}`
201
+ : `flow=reset-password&userId=${user.key}&resetCode=${encodeURIComponent(token)}`
202
+ try {
203
+ await config.sendUserLink({
204
+ kind,
205
+ to: { name: user.name, email: user.email },
206
+ link: `${config.applicationUrl}${config.backOfficePath}/login?${flow}`,
207
+ message,
208
+ })
209
+ } catch {
210
+ // The provider rejected it or could not be reached. The sender has already
211
+ // logged why; here it is only the difference between `Success` and
212
+ // `CannotInvite`, and the saved token stays valid for a resend.
213
+ return false
214
+ }
215
+ return true
216
+ }
217
+
218
+ const setPassword = async (user: UserRecord, password: string) => {
219
+ await db.exec(
220
+ `UPDATE user_account SET password_hash = ?, password_config = ?, security_stamp = ?,
221
+ last_password_change_date = ?, failed_login_attempts = 0, update_date = ? WHERE id = ?`,
222
+ [
223
+ await hashPassword(password),
224
+ passwordConfigJson(),
225
+ crypto.randomUUID(),
226
+ DbDate.toDb(new Date()),
227
+ DbDate.toDb(new Date()),
228
+ user.id,
229
+ ],
230
+ )
231
+ }
232
+
233
+ async function create(
234
+ principal: Principal,
235
+ input: Parameters<UserPort['create']>[1],
236
+ invited: boolean,
237
+ ): Promise<Result<UserRecord>> {
238
+ const valid = await validateIdentity(input)
239
+ if (valid !== 'Success') return { status: valid }
240
+ if (input.groupKeys.length === 0) return { status: 'NoUserGroup' }
241
+ if ((await groups.byKeys(input.groupKeys)).length !== new Set(input.groupKeys).size)
242
+ return { status: 'MissingUserGroup' }
243
+ if (!(await mayAssignGroups(principal, undefined, input.groupKeys)))
244
+ return { status: 'Unauthorized' }
245
+ const created = await users.create({
246
+ key: input.key ?? crypto.randomUUID(),
247
+ name: input.name || input.userName,
248
+ userName: input.userName,
249
+ email: input.email,
250
+ kind: input.kind,
251
+ groupKeys: input.groupKeys,
252
+ languageIsoCode: null,
253
+ invited,
254
+ })
255
+ return created ? { status: 'Success', value: created } : { status: 'MissingUserGroup' }
256
+ }
257
+
258
+ const findAll = async (keys: readonly string[]) => {
259
+ const found = await users.byKeys(keys)
260
+ return found.length === new Set(keys.map(normaliseUuid)).size ? found : undefined
261
+ }
262
+
263
+ const userPort: UserPort = {
264
+ configuration: () => ({
265
+ canInviteUsers: Boolean(config.sendUserLink),
266
+ usernameIsEmail: config.usernameIsEmail,
267
+ passwordConfiguration: DEFAULT_PASSWORD_CONFIGURATION,
268
+ allowChangePassword: true,
269
+ allowTwoFactor: false,
270
+ }),
271
+ currentConfiguration: () => ({
272
+ keepUserLoggedIn: false,
273
+ passwordConfiguration: DEFAULT_PASSWORD_CONFIGURATION,
274
+ allowChangePassword: true,
275
+ allowTwoFactor: false,
276
+ }),
277
+ passwordConfiguration: () => DEFAULT_PASSWORD_CONFIGURATION,
278
+
279
+ async list(principal, skip, take) {
280
+ const admin = await adminKey()
281
+ const visible = (await users.all()).filter(await visibleTo(principal))
282
+ visible.sort((a, b) => a.userName.localeCompare(b.userName))
283
+ return {
284
+ total: visible.length,
285
+ items: visible.slice(skip, skip + take).map((u) => toUserResponse(u, admin)),
286
+ }
287
+ },
288
+
289
+ async filter(principal, query) {
290
+ const admin = await adminKey()
291
+ const text = query.filter?.toLowerCase()
292
+ const wantedGroups = query.groupKeys.map(normaliseUuid)
293
+ const states = query.states.filter((s) => s !== 'All')
294
+ const visible = (await users.all())
295
+ .filter(await visibleTo(principal))
296
+ .filter(
297
+ (u) =>
298
+ !text || u.name.toLowerCase().includes(text) || u.userName.toLowerCase().includes(text),
299
+ )
300
+ .filter(
301
+ (u) => wantedGroups.length === 0 || u.groupKeys.some((g) => wantedGroups.includes(g)),
302
+ )
303
+ .filter((u) => states.length === 0 || states.includes(userState(u)))
304
+ const field = (u: UserRecord): string | number => {
305
+ switch (query.orderBy) {
306
+ case 'Name':
307
+ return u.name.toLowerCase()
308
+ case 'Email':
309
+ return u.email.toLowerCase()
310
+ case 'Language':
311
+ return u.languageIsoCode ?? ''
312
+ case 'Id':
313
+ return u.id
314
+ case 'CreateDate':
315
+ return u.createDate.getTime()
316
+ case 'UpdateDate':
317
+ return u.updateDate.getTime()
318
+ case 'IsApproved':
319
+ return u.isApproved ? 1 : 0
320
+ case 'IsLockedOut':
321
+ return u.isLockedOut ? 1 : 0
322
+ case 'LastLoginDate':
323
+ return u.lastLoginDate?.getTime() ?? 0
324
+ default:
325
+ return u.userName.toLowerCase()
326
+ }
327
+ }
328
+ const sign = query.direction === 'Descending' ? -1 : 1
329
+ visible.sort((a, b) => {
330
+ const x = field(a)
331
+ const y = field(b)
332
+ return (x < y ? -1 : x > y ? 1 : 0) * sign
333
+ })
334
+ return {
335
+ total: visible.length,
336
+ items: visible
337
+ .slice(query.skip, query.skip + query.take)
338
+ .map((u) => toUserResponse(u, admin)),
339
+ }
340
+ },
341
+
342
+ async get(principal, key) {
343
+ const user = await users.byKey(key)
344
+ if (!user) return { status: 'UserNotFound' }
345
+ if (!(await mayActOn(principal, [user]))) return { status: 'Forbidden' }
346
+ return { status: 'Success', value: toUserResponse(user, await adminKey()) }
347
+ },
348
+
349
+ async batch(principal, keys) {
350
+ const admin = await adminKey()
351
+ const visible = await visibleTo(principal)
352
+ return (await users.byKeys(keys)).filter(visible).map((u) => toUserResponse(u, admin))
353
+ },
354
+
355
+ async create(principal, input) {
356
+ const result = await create(principal, input, false)
357
+ return result.status === 'Success' ? { status: 'Success', value: result.value.key } : result
358
+ },
359
+
360
+ async invite(principal, input, message) {
361
+ if (!config.sendUserLink) return { status: 'CannotInvite' }
362
+ const result = await create(principal, { ...input, kind: 'Default' }, true)
363
+ if (result.status !== 'Success') return result
364
+ await sendLink(result.value, 'invite', message)
365
+ return { status: 'Success', value: result.value.key }
366
+ },
367
+
368
+ async resendInvite(principal, key, message) {
369
+ const user = await users.byKey(key)
370
+ if (!user) return 'UserNotFound'
371
+ if (!(await mayActOn(principal, [user]))) return 'Forbidden'
372
+ if (userState(user) !== 'Invited') return 'NotInInviteState'
373
+ return (await sendLink(user, 'invite', message)) ? 'Success' : 'CannotInvite'
374
+ },
375
+
376
+ async verifyInvite(key, token) {
377
+ const user = await users.byKey(key)
378
+ if (!user) return 'UserNotFound'
379
+ if (userState(user) !== 'Invited') return 'NotInInviteState'
380
+ return (await users.checkToken(user.id, 'invite', hashToken(token), false))
381
+ ? 'Success'
382
+ : 'InvalidInviteToken'
383
+ },
384
+
385
+ async createInitialPassword(key, token, password) {
386
+ const user = await users.byKey(key)
387
+ if (!user) return 'UserNotFound'
388
+ if (userState(user) !== 'Invited') return 'NotInInviteState'
389
+ if (!isValidPassword(password)) return 'InvalidPassword'
390
+ if (!(await users.checkToken(user.id, 'invite', hashToken(token), true)))
391
+ return 'InvalidInviteToken'
392
+ await setPassword(user, password)
393
+ await users.approve(user.key)
394
+ return 'Success'
395
+ },
396
+
397
+ async update(principal, key, input) {
398
+ const user = await users.byKey(key)
399
+ if (!user) return 'UserNotFound'
400
+ if (!(await mayActOn(principal, [user]))) return 'Forbidden'
401
+ const valid = await validateIdentity(input, user)
402
+ if (valid !== 'Success') return valid
403
+ if (input.languageIsoCode && !ISO_CODE.test(input.languageIsoCode)) return 'InvalidIsoCode'
404
+ if (input.groupKeys.length === 0) return 'NoUserGroup'
405
+ if ((await groups.byKeys(input.groupKeys)).length !== new Set(input.groupKeys).size)
406
+ return 'MissingUserGroup'
407
+ if (!(await mayAssignGroups(principal, user, input.groupKeys))) return 'Unauthorized'
408
+ const startNodes = {
409
+ document: { root: input.hasDocumentRootAccess, keys: input.documentStartNodeKeys },
410
+ media: { root: input.hasMediaRootAccess, keys: input.mediaStartNodeKeys },
411
+ element: { root: input.hasElementRootAccess, keys: input.elementStartNodeKeys },
412
+ }
413
+ for (const tree of ['document', 'media', 'element'] as const)
414
+ if (
415
+ !(await mayGrantStartNodes(
416
+ principal.startNodes[tree],
417
+ tree,
418
+ startNodes[tree],
419
+ user.startNodes[tree],
420
+ ))
421
+ )
422
+ return 'Unauthorized'
423
+ const result = await users.update(key, {
424
+ name: input.name,
425
+ userName: input.userName,
426
+ email: input.email,
427
+ languageIsoCode: input.languageIsoCode || null,
428
+ groupKeys: input.groupKeys,
429
+ startNodes,
430
+ })
431
+ if (result === 'not-found') return 'UserNotFound'
432
+ if (result === 'missing-group') return 'MissingUserGroup'
433
+ if (result === 'document') return 'ContentStartNodeNotFound'
434
+ if (result === 'media') return 'MediaStartNodeNotFound'
435
+ if (result === 'element') return 'ElementStartNodeNotFound'
436
+ return 'Success'
437
+ },
438
+
439
+ async delete(principal, keys) {
440
+ if (keys.map(normaliseUuid).includes(normaliseUuid(principal.id))) return 'CannotDeleteSelf'
441
+ const found = await findAll(keys)
442
+ if (!found) return 'UserNotFound'
443
+ if (!(await mayActOn(principal, found))) return 'Forbidden'
444
+ if (found.some((u) => u.lastLoginDate)) return 'CannotDeleteUserWithLoginHistory'
445
+ for (const user of found) {
446
+ if (user.avatar) await options.mediaFiles.remove(user.avatar)
447
+ await users.delete(user.key)
448
+ }
449
+ return 'Success'
450
+ },
451
+
452
+ async setApproved(principal, keys, approved) {
453
+ if (!approved && keys.map(normaliseUuid).includes(normaliseUuid(principal.id)))
454
+ return 'CannotDisableSelf'
455
+ const found = await findAll(keys)
456
+ if (!found) return 'UserNotFound'
457
+ if (!(await mayActOn(principal, found))) return 'Forbidden'
458
+ if (!approved && found.some((u) => userState(u) === 'Invited'))
459
+ return 'CannotDisableInvitedUser'
460
+ await users.setApproved(keys, approved)
461
+ if (!approved) for (const user of found) await options.endSessions(user.id)
462
+ return 'Success'
463
+ },
464
+
465
+ async unlock(principal, keys) {
466
+ const found = await findAll(keys)
467
+ if (!found) return 'UserNotFound'
468
+ if (!(await mayActOn(principal, found))) return 'Forbidden'
469
+ await users.unlock(keys)
470
+ return 'Success'
471
+ },
472
+
473
+ async setGroups(principal, userKeys, groupKeys) {
474
+ const found = await findAll(userKeys)
475
+ if (!found) return 'UserNotFound'
476
+ if (!(await mayActOn(principal, found))) return 'Forbidden'
477
+ for (const user of found)
478
+ if (!(await mayAssignGroups(principal, user, groupKeys))) return 'Unauthorized'
479
+ return (await users.setGroups(userKeys, groupKeys)) ? 'Success' : 'MissingUserGroup'
480
+ },
481
+
482
+ async changePassword(principal, key, newPassword, oldPassword) {
483
+ const user = await users.byKey(key)
484
+ if (!user) return 'UserNotFound'
485
+ if (!(await mayActOn(principal, [user]))) return 'Forbidden'
486
+ const self = user.key === normaliseUuid(principal.id)
487
+ if (self) {
488
+ if (!oldPassword) return 'SelfOldPasswordRequired'
489
+ if (!(await verifyPassword(oldPassword, await users.passwordHash(user.key))))
490
+ return 'InvalidPassword'
491
+ }
492
+ if (!isValidPassword(newPassword)) return 'InvalidPassword'
493
+ await setPassword(user, newPassword)
494
+ if (!self) await options.endSessions(user.id)
495
+ return 'Success'
496
+ },
497
+
498
+ async resetPassword(principal, key) {
499
+ const user = await users.byKey(key)
500
+ if (!user) return { status: 'UserNotFound' }
501
+ if (!(await mayActOn(principal, [user]))) return { status: 'Forbidden' }
502
+ const password = generateUserPassword()
503
+ await setPassword(user, password)
504
+ if (user.key !== normaliseUuid(principal.id)) await options.endSessions(user.id)
505
+ return { status: 'Success', value: password }
506
+ },
507
+
508
+ async setAvatar(principal, key, temporaryFileId) {
509
+ const user = await users.byKey(key)
510
+ if (!user) return 'UserNotFound'
511
+ if (!(await mayActOn(principal, [user]))) return 'Forbidden'
512
+ const upload = await options.mediaFiles.temporary(temporaryFileId)
513
+ if (!upload) return 'AvatarFileNotFound'
514
+ if (!AVATAR_EXTENSIONS.has(upload.fileName.split('.').pop()?.toLowerCase() ?? ''))
515
+ return 'InvalidAvatar'
516
+ const placed = await options.mediaFiles.place(temporaryFileId)
517
+ if (!placed) return 'AvatarFileNotFound'
518
+ if (user.avatar) await options.mediaFiles.remove(user.avatar)
519
+ await users.setAvatar(user.key, placed.src)
520
+ return 'Success'
521
+ },
522
+
523
+ async removeAvatar(principal, key) {
524
+ const user = await users.byKey(key)
525
+ if (!user) return 'UserNotFound'
526
+ if (!(await mayActOn(principal, [user]))) return 'Forbidden'
527
+ if (user.avatar) await options.mediaFiles.remove(user.avatar)
528
+ await users.setAvatar(user.key, null)
529
+ return 'Success'
530
+ },
531
+
532
+ async calculateStartNodes(principal, key) {
533
+ const user = await users.byKey(key)
534
+ if (!user) return { status: 'UserNotFound' }
535
+ if (!(await mayActOn(principal, [user]))) return { status: 'Forbidden' }
536
+ const { startNodes } = await loadAccess(db, user.id)
537
+ return {
538
+ status: 'Success',
539
+ value: {
540
+ id: user.key,
541
+ documentStartNodeIds: refs(startNodes.document.keys),
542
+ hasDocumentRootAccess: startNodes.document.root,
543
+ mediaStartNodeIds: refs(startNodes.media.keys),
544
+ hasMediaRootAccess: startNodes.media.root,
545
+ elementStartNodeIds: refs(startNodes.element.keys),
546
+ hasElementRootAccess: startNodes.element.root,
547
+ },
548
+ }
549
+ },
550
+
551
+ async clientCredentials(principal, key) {
552
+ const user = await users.byKey(key)
553
+ if (!user) return { status: 'UserNotFound' }
554
+ if (!(await mayActOn(principal, [user]))) return { status: 'Forbidden' }
555
+ return { status: 'Success', value: await users.clientCredentials(user.id) }
556
+ },
557
+
558
+ async addClientCredential(principal, key, clientId, secret) {
559
+ const user = await users.byKey(key)
560
+ if (!user) return 'UserNotFound'
561
+ if (!(await mayActOn(principal, [user]))) return 'Forbidden'
562
+ if (user.kind !== 'Api') return 'InvalidUserType'
563
+ if (!clientId.startsWith(CLIENT_ID_PREFIX) || clientId.length <= CLIENT_ID_PREFIX.length)
564
+ return 'InvalidClientId'
565
+ if (!isValidPassword(secret)) return 'InvalidPassword'
566
+ return (await users.addClientCredential(user.id, clientId, await hashPassword(secret)))
567
+ ? 'Success'
568
+ : 'DuplicateClientId'
569
+ },
570
+
571
+ async removeClientCredential(principal, key, clientId) {
572
+ const user = await users.byKey(key)
573
+ if (!user) return 'UserNotFound'
574
+ if (!(await mayActOn(principal, [user]))) return 'Forbidden'
575
+ await users.removeClientCredential(user.id, clientId)
576
+ return 'Success'
577
+ },
578
+
579
+ async setLanguage(principal, isoCode) {
580
+ if (!ISO_CODE.test(isoCode)) return 'InvalidIsoCode'
581
+ await users.setLanguage(principal.id, isoCode)
582
+ return 'Success'
583
+ },
584
+
585
+ async permissions(principal, tree, keys) {
586
+ if (tree !== 'document') {
587
+ // Media carries no verbs; element verbs are the groups' own until the Library exists
588
+ const verbs =
589
+ tree === 'element'
590
+ ? [
591
+ ...new Set(
592
+ principal.groups.flatMap((g) =>
593
+ g.defaults.filter((p) => p.startsWith('Umb.Element')),
594
+ ),
595
+ ),
596
+ ]
597
+ : []
598
+ return keys.map((nodeKey) => ({ nodeKey: normaliseUuid(nodeKey), permissions: verbs }))
599
+ }
600
+ const found = await nodes.documents(keys)
601
+ return keys.flatMap((key) => {
602
+ const node = found.get(normaliseUuid(key))
603
+ if (!node) return []
604
+ return [
605
+ {
606
+ nodeKey: normaliseUuid(key),
607
+ permissions: [...permissionsForPath(principal.groups, node.chain)],
608
+ },
609
+ ]
610
+ })
611
+ },
612
+
613
+ async forgotPassword(email) {
614
+ if (!config.allowPasswordReset || !isValidEmail(email)) return
615
+ const user = await users.byEmail(email)
616
+ if (!user?.isApproved || user.isLockedOut) return
617
+ await sendLink(user, 'reset', null)
618
+ },
619
+
620
+ async verifyResetCode(key, code) {
621
+ const user = await users.byKey(key)
622
+ if (!user) return 'UserNotFound'
623
+ return (await users.checkToken(user.id, 'reset', hashToken(code), false))
624
+ ? 'Success'
625
+ : 'InvalidResetCode'
626
+ },
627
+
628
+ async resetWithCode(key, code, password) {
629
+ const user = await users.byKey(key)
630
+ if (!user) return 'UserNotFound'
631
+ if (!isValidPassword(password)) return 'InvalidPassword'
632
+ if (!(await users.checkToken(user.id, 'reset', hashToken(code), true)))
633
+ return 'InvalidResetCode'
634
+ await setPassword(user, password)
635
+ await options.endSessions(user.id)
636
+ return 'Success'
637
+ },
638
+ }
639
+
640
+ return {
641
+ users: userPort,
642
+ groups: createUserGroupPort(db, { adminKey, nodes }),
643
+ userData: createUserDataPort(db),
644
+ }
645
+ }
646
+
647
+ function toPermissions(rows: readonly GranularPermission[]): UserGroupResponse['permissions'] {
648
+ const out: UserGroupResponse['permissions'] = []
649
+ const byKey = new Map<string, Set<string>>()
650
+ const properties = new Map<
651
+ string,
652
+ { documentType: string; propertyType: string; verbs: Set<string> }
653
+ >()
654
+ const unknown = new Map<string, Set<string>>()
655
+ for (const row of rows) {
656
+ if (row.context === 'Document' && row.key) {
657
+ const verbs = byKey.get(row.key) ?? new Set<string>()
658
+ if (row.permission) verbs.add(row.permission)
659
+ byKey.set(row.key, verbs)
660
+ } else if (row.context === 'DocumentTypeProperty' && row.key) {
661
+ const [propertyType, verb = ''] = row.permission.split('|')
662
+ if (!propertyType) continue
663
+ const id = `${row.key}|${propertyType}`
664
+ const entry = properties.get(id) ?? {
665
+ documentType: row.key,
666
+ propertyType: normaliseUuid(propertyType),
667
+ verbs: new Set<string>(),
668
+ }
669
+ if (verb) entry.verbs.add(verb)
670
+ properties.set(id, entry)
671
+ } else if (!row.key) {
672
+ const verbs = unknown.get(row.context) ?? new Set<string>()
673
+ if (row.permission) verbs.add(row.permission)
674
+ unknown.set(row.context, verbs)
675
+ }
676
+ }
677
+ for (const [document, verbs] of byKey)
678
+ out.push({
679
+ $type: 'DocumentPermissionPresentationModel',
680
+ document: { id: document },
681
+ verbs: [...verbs],
682
+ })
683
+ for (const entry of properties.values())
684
+ out.push({
685
+ $type: 'DocumentPropertyValuePermissionPresentationModel',
686
+ documentType: { id: entry.documentType },
687
+ propertyType: { id: entry.propertyType },
688
+ verbs: [...entry.verbs],
689
+ })
690
+ for (const [context, verbs] of unknown)
691
+ out.push({ $type: 'UnknownTypePermissionPresentationModel', context, verbs: [...verbs] })
692
+ return out
693
+ }
694
+
695
+ function fromPermissions(permissions: UserGroupInput['permissions']): GranularPermission[] {
696
+ const rows: GranularPermission[] = []
697
+ for (const raw of permissions as unknown as Array<Record<string, unknown>>) {
698
+ const verbs = Array.isArray(raw.verbs)
699
+ ? [...new Set((raw.verbs as unknown[]).filter((v): v is string => typeof v === 'string'))]
700
+ : []
701
+ const idOf = (ref: unknown) => {
702
+ const id = (ref as { id?: unknown } | null | undefined)?.id
703
+ return typeof id === 'string' ? normaliseUuid(id) : null
704
+ }
705
+ switch (raw.$type) {
706
+ case 'DocumentPermissionPresentationModel': {
707
+ const key = idOf(raw.document)
708
+ // No verbs is an explicit "nothing here", stored as one empty verb, as Umbraco does
709
+ if (key)
710
+ for (const verb of verbs.length ? verbs : [''])
711
+ rows.push({ key, permission: verb, context: 'Document' })
712
+ break
713
+ }
714
+ case 'ElementPermissionPresentationModel': {
715
+ const key = idOf(raw.element)
716
+ if (key)
717
+ for (const verb of verbs.length ? verbs : [''])
718
+ rows.push({ key, permission: verb, context: 'Element' })
719
+ break
720
+ }
721
+ case 'DocumentPropertyValuePermissionPresentationModel': {
722
+ const documentType = idOf(raw.documentType)
723
+ const propertyType = idOf(raw.propertyType)
724
+ if (documentType && propertyType)
725
+ for (const verb of verbs.length ? verbs : [''])
726
+ rows.push({
727
+ key: documentType,
728
+ permission: `${propertyType}|${verb}`,
729
+ context: 'DocumentTypeProperty',
730
+ })
731
+ break
732
+ }
733
+ case 'UnknownTypePermissionPresentationModel':
734
+ if (typeof raw.context === 'string')
735
+ for (const verb of verbs) rows.push({ key: null, permission: verb, context: raw.context })
736
+ break
737
+ }
738
+ }
739
+ return rows
740
+ }
741
+
742
+ function createUserGroupPort(
743
+ db: Db,
744
+ deps: { adminKey: () => Promise<string | undefined>; nodes: NodeLookup },
745
+ ): UserGroupPort {
746
+ const groups = new UserGroupRepository(db)
747
+ const users = new UserRepository(db)
748
+
749
+ const toResponse = async (group: Awaited<ReturnType<UserGroupRepository['byKey']>> & object) => ({
750
+ id: group.key,
751
+ isDeletable: !SYSTEM_GROUPS.has(group.alias),
752
+ aliasCanBeChanged: !SYSTEM_GROUPS.has(group.alias),
753
+ name: group.name,
754
+ alias: group.alias,
755
+ description: group.description,
756
+ icon: group.icon,
757
+ sections: group.sections.map(sectionName),
758
+ languages: group.languages,
759
+ hasAccessToAllLanguages: group.hasAccessToAllLanguages,
760
+ documentStartNode: group.startNodes.document.key ? { id: group.startNodes.document.key } : null,
761
+ documentRootAccess: group.startNodes.document.root,
762
+ mediaStartNode: group.startNodes.media.key ? { id: group.startNodes.media.key } : null,
763
+ mediaRootAccess: group.startNodes.media.root,
764
+ elementStartNode: group.startNodes.element.key ? { id: group.startNodes.element.key } : null,
765
+ elementRootAccess: group.startNodes.element.root,
766
+ fallbackPermissions: group.permissions,
767
+ permissions: toPermissions(group.granular),
768
+ })
769
+
770
+ const isMember = (principal: Principal, key: string) =>
771
+ principal.isAdmin || principal.groupKeys.map(normaliseUuid).includes(normaliseUuid(key))
772
+
773
+ /** Umbraco's `UserGroupService.ValidateAccess` and input rules for create and update. */
774
+ const validate = async (
775
+ principal: Principal,
776
+ input: UserGroupInput,
777
+ existing?: { key: string; alias: string },
778
+ ): Promise<UserGroupStatus> => {
779
+ if (!input.name) return 'MissingName'
780
+ if (input.name.length > 200) return 'NameTooLong'
781
+ if (input.alias.length > 200) return 'AliasTooLong'
782
+ if (existing && SYSTEM_GROUPS.has(existing.alias) && input.alias !== existing.alias)
783
+ return 'CanNotUpdateAliasIsSystemUserGroup'
784
+ const clash = await groups.byAlias(input.alias)
785
+ if (clash && clash.key !== existing?.key) return 'DuplicateAlias'
786
+ const mine = new Set(principal.allowedSections.map(sectionKey))
787
+ if (!mine.has('users')) return 'Unauthorized'
788
+ if (!principal.isAdmin) {
789
+ // A grant is compared by section, not by spelling, so a group may hold an
790
+ // extension's section only if the caller holds it too.
791
+ if (!input.sections.every((s) => mine.has(sectionKey(s)))) return 'Unauthorized'
792
+ for (const [tree, key] of [
793
+ ['document', input.documentStartNode],
794
+ ['media', input.mediaStartNode],
795
+ ] as const) {
796
+ if (!key) continue
797
+ const found = await (tree === 'media'
798
+ ? deps.nodes.media([key])
799
+ : deps.nodes.documents([key]))
800
+ const node = found.get(normaliseUuid(key))
801
+ if (node && !hasPathAccess(principal.startNodes[tree], node.chain, node.trashed))
802
+ return 'Unauthorized'
803
+ }
804
+ }
805
+ return 'Success'
806
+ }
807
+
808
+ const save = async (input: UserGroupInput, key: string): Promise<UserGroupStatus> => {
809
+ const result = await groups.save({
810
+ key,
811
+ alias: input.alias,
812
+ name: input.name,
813
+ description: input.description,
814
+ icon: input.icon,
815
+ sections: input.sections.map(sectionAppAlias),
816
+ languages: input.languages,
817
+ hasAccessToAllLanguages: input.hasAccessToAllLanguages,
818
+ startNodes: {
819
+ document: { root: input.documentRootAccess, key: input.documentStartNode },
820
+ media: { root: input.mediaRootAccess, key: input.mediaStartNode },
821
+ element: { root: input.elementRootAccess, key: input.elementStartNode },
822
+ },
823
+ permissions: input.fallbackPermissions,
824
+ granular: fromPermissions(input.permissions),
825
+ })
826
+ if (result === 'saved') return 'Success'
827
+ if (result === 'missing-language') return 'LanguageNotFound'
828
+ return result.missingNode === 'document'
829
+ ? 'DocumentStartNodeKeyNotFound'
830
+ : result.missingNode === 'media'
831
+ ? 'MediaStartNodeKeyNotFound'
832
+ : 'ElementStartNodeKeyNotFound'
833
+ }
834
+
835
+ const page = async (
836
+ list: Awaited<ReturnType<UserGroupRepository['all']>>,
837
+ skip: number,
838
+ take: number,
839
+ ) => ({
840
+ total: list.length,
841
+ items: await Promise.all(list.slice(skip, skip + take).map(toResponse)),
842
+ })
843
+
844
+ const adminWouldBeEmpty = async (groupKey: string, removing: readonly string[]) => {
845
+ if (normaliseUuid(groupKey) !== (await deps.adminKey())) return false
846
+ const members = (await users.all()).filter((u) => u.groupKeys.includes(normaliseUuid(groupKey)))
847
+ const gone = new Set(removing.map(normaliseUuid))
848
+ return members.every((m) => gone.has(m.key))
849
+ }
850
+
851
+ return {
852
+ list: async (skip, take) => page(await groups.all(), skip, take),
853
+
854
+ async filter(principal, filter, skip, take) {
855
+ const admin = await deps.adminKey()
856
+ const text = filter.toLowerCase()
857
+ const list = (await groups.all())
858
+ .filter((g) => principal.isAdmin || (g.key !== admin && isMember(principal, g.key)))
859
+ .filter((g) => !text || g.name.toLowerCase().includes(text))
860
+ return page(list, skip, take)
861
+ },
862
+
863
+ async get(principal, key) {
864
+ const group = await groups.byKey(key)
865
+ if (!group) return 'NotFound'
866
+ if (!isMember(principal, group.key)) return 'Forbidden'
867
+ return toResponse(group)
868
+ },
869
+
870
+ async items(keys) {
871
+ return (await groups.byKeys(keys)).map((g) => ({
872
+ id: g.key,
873
+ name: g.name,
874
+ icon: g.icon,
875
+ alias: g.alias,
876
+ flags: [],
877
+ }))
878
+ },
879
+
880
+ async create(principal, input) {
881
+ const valid = await validate(principal, input)
882
+ if (valid !== 'Success') return { status: valid }
883
+ const key = normaliseUuid(input.key ?? crypto.randomUUID())
884
+ if (await groups.byKey(key)) return { status: 'DuplicateAlias' }
885
+ const saved = await save(input, key)
886
+ if (saved !== 'Success') return { status: saved }
887
+ // A non-admin who creates a group joins it, as in Umbraco
888
+ if (!principal.isAdmin) await users.addToGroup(key, [principal.id])
889
+ return { status: 'Success', key }
890
+ },
891
+
892
+ async update(principal, key, input) {
893
+ const group = await groups.byKey(key)
894
+ if (!group) return 'NotFound'
895
+ if (!isMember(principal, group.key)) return 'Forbidden'
896
+ const valid = await validate(principal, input, group)
897
+ if (valid !== 'Success') return valid
898
+ return save(input, group.key)
899
+ },
900
+
901
+ async delete(principal, keys) {
902
+ const found = await groups.byKeys(keys)
903
+ if (found.length !== new Set(keys.map(normaliseUuid)).size) return 'NotFound'
904
+ if (!found.every((g) => isMember(principal, g.key))) return 'Forbidden'
905
+ if (found.some((g) => SYSTEM_GROUPS.has(g.alias))) return 'IsSystemUserGroup'
906
+ for (const group of found) await groups.delete(group.key)
907
+ return 'Success'
908
+ },
909
+
910
+ async addUsers(principal, key, userKeys) {
911
+ const group = await groups.byKey(key)
912
+ if (!group) return 'NotFound'
913
+ if (!isMember(principal, group.key)) return 'Forbidden'
914
+ if ((await users.byKeys(userKeys)).length !== new Set(userKeys.map(normaliseUuid)).size)
915
+ return 'UserNotFound'
916
+ await users.addToGroup(group.key, userKeys)
917
+ return 'Success'
918
+ },
919
+
920
+ async removeUsers(principal, key, userKeys) {
921
+ const group = await groups.byKey(key)
922
+ if (!group) return 'NotFound'
923
+ if (!isMember(principal, group.key)) return 'Forbidden'
924
+ if ((await users.byKeys(userKeys)).length !== new Set(userKeys.map(normaliseUuid)).size)
925
+ return 'UserNotFound'
926
+ if (await adminWouldBeEmpty(group.key, userKeys)) return 'AdminGroupCannotBeEmpty'
927
+ await users.removeFromGroup(group.key, userKeys)
928
+ return 'Success'
929
+ },
930
+ }
931
+ }
932
+
933
+ function createUserDataPort(db: Db): UserDataPort {
934
+ const repo = new UserDataRepository(db)
935
+ const idOf = async (principal: Principal) =>
936
+ (await new UserRepository(db).byKey(principal.id))?.id ?? -1
937
+ return {
938
+ async list(principal, filter, skip, take) {
939
+ return repo.list(await idOf(principal), filter, skip, take) as Promise<
940
+ Page<{
941
+ key: string
942
+ group: string
943
+ identifier: string
944
+ value: string
945
+ }>
946
+ >
947
+ },
948
+ get: async (principal, key) => repo.get(await idOf(principal), key),
949
+ async create(principal, entry) {
950
+ return (await repo.create(await idOf(principal), entry)) === 'Success'
951
+ ? 'Success'
952
+ : 'AlreadyExists'
953
+ },
954
+ async update(principal, entry) {
955
+ return (await repo.update(await idOf(principal), entry)) === 'Success'
956
+ ? 'Success'
957
+ : 'NotFound'
958
+ },
959
+ async delete(principal, key) {
960
+ return (await repo.delete(await idOf(principal), key)) === 'Success' ? 'Success' : 'NotFound'
961
+ },
962
+ }
963
+ }