@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,347 @@
1
+ /**
2
+ * The Media section: items stored as documents of the media object type (the
3
+ * repository takes a kind), never published. An item's file is the path its
4
+ * `umbracoFile` holds, served from the media root; deleting an item for good
5
+ * deletes its file too.
6
+ */
7
+ import type { MediaPort, Principal } from '@crombolo/api-management'
8
+ import {
9
+ type ContentTypeAggregate,
10
+ DEFAULT_UPLOAD_SETTINGS,
11
+ type DocumentAggregate,
12
+ IMAGE_CROPPER_EDITOR_ALIAS,
13
+ normaliseUuid,
14
+ ObjectTypes,
15
+ } from '@crombolo/core'
16
+ import {
17
+ appendCacheInstruction,
18
+ ContentTypeRepository,
19
+ DataTypeRepository,
20
+ type Db,
21
+ DocumentRepository,
22
+ type NodeRow,
23
+ type NodeSchemaState,
24
+ type ValueIntake,
25
+ WriteRejectedError,
26
+ } from '@crombolo/data'
27
+ import { TemporaryFileMissingError } from '../file-intake.ts'
28
+ import type { MediaFileStore } from '../media-files.ts'
29
+
30
+ /** The file path an item's `umbracoFile` holds, whether an upload or a cropper value. */
31
+ export function mediaFileOf(media: DocumentAggregate): string | null {
32
+ const value = media.values.find((v) => v.alias === 'umbracoFile')?.value
33
+ if (typeof value === 'string') return value || null
34
+ const src = (value as { src?: unknown } | null | undefined)?.src
35
+ return typeof src === 'string' && src ? src : null
36
+ }
37
+
38
+ export function createMediaPort(
39
+ db: Db,
40
+ files: MediaFileStore,
41
+ options: {
42
+ nodeState?: NodeSchemaState
43
+ nodeId?: string
44
+ valueIntake?: ValueIntake
45
+ /** Told after every change, so pages that show media refresh. */
46
+ onChange?: () => void | Promise<void>
47
+ } = {},
48
+ ): MediaPort {
49
+ const changed = async () => {
50
+ await appendCacheInstruction(db, {
51
+ kind: 'content',
52
+ payload: { media: true },
53
+ by: options.nodeId,
54
+ })
55
+ await options.onChange?.()
56
+ }
57
+ const repo = new DocumentRepository(db, { ...options, kind: 'media' })
58
+
59
+ const userIdOf = async (principal: Principal): Promise<number | undefined> => {
60
+ const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
61
+ principal.id,
62
+ ])
63
+ return rows[0] ? Number(rows[0].id) : undefined
64
+ }
65
+ const failure = (error: unknown) => {
66
+ if (error instanceof WriteRejectedError)
67
+ return { ok: false as const, reason: error.message, status: 409 }
68
+ if (error instanceof TemporaryFileMissingError)
69
+ return { ok: false as const, reason: error.message, status: 400 }
70
+ throw error
71
+ }
72
+ const notFound = (what = 'The media item could not be found.') => ({
73
+ ok: false as const,
74
+ reason: what,
75
+ status: 404,
76
+ })
77
+ const tree = async (rows: readonly NodeRow[]) => repo.treeItems(rows)
78
+ const mediaTypes = new ContentTypeRepository(db, { kind: 'media' })
79
+ const dataTypes = new DataTypeRepository(db)
80
+
81
+ /** Every media type with the data type of its `umbracoFile`, compositions included. */
82
+ const mediaTypesWithFile = async () => {
83
+ const all = await mediaTypes.all()
84
+ const byKey = new Map(all.map((t) => [t.key, t]))
85
+ const fileOf = (type: ContentTypeAggregate, seen = new Set<string>()): string | undefined => {
86
+ if (seen.has(type.key)) return undefined
87
+ seen.add(type.key)
88
+ const own = type.properties.find((p) => p.alias === 'umbracoFile')?.dataTypeKey
89
+ if (own) return own
90
+ for (const c of type.compositions) {
91
+ const composed = byKey.get(c.contentTypeKey)
92
+ const found = composed ? fileOf(composed, seen) : undefined
93
+ if (found) return found
94
+ }
95
+ return undefined
96
+ }
97
+ const result = []
98
+ for (const type of all) {
99
+ const dataTypeKey = fileOf(type)
100
+ result.push({ type, file: dataTypeKey ? await dataTypes.byKey(dataTypeKey) : undefined })
101
+ }
102
+ return result
103
+ }
104
+ const allowedExtensions = (values: ReadonlyArray<{ alias: string; value: unknown }>) => {
105
+ const raw = values.find((v) => v.alias === 'fileExtensions')?.value
106
+ if (!Array.isArray(raw)) return []
107
+ return raw
108
+ .map((e) => (typeof e === 'string' ? e : (e as { value?: unknown })?.value))
109
+ .filter((e): e is string => typeof e === 'string')
110
+ .map((e) => e.replace(/^\./, '').toLowerCase())
111
+ }
112
+
113
+ /** Every file in a branch, gathered before the branch is deleted. */
114
+ const filesIn = async (key: string): Promise<string[]> => {
115
+ const root = await repo.nodes.byKey(key)
116
+ if (!root) return []
117
+ const rows = await db.query<{ unique_id: string }>(
118
+ 'SELECT unique_id FROM node WHERE (id = ? OR path LIKE ?) AND node_object_type = ?',
119
+ [root.id, `${root.path},%`, ObjectTypes.Media],
120
+ )
121
+ const found: string[] = []
122
+ for (const row of rows) {
123
+ const media = await repo.byKey(normaliseUuid(String(row.unique_id)))
124
+ const file = media ? mediaFileOf(media) : null
125
+ if (file) found.push(file)
126
+ }
127
+ return found
128
+ }
129
+ const removeBranch = async (key: string) => {
130
+ const paths = await filesIn(key)
131
+ const removed = await repo.delete(key)
132
+ if (removed) {
133
+ for (const path of paths) await files.remove(path)
134
+ await changed()
135
+ }
136
+ return removed
137
+ }
138
+
139
+ return {
140
+ byKey: (key) => repo.byKey(key),
141
+ validate: (input) => repo.validate(input, null),
142
+
143
+ async create(input, principal) {
144
+ try {
145
+ const created = await repo.create({
146
+ ...input,
147
+ componentKey: null,
148
+ userId: await userIdOf(principal),
149
+ })
150
+ await changed()
151
+ return { ok: true as const, key: created.key }
152
+ } catch (error) {
153
+ return failure(error)
154
+ }
155
+ },
156
+
157
+ async update(key, input, principal) {
158
+ try {
159
+ const before = await repo.byKey(key)
160
+ const updated = await repo.update({
161
+ ...input,
162
+ key,
163
+ componentKey: null,
164
+ userId: await userIdOf(principal),
165
+ })
166
+ if (!updated) return notFound()
167
+ // A replaced file is no longer anyone's: Umbraco deletes it too.
168
+ const was = before ? mediaFileOf(before) : null
169
+ if (was && was !== mediaFileOf(updated)) await files.remove(was)
170
+ await changed()
171
+ return { ok: true as const }
172
+ } catch (error) {
173
+ return failure(error)
174
+ }
175
+ },
176
+
177
+ remove: removeBranch,
178
+ async moveToRecycleBin(key) {
179
+ const moved = await repo.moveToRecycleBin(key)
180
+ if (moved) await changed()
181
+ return moved
182
+ },
183
+
184
+ async move(key, targetKey) {
185
+ const result = await repo.move(key, targetKey)
186
+ if (result === 'moved') {
187
+ await changed()
188
+ return { ok: true as const }
189
+ }
190
+ return result === 'notFound'
191
+ ? notFound('The media item or target could not be found.')
192
+ : { ok: false as const, reason: 'A media item cannot move below itself.', status: 400 }
193
+ },
194
+
195
+ async sort(parentKey, sorting) {
196
+ return (await repo.sort(parentKey, sorting)) === 'sorted'
197
+ ? { ok: true as const }
198
+ : notFound('The parent, or one of the items, could not be found under it.')
199
+ },
200
+
201
+ async sortChildrenBy(parentKey, field, direction) {
202
+ return (await repo.sortChildrenBy(parentKey, field, direction)) === 'sorted'
203
+ ? { ok: true as const }
204
+ : notFound()
205
+ },
206
+
207
+ async restore(key, targetKey) {
208
+ const result = await repo.restore(key, targetKey)
209
+ if (result === 'restored') {
210
+ await changed()
211
+ return { ok: true as const }
212
+ }
213
+ return result === 'notFound'
214
+ ? notFound('The media item is not in the recycle bin.')
215
+ : {
216
+ ok: false as const,
217
+ reason: 'The restore target does not exist or is in the recycle bin.',
218
+ status: 400,
219
+ }
220
+ },
221
+
222
+ async emptyRecycleBin() {
223
+ for (const node of await repo.trashedRoots()) await removeBranch(node.key)
224
+ },
225
+
226
+ originalParent: (key) => repo.originalParent(key),
227
+ versions: (key) => repo.versions(key),
228
+ async systemUserKey() {
229
+ const rows = await db.query<{ key: string }>(
230
+ 'SELECT key FROM user_account ORDER BY id LIMIT 1',
231
+ )
232
+ return rows[0] ? normaliseUuid(String(rows[0].key)) : null
233
+ },
234
+
235
+ async treeRoot(paging) {
236
+ const page = await repo.children(null, paging.skip, paging.take)
237
+ return { total: page.total, items: await tree(page.items) }
238
+ },
239
+ async treeChildren(parentKey, paging) {
240
+ const page = await repo.children(parentKey, paging.skip, paging.take)
241
+ return { total: page.total, items: await tree(page.items) }
242
+ },
243
+ treeAncestors: async (key) =>
244
+ tree((await repo.nodes.ancestors(key)).filter((row) => row.objectType === ObjectTypes.Media)),
245
+ async treeSiblings(target, before, after) {
246
+ const window = await repo.siblings(target, before, after)
247
+ if (!window) return undefined
248
+ return { ...window, items: await tree(window.items) }
249
+ },
250
+ async recycleBin(parentKey, paging) {
251
+ const page = await repo.trashed(parentKey, paging.skip, paging.take)
252
+ return { total: page.total, items: await tree(page.items) }
253
+ },
254
+ async items(keys) {
255
+ const rows = await Promise.all(keys.map((key) => repo.nodes.byKey(key)))
256
+ return tree(rows.filter((row): row is NodeRow => row?.objectType === ObjectTypes.Media))
257
+ },
258
+ async search(query, options) {
259
+ const page = await repo.search(query, options)
260
+ return { total: page.total, items: await tree(page.items) }
261
+ },
262
+
263
+ async collection(parentKey, options) {
264
+ const children = await repo.children(parentKey, 0, Number.MAX_SAFE_INTEGER)
265
+ const filter = options.filter?.trim().toLowerCase()
266
+ const loaded: Array<DocumentAggregate & { sortOrder: number; creator: string | null }> = []
267
+ for (const node of children.items) {
268
+ const media = await repo.load(node)
269
+ if (!media) continue
270
+ const name = media.variants[0]?.name ?? node.text ?? ''
271
+ if (filter && !name.toLowerCase().includes(filter)) continue
272
+ const latest = (await repo.versions(node.key))[0]
273
+ const creator = latest?.userKey
274
+ ? ((
275
+ await db.query<{ user_name: string }>(
276
+ 'SELECT user_name FROM user_account WHERE key = ?',
277
+ [latest.userKey],
278
+ )
279
+ )[0]?.user_name ?? null)
280
+ : null
281
+ loaded.push({ ...media, sortOrder: node.sortOrder, creator })
282
+ }
283
+ const keyOf = (m: (typeof loaded)[number]): string | number => {
284
+ const variant = m.variants[0]
285
+ switch (options.orderBy) {
286
+ case 'name':
287
+ return (variant?.name ?? '').toLowerCase()
288
+ case 'createDate':
289
+ return variant?.createDate.getTime() ?? 0
290
+ case 'updateDate':
291
+ return variant?.updateDate.getTime() ?? 0
292
+ default:
293
+ return m.sortOrder
294
+ }
295
+ }
296
+ loaded.sort((a, b) => {
297
+ const x = keyOf(a)
298
+ const y = keyOf(b)
299
+ const order = x < y ? -1 : x > y ? 1 : 0
300
+ return options.orderDirection === 'Descending' ? -order : order
301
+ })
302
+ return {
303
+ total: loaded.length,
304
+ items: loaded.slice(options.skip, options.skip + options.take),
305
+ }
306
+ },
307
+
308
+ async typesForExtension(raw) {
309
+ const extension = raw.replace(/^\./, '').toLowerCase()
310
+ const results: Array<{ key: string; name: string; icon: string; matched: boolean }> = []
311
+ const catchAll: typeof results = []
312
+ for (const { type, file } of await mediaTypesWithFile()) {
313
+ if (!file) continue
314
+ const allowed = allowedExtensions(file.values)
315
+ const entry = { key: type.key, name: type.name, icon: type.icon }
316
+ if (file.editorAlias === IMAGE_CROPPER_EDITOR_ALIAS) {
317
+ if (DEFAULT_UPLOAD_SETTINGS.imageFileTypes.includes(extension))
318
+ results.push({ ...entry, matched: true })
319
+ } else if (allowed.includes(extension)) results.push({ ...entry, matched: true })
320
+ else if (allowed.length === 0) catchAll.push({ ...entry, matched: false })
321
+ }
322
+ const seen = new Set(results.map((r) => r.key))
323
+ return [...results, ...catchAll.filter((c) => !seen.has(c.key))]
324
+ },
325
+
326
+ async folderTypes() {
327
+ const all = await mediaTypesWithFile()
328
+ const folders = all
329
+ .filter(({ type, file }) => !file && type.allowedContentTypes.length > 0)
330
+ .map(({ type }) => type)
331
+ if (!folders.some((t) => t.alias === 'Folder')) {
332
+ const folder = all.find(({ type }) => type.alias === 'Folder')?.type
333
+ if (folder) folders.push(folder)
334
+ }
335
+ return folders.map((t) => ({ key: t.key, name: t.name, icon: t.icon }))
336
+ },
337
+
338
+ async urls(keys) {
339
+ const result = []
340
+ for (const key of keys) {
341
+ const media = await repo.byKey(key)
342
+ if (media) result.push({ key: media.key, url: mediaFileOf(media) })
343
+ }
344
+ return result
345
+ },
346
+ }
347
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Member groups are plain `node` rows with the MemberGroup object type — no
3
+ * table of their own, so this is the node repository with a name-uniqueness
4
+ * rule on top, as Umbraco's `MemberGroupService` has.
5
+ */
6
+ import type { MemberGroupPort, MemberGroupStatus } from '@crombolo/api-management'
7
+ import { ObjectTypes, type Page, SystemNodes } from '@crombolo/core'
8
+ import { type Db, NodeRepository } from '@crombolo/data'
9
+
10
+ const TYPE = ObjectTypes.MemberGroup
11
+
12
+ export function createMemberGroupPort(db: Db): MemberGroupPort {
13
+ const nodes = new NodeRepository(db)
14
+
15
+ const named = async (name: string): Promise<{ key: string } | undefined> => {
16
+ const rows = await db.query<{ unique_id: string }>(
17
+ 'SELECT n.unique_id FROM node n WHERE n.node_object_type = ? AND n.text = ?',
18
+ [TYPE, name],
19
+ )
20
+ const key = rows[0]?.unique_id
21
+ return key ? { key: String(key) } : undefined
22
+ }
23
+
24
+ const page = async (skip: number, take: number): Promise<Page<{ id: string; name: string }>> => {
25
+ const result = await nodes.roots(TYPE, skip, take)
26
+ return {
27
+ total: result.total,
28
+ items: result.items.map((row) => ({ id: row.key, name: row.text ?? '' })),
29
+ }
30
+ }
31
+
32
+ return {
33
+ list: (paging) => page(paging.skip, paging.take),
34
+
35
+ async byKey(key) {
36
+ const node = await nodes.byKey(key)
37
+ if (!node || node.objectType !== TYPE) return undefined
38
+ return { id: node.key, name: node.text ?? '' }
39
+ },
40
+
41
+ async items(keys) {
42
+ const rows = await nodes.byKeys(keys)
43
+ return rows
44
+ .filter((row) => row.objectType === TYPE)
45
+ .map((row) => ({ id: row.key, name: row.text ?? '', flags: [] }))
46
+ },
47
+
48
+ async tree(paging) {
49
+ const result = await page(paging.skip, paging.take)
50
+ return {
51
+ total: result.total,
52
+ // A flat list: no group ever has children, and none has a parent.
53
+ items: result.items.map((item) => ({
54
+ ...item,
55
+ parent: null,
56
+ hasChildren: false,
57
+ flags: [],
58
+ })),
59
+ }
60
+ },
61
+
62
+ async create(input) {
63
+ if (await named(input.name)) return { status: 'duplicate-name' }
64
+ const node = await nodes.create({
65
+ key: input.key,
66
+ parentId: SystemNodes.Root,
67
+ objectType: TYPE,
68
+ text: input.name,
69
+ })
70
+ return { status: 'ok', key: node.key }
71
+ },
72
+
73
+ async rename(key, name): Promise<MemberGroupStatus> {
74
+ const node = await nodes.byKey(key)
75
+ if (!node || node.objectType !== TYPE) return 'not-found'
76
+ const clash = await named(name)
77
+ if (clash && clash.key !== node.key) return 'duplicate-name'
78
+ await nodes.rename(node.id, name)
79
+ return 'ok'
80
+ },
81
+
82
+ async remove(key): Promise<MemberGroupStatus> {
83
+ const node = await nodes.byKey(key)
84
+ if (!node || node.objectType !== TYPE) return 'not-found'
85
+ await db.exec('DELETE FROM member_group_member WHERE member_group_id = ?', [node.id])
86
+ await nodes.delete(node.id)
87
+ return 'ok'
88
+ },
89
+ }
90
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * The Members section.
3
+ *
4
+ * A member is a content node with a sign-in facet, so the content half of a save
5
+ * is the same machinery documents use and the facet half is the `member` row.
6
+ * Three things are ours to enforce here rather than in the repository:
7
+ *
8
+ * - **Uniqueness.** Umbraco refuses a duplicate username or e-mail; the columns
9
+ * are case-insensitive in both dialects, so "A@b.com" and "a@b.com" clash.
10
+ * - **Sensitive values.** A member property marked sensitive is withheld from a
11
+ * user outside the Sensitive data group, compositions included.
12
+ * - **Passwords.** Plaintext arrives here and nowhere deeper; a change of one
13
+ * rotates the security stamp, which ends the member's sessions.
14
+ *
15
+ * `kind` is always `Default`: Umbraco reports `Api` only for a Delivery API
16
+ * client-credentials member and `ExternalOnly` only for one in its lightweight
17
+ * external-member table, and we have neither.
18
+ */
19
+ import type {
20
+ MemberFilterCriteria,
21
+ MemberItem,
22
+ MemberPort,
23
+ MemberResponse,
24
+ Principal,
25
+ SaveMember,
26
+ } from '@crombolo/api-management'
27
+ import { hashPassword, passwordConfigJson, verifyPassword } from '@crombolo/auth'
28
+ import {
29
+ type ContentTypeAggregate,
30
+ hasAccessToSensitiveData,
31
+ normaliseUuid,
32
+ type Page,
33
+ } from '@crombolo/core'
34
+ import {
35
+ ContentTypeRepository,
36
+ type Db,
37
+ type DocumentRepositoryOptions,
38
+ type MemberCredentials,
39
+ MemberRepository,
40
+ type ValueIntake,
41
+ WriteRejectedError,
42
+ } from '@crombolo/data'
43
+
44
+ export interface MemberPortOptions extends DocumentRepositoryOptions {
45
+ valueIntake?: ValueIntake
46
+ /** How many failed sign-ins lock an account out; Umbraco's default is 5. */
47
+ maxFailedPasswordAttempts?: number
48
+ }
49
+
50
+ const notFound = () => ({
51
+ ok: false as const,
52
+ reason: 'The member could not be found.',
53
+ status: 404,
54
+ })
55
+
56
+ export function createMemberPort(db: Db, options: MemberPortOptions = {}): MemberPort {
57
+ const members = new MemberRepository(db, options)
58
+ const memberTypes = new ContentTypeRepository(db, { kind: 'member' })
59
+
60
+ const userIdOf = async (principal: Principal): Promise<number | undefined> => {
61
+ const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
62
+ principal.id,
63
+ ])
64
+ return rows[0] ? Number(rows[0].id) : undefined
65
+ }
66
+
67
+ /** Sensitive aliases of a member type, following its compositions. */
68
+ const sensitiveAliases = async (typeKey: string): Promise<Set<string>> => {
69
+ const all = await memberTypes.all()
70
+ const byKey = new Map(all.map((type) => [normaliseUuid(type.key), type]))
71
+ const aliases = new Set<string>()
72
+ const walk = (type: ContentTypeAggregate | undefined, seen = new Set<string>()) => {
73
+ if (!type || seen.has(type.key)) return
74
+ seen.add(type.key)
75
+ for (const property of type.properties)
76
+ if (property.isSensitive === true) aliases.add(property.alias)
77
+ for (const composition of type.compositions)
78
+ walk(byKey.get(normaliseUuid(composition.contentTypeKey)), seen)
79
+ }
80
+ walk(byKey.get(normaliseUuid(typeKey)))
81
+ return aliases
82
+ }
83
+
84
+ const toResponse = async (
85
+ member: MemberCredentials,
86
+ viewer: Principal,
87
+ ): Promise<MemberResponse | undefined> => {
88
+ const content = await members.content.byKey(member.key)
89
+ if (!content) return undefined
90
+ const withheld = hasAccessToSensitiveData(viewer.groups)
91
+ ? new Set<string>()
92
+ : await sensitiveAliases(member.contentTypeKey)
93
+ return {
94
+ id: member.key,
95
+ email: member.email,
96
+ username: member.username,
97
+ memberType: {
98
+ id: member.contentTypeKey,
99
+ icon: member.contentTypeIcon ?? 'icon-user',
100
+ collection: content.contentTypeCollectionKey
101
+ ? { id: content.contentTypeCollectionKey }
102
+ : null,
103
+ },
104
+ isApproved: member.isApproved,
105
+ isLockedOut: member.isLockedOut,
106
+ // No second factor is offered yet, so no member has one enabled.
107
+ isTwoFactorEnabled: false,
108
+ failedPasswordAttempts: member.failedPasswordAttempts,
109
+ lastLoginDate: member.lastLoginDate?.toISOString() ?? null,
110
+ lastLockoutDate: member.lastLockoutDate?.toISOString() ?? null,
111
+ lastPasswordChangeDate: member.lastPasswordChangeDate?.toISOString() ?? null,
112
+ groups: await members.groupKeys(member.key),
113
+ kind: 'Default',
114
+ profileData: null,
115
+ flags: [],
116
+ values: content.values
117
+ .filter((value) => !withheld.has(value.alias))
118
+ .map((value) => ({
119
+ alias: value.alias,
120
+ culture: value.culture,
121
+ segment: value.segment,
122
+ value: value.value,
123
+ editorAlias: value.editorAlias ?? '',
124
+ })),
125
+ variants: content.variants.map((variant) => ({
126
+ culture: variant.culture,
127
+ segment: variant.segment,
128
+ name: variant.name,
129
+ createDate: variant.createDate.toISOString(),
130
+ updateDate: variant.updateDate.toISOString(),
131
+ })),
132
+ }
133
+ }
134
+
135
+ const toItem = (member: MemberCredentials): MemberItem => ({
136
+ id: member.key,
137
+ memberType: { id: member.contentTypeKey, icon: member.contentTypeIcon ?? 'icon-user' },
138
+ variants: [{ name: member.name, culture: null }],
139
+ kind: 'Default',
140
+ flags: [],
141
+ })
142
+
143
+ const contentOf = (input: SaveMember, contentTypeKey: string) => ({
144
+ key: input.key,
145
+ contentTypeKey,
146
+ componentKey: null,
147
+ parentKey: null,
148
+ values: input.values,
149
+ variants:
150
+ input.variants.length > 0
151
+ ? input.variants
152
+ : [{ culture: null, segment: null, name: input.username }],
153
+ })
154
+
155
+ /** Umbraco's duplicate checks, which the columns' case-insensitivity decides. */
156
+ const duplicate = async (input: SaveMember, exceptKey?: string) => {
157
+ if (await members.taken('username', input.username, exceptKey))
158
+ return {
159
+ ok: false as const,
160
+ reason: 'Another member already uses this username.',
161
+ status: 400,
162
+ }
163
+ if (await members.taken('email', input.email, exceptKey))
164
+ return {
165
+ ok: false as const,
166
+ reason: 'Another member already uses this e-mail address.',
167
+ status: 400,
168
+ }
169
+ return undefined
170
+ }
171
+
172
+ const failure = (error: unknown) => {
173
+ if (error instanceof WriteRejectedError)
174
+ return { ok: false as const, reason: error.message, status: 409 }
175
+ throw error
176
+ }
177
+
178
+ return {
179
+ async byKey(key, viewer) {
180
+ const member = await members.byKey(key)
181
+ return member ? toResponse(member, viewer) : undefined
182
+ },
183
+
184
+ validate: (input) => members.content.validate(contentOf(input, input.memberTypeKey), null),
185
+
186
+ async create(input, principal) {
187
+ const type = await memberTypes.byKey(input.memberTypeKey)
188
+ if (!type)
189
+ return { ok: false as const, reason: 'The member type could not be found.', status: 404 }
190
+ const clash = await duplicate(input)
191
+ if (clash) return clash
192
+ try {
193
+ const created = await members.create(
194
+ { ...contentOf(input, type.key), userId: await userIdOf(principal) },
195
+ {
196
+ email: input.email,
197
+ username: input.username,
198
+ isApproved: input.isApproved,
199
+ isLockedOut: input.isLockedOut,
200
+ passwordHash: input.password ? await hashPassword(input.password) : null,
201
+ passwordConfig: input.password ? passwordConfigJson() : null,
202
+ groupKeys: input.groupKeys ?? [],
203
+ },
204
+ )
205
+ return { ok: true as const, key: created.key }
206
+ } catch (error) {
207
+ return failure(error)
208
+ }
209
+ },
210
+
211
+ async update(key, input, principal) {
212
+ const existing = await members.byKey(key)
213
+ if (!existing) return notFound()
214
+ const clash = await duplicate(input, key)
215
+ if (clash) return clash
216
+ // Umbraco requires the current password only when the editor supplies one;
217
+ // an administrator changing another member's password does not.
218
+ if (input.oldPassword && !(await verifyPassword(input.oldPassword, existing.passwordHash)))
219
+ return { ok: false as const, reason: 'The current password is not correct.', status: 400 }
220
+ try {
221
+ const updated = await members.update(
222
+ key,
223
+ { ...contentOf(input, existing.contentTypeKey), userId: await userIdOf(principal) },
224
+ {
225
+ email: input.email,
226
+ username: input.username,
227
+ isApproved: input.isApproved,
228
+ isLockedOut: input.isLockedOut,
229
+ passwordHash: input.password ? await hashPassword(input.password) : undefined,
230
+ passwordConfig: input.password ? passwordConfigJson() : undefined,
231
+ groupKeys: input.groupKeys,
232
+ },
233
+ )
234
+ return updated ? { ok: true as const } : notFound()
235
+ } catch (error) {
236
+ return failure(error)
237
+ }
238
+ },
239
+
240
+ remove: (key) => members.delete(key),
241
+
242
+ async filter(criteria: MemberFilterCriteria, viewer): Promise<Page<MemberResponse>> {
243
+ const page = await members.filter(criteria)
244
+ const items: MemberResponse[] = []
245
+ for (const member of page.items) {
246
+ const response = await toResponse(member, viewer)
247
+ if (response) items.push(response)
248
+ }
249
+ return { total: page.total, items }
250
+ },
251
+
252
+ async items(keys) {
253
+ return (await members.items(keys)).map(toItem)
254
+ },
255
+
256
+ async search(query, paging) {
257
+ const page = await members.search(query, paging)
258
+ return { total: page.total, items: page.items.map(toItem) }
259
+ },
260
+ }
261
+ }