@crombolo/data 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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +31 -4
  4. package/src/change-report.ts +166 -0
  5. package/src/cluster.ts +108 -0
  6. package/src/database.ts +121 -0
  7. package/src/dialect.ts +128 -0
  8. package/src/index.ts +60 -0
  9. package/src/locks.ts +103 -0
  10. package/src/maintenance.ts +51 -0
  11. package/src/migrations.ts +431 -0
  12. package/src/node-ref.ts +107 -0
  13. package/src/pending.ts +25 -0
  14. package/src/repositories/bundles.ts +191 -0
  15. package/src/repositories/content-types.ts +831 -0
  16. package/src/repositories/data-types.ts +222 -0
  17. package/src/repositories/dictionary.ts +259 -0
  18. package/src/repositories/documents.ts +2389 -0
  19. package/src/repositories/domains.ts +108 -0
  20. package/src/repositories/folders.ts +120 -0
  21. package/src/repositories/form-entries.ts +314 -0
  22. package/src/repositories/form-workflows.ts +213 -0
  23. package/src/repositories/languages.ts +108 -0
  24. package/src/repositories/log-searches.ts +33 -0
  25. package/src/repositories/members.ts +427 -0
  26. package/src/repositories/nodes.ts +361 -0
  27. package/src/repositories/notifications.ts +54 -0
  28. package/src/repositories/public-access.ts +187 -0
  29. package/src/repositories/redirects.ts +274 -0
  30. package/src/repositories/references.ts +162 -0
  31. package/src/repositories/tags.ts +93 -0
  32. package/src/repositories/templates.ts +114 -0
  33. package/src/repositories/transfer-runs.ts +190 -0
  34. package/src/repositories/user-data.ts +90 -0
  35. package/src/repositories/users.ts +898 -0
  36. package/src/schema/assistant.ts +74 -0
  37. package/src/schema/built-in-content-types.ts +106 -0
  38. package/src/schema/built-in-media-types.ts +335 -0
  39. package/src/schema/built-in-member-types.ts +61 -0
  40. package/src/schema/bundle-components.ts +49 -0
  41. package/src/schema/bundles-rename.ts +32 -0
  42. package/src/schema/change-report.ts +46 -0
  43. package/src/schema/client-id-prefix.ts +43 -0
  44. package/src/schema/content-editing.ts +50 -0
  45. package/src/schema/content-seed.ts +586 -0
  46. package/src/schema/content.ts +263 -0
  47. package/src/schema/created-packages.ts +56 -0
  48. package/src/schema/elements.ts +62 -0
  49. package/src/schema/form-permission-prefix.ts +50 -0
  50. package/src/schema/form-permissions.ts +26 -0
  51. package/src/schema/form-workflows.ts +54 -0
  52. package/src/schema/forms.ts +63 -0
  53. package/src/schema/group-permissions.ts +20 -0
  54. package/src/schema/identity.ts +176 -0
  55. package/src/schema/log-viewer.ts +68 -0
  56. package/src/schema/member-property-type.ts +30 -0
  57. package/src/schema/members.ts +97 -0
  58. package/src/schema/plan.ts +73 -0
  59. package/src/schema/public-access.ts +56 -0
  60. package/src/schema/recycle-bin.ts +21 -0
  61. package/src/schema/redirects.ts +57 -0
  62. package/src/schema/rich-text-ui.ts +34 -0
  63. package/src/schema/seed.ts +298 -0
  64. package/src/schema/server-role.ts +21 -0
  65. package/src/schema/since-version.ts +26 -0
  66. package/src/schema/transfer-runs.ts +71 -0
  67. package/src/schema/upgrade-report.ts +43 -0
  68. package/src/schema/users-localisation.ts +85 -0
  69. package/src/schema/values.ts +108 -0
  70. package/src/schema-ops.ts +35 -0
  71. package/src/schema-state.ts +196 -0
  72. package/README.md +0 -3
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Members: content nodes with a sign-in facet.
3
+ *
4
+ * A member is stored exactly as a document is — `node` + `content` +
5
+ * `content_version` + values — so member types, properties and versioning are
6
+ * machinery that already exists. What makes it a member is the `member` row
7
+ * beside it: e-mail, username, password hash, approval and lockout. Group
8
+ * membership is `member_group_member`, and the rules that protect a page name a
9
+ * group by its **name**, so `roles` reads names rather than keys.
10
+ *
11
+ * Passwords arrive already hashed. Hashing belongs to `@crombolo/auth`, and
12
+ * `data` depends on nothing but types.
13
+ */
14
+ import { normaliseUuid, ObjectTypes } from '@crombolo/core'
15
+ import type { Db } from '../database.ts'
16
+ import { DbDate, fromDbBool } from '../dialect.ts'
17
+ import {
18
+ DocumentRepository,
19
+ type DocumentRepositoryOptions,
20
+ type SaveDocumentInput,
21
+ } from './documents.ts'
22
+ import { NodeRepository } from './nodes.ts'
23
+
24
+ /** The sign-in facet beside a member's content. */
25
+ export interface MemberIdentity {
26
+ email: string
27
+ username: string
28
+ isApproved: boolean
29
+ isLockedOut: boolean
30
+ failedPasswordAttempts: number
31
+ lastLoginDate: Date | undefined
32
+ lastLockoutDate: Date | undefined
33
+ lastPasswordChangeDate: Date | undefined
34
+ emailConfirmedDate: Date | undefined
35
+ /** Rotated whenever a credential changes; a session carrying a stale one is over. */
36
+ securityStamp: string | null
37
+ }
38
+
39
+ export interface MemberRecord extends MemberIdentity {
40
+ key: string
41
+ name: string
42
+ /** Member group names, which is the currency public-access rules deal in. */
43
+ roles: string[]
44
+ contentTypeKey: string
45
+ contentTypeAlias: string
46
+ contentTypeIcon: string | null
47
+ }
48
+
49
+ export interface MemberCredentials extends MemberRecord {
50
+ passwordHash: string | null
51
+ passwordConfig: string | null
52
+ }
53
+
54
+ /** What a create or an update writes to the facet; omitted fields are left alone. */
55
+ export interface MemberIdentityInput {
56
+ email: string
57
+ username: string
58
+ isApproved: boolean
59
+ isLockedOut: boolean
60
+ passwordHash?: string | null
61
+ passwordConfig?: string | null
62
+ /** Group **keys**; the membership table stores node ids. */
63
+ groupKeys?: readonly string[]
64
+ }
65
+
66
+ export interface MemberFilter {
67
+ memberTypeKey?: string | null
68
+ memberGroupName?: string | null
69
+ isApproved?: boolean
70
+ isLockedOut?: boolean
71
+ /** Matched against e-mail, username and name. */
72
+ filter?: string
73
+ orderBy?: string
74
+ orderDirection?: 'Ascending' | 'Descending'
75
+ skip: number
76
+ take: number
77
+ }
78
+
79
+ /** Umbraco's `MemberFilterRepository` order columns; anything else is username. */
80
+ const ORDER_COLUMNS: Record<string, string> = {
81
+ email: 'm.email',
82
+ name: 'n.text',
83
+ username: 'm.login_name',
84
+ isapproved: 'm.is_approved',
85
+ islockedout: 'm.is_locked_out',
86
+ lastlogindate: 'm.last_login_date',
87
+ }
88
+
89
+ interface MemberRow {
90
+ unique_id: string
91
+ text: string | null
92
+ email: string
93
+ login_name: string
94
+ password: string | null
95
+ password_config: string | null
96
+ security_stamp_token: string | null
97
+ email_confirmed_date: unknown
98
+ failed_password_attempts: number
99
+ is_locked_out: unknown
100
+ is_approved: unknown
101
+ last_login_date: unknown
102
+ last_lockout_date: unknown
103
+ last_password_change_date: unknown
104
+ type_key: string
105
+ type_alias: string
106
+ type_icon: string | null
107
+ }
108
+
109
+ const SELECT = `SELECT n.unique_id, n.text, m.*,
110
+ tn.unique_id AS type_key, ct.alias AS type_alias, ct.icon AS type_icon
111
+ FROM member m
112
+ JOIN node n ON n.id = m.node_id
113
+ JOIN content c ON c.node_id = m.node_id
114
+ JOIN content_type ct ON ct.node_id = c.content_type_id
115
+ JOIN node tn ON tn.id = ct.node_id`
116
+
117
+ export class MemberRepository {
118
+ #db: Db
119
+ #nodes: NodeRepository
120
+ readonly content: DocumentRepository
121
+
122
+ constructor(db: Db, options: DocumentRepositoryOptions = {}) {
123
+ this.#db = db
124
+ this.#nodes = new NodeRepository(db)
125
+ this.content = new DocumentRepository(db, { ...options, kind: 'member' })
126
+ }
127
+
128
+ async #rolesOf(nodeId: number): Promise<string[]> {
129
+ const rows = await this.#db.query<{ text: string | null }>(
130
+ `SELECT g.text FROM member_group_member mg JOIN node g ON g.id = mg.member_group_id
131
+ WHERE mg.member_id = ? ORDER BY g.text`,
132
+ [nodeId],
133
+ )
134
+ return rows.map((row) => String(row.text ?? '')).filter(Boolean)
135
+ }
136
+
137
+ async #groupKeysOf(nodeId: number): Promise<string[]> {
138
+ const rows = await this.#db.query<{ unique_id: string }>(
139
+ `SELECT g.unique_id FROM member_group_member mg JOIN node g ON g.id = mg.member_group_id
140
+ WHERE mg.member_id = ? ORDER BY g.text`,
141
+ [nodeId],
142
+ )
143
+ return rows.map((row) => normaliseUuid(String(row.unique_id)))
144
+ }
145
+
146
+ async #hydrate(row: MemberRow): Promise<MemberCredentials> {
147
+ const node = await this.#nodes.byKey(normaliseUuid(String(row.unique_id)))
148
+ return {
149
+ key: normaliseUuid(String(row.unique_id)),
150
+ name: row.text ?? '',
151
+ email: row.email,
152
+ username: row.login_name,
153
+ passwordHash: row.password,
154
+ passwordConfig: row.password_config,
155
+ securityStamp: row.security_stamp_token,
156
+ isApproved: fromDbBool(row.is_approved),
157
+ isLockedOut: fromDbBool(row.is_locked_out),
158
+ failedPasswordAttempts: Number(row.failed_password_attempts ?? 0),
159
+ lastLoginDate: DbDate.fromDb(row.last_login_date),
160
+ lastLockoutDate: DbDate.fromDb(row.last_lockout_date),
161
+ lastPasswordChangeDate: DbDate.fromDb(row.last_password_change_date),
162
+ emailConfirmedDate: DbDate.fromDb(row.email_confirmed_date),
163
+ roles: node ? await this.#rolesOf(node.id) : [],
164
+ contentTypeKey: normaliseUuid(String(row.type_key)),
165
+ contentTypeAlias: String(row.type_alias),
166
+ contentTypeIcon: row.type_icon,
167
+ }
168
+ }
169
+
170
+ async #one(where: string, parameters: unknown[]): Promise<MemberCredentials | undefined> {
171
+ const rows = await this.#db.query<MemberRow>(`${SELECT} WHERE ${where}`, parameters)
172
+ return rows[0] ? this.#hydrate(rows[0]) : undefined
173
+ }
174
+
175
+ byKey(key: string): Promise<MemberCredentials | undefined> {
176
+ return this.#one('n.unique_id = ?', [normaliseUuid(key)])
177
+ }
178
+
179
+ byUsername(username: string): Promise<MemberCredentials | undefined> {
180
+ return this.#one('m.login_name = ?', [username])
181
+ }
182
+
183
+ byEmail(email: string): Promise<MemberCredentials | undefined> {
184
+ return this.#one('m.email = ?', [email])
185
+ }
186
+
187
+ /** The group keys a member belongs to, for the editor's Groups picker. */
188
+ async groupKeys(key: string): Promise<string[]> {
189
+ const node = await this.#nodes.byKey(key)
190
+ return node ? this.#groupKeysOf(node.id) : []
191
+ }
192
+
193
+ /** Whether a username or an e-mail is taken by anyone other than `exceptKey`. */
194
+ async taken(field: 'username' | 'email', value: string, exceptKey?: string): Promise<boolean> {
195
+ const column = field === 'username' ? 'm.login_name' : 'm.email'
196
+ const rows = await this.#db.query<{ unique_id: string }>(
197
+ `SELECT n.unique_id FROM member m JOIN node n ON n.id = m.node_id WHERE ${column} = ?`,
198
+ [value],
199
+ )
200
+ return rows.some(
201
+ (row) => !exceptKey || normaliseUuid(String(row.unique_id)) !== normaliseUuid(exceptKey),
202
+ )
203
+ }
204
+
205
+ async #writeGroups(nodeId: number, groupKeys: readonly string[]): Promise<void> {
206
+ await this.#db.exec('DELETE FROM member_group_member WHERE member_id = ?', [nodeId])
207
+ for (const key of groupKeys) {
208
+ const group = await this.#nodes.byKey(key)
209
+ if (!group || group.objectType !== ObjectTypes.MemberGroup) continue
210
+ await this.#db.exec(
211
+ 'INSERT INTO member_group_member (member_id, member_group_id) VALUES (?, ?)',
212
+ [nodeId, group.id],
213
+ )
214
+ }
215
+ }
216
+
217
+ async create(
218
+ content: SaveDocumentInput,
219
+ identity: MemberIdentityInput,
220
+ ): Promise<MemberCredentials> {
221
+ // Members never nest: the parent is always the tree root.
222
+ const created = await this.content.create({ ...content, parentKey: null, componentKey: null })
223
+ const node = await this.#nodes.byKey(created.key)
224
+ if (!node) throw new Error(`the member node ${created.key} vanished while being created`)
225
+ const now = new Date()
226
+ await this.#db.exec(
227
+ `INSERT INTO member (node_id, email, login_name, password, password_config,
228
+ security_stamp_token, failed_password_attempts, is_locked_out,
229
+ is_approved, last_password_change_date)
230
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
231
+ [
232
+ node.id,
233
+ identity.email,
234
+ identity.username,
235
+ identity.passwordHash ?? null,
236
+ identity.passwordConfig ?? null,
237
+ crypto.randomUUID(),
238
+ 0,
239
+ this.#db.dialect.boolValue(identity.isLockedOut),
240
+ this.#db.dialect.boolValue(identity.isApproved),
241
+ identity.passwordHash ? DbDate.toDb(now) : null,
242
+ ],
243
+ )
244
+ await this.#writeGroups(node.id, identity.groupKeys ?? [])
245
+ const member = await this.byKey(created.key)
246
+ if (!member) throw new Error(`the member ${created.key} could not be read back`)
247
+ return member
248
+ }
249
+
250
+ /**
251
+ * Saves content and facet together. A new password rotates the security
252
+ * stamp, which ends every session the member had.
253
+ */
254
+ async update(
255
+ key: string,
256
+ content: SaveDocumentInput,
257
+ identity: MemberIdentityInput,
258
+ ): Promise<MemberCredentials | undefined> {
259
+ const node = await this.#nodes.byKey(key)
260
+ if (!node || node.objectType !== ObjectTypes.Member) return undefined
261
+ const updated = await this.content.update({ ...content, key, componentKey: null })
262
+ if (!updated) return undefined
263
+ const now = new Date()
264
+ const sets = ['email = ?', 'login_name = ?', 'is_approved = ?', 'is_locked_out = ?']
265
+ const values: unknown[] = [
266
+ identity.email,
267
+ identity.username,
268
+ this.#db.dialect.boolValue(identity.isApproved),
269
+ this.#db.dialect.boolValue(identity.isLockedOut),
270
+ ]
271
+ if (!identity.isLockedOut) {
272
+ sets.push('failed_password_attempts = ?')
273
+ values.push(0)
274
+ }
275
+ if (identity.passwordHash) {
276
+ sets.push(
277
+ 'password = ?',
278
+ 'password_config = ?',
279
+ 'last_password_change_date = ?',
280
+ 'security_stamp_token = ?',
281
+ )
282
+ values.push(
283
+ identity.passwordHash,
284
+ identity.passwordConfig ?? null,
285
+ DbDate.toDb(now),
286
+ crypto.randomUUID(),
287
+ )
288
+ }
289
+ await this.#db.exec(`UPDATE member SET ${sets.join(', ')} WHERE node_id = ?`, [
290
+ ...values,
291
+ node.id,
292
+ ])
293
+ if (identity.groupKeys) await this.#writeGroups(node.id, identity.groupKeys)
294
+ return this.byKey(key)
295
+ }
296
+
297
+ /** Records a successful sign-in, clearing whatever failures came before it. */
298
+ async recordLogin(key: string): Promise<void> {
299
+ const node = await this.#nodes.byKey(key)
300
+ if (!node) return
301
+ await this.#db.exec(
302
+ 'UPDATE member SET last_login_date = ?, failed_password_attempts = 0 WHERE node_id = ?',
303
+ [DbDate.toDb(new Date()), node.id],
304
+ )
305
+ }
306
+
307
+ /**
308
+ * Records a failed sign-in and locks the account out once `maxAttempts` is
309
+ * reached, as Umbraco's lockout does.
310
+ */
311
+ async recordFailedLogin(key: string, maxAttempts: number): Promise<{ lockedOut: boolean }> {
312
+ const node = await this.#nodes.byKey(key)
313
+ if (!node) return { lockedOut: false }
314
+ const rows = await this.#db.query<{ failed_password_attempts: number }>(
315
+ 'SELECT failed_password_attempts FROM member WHERE node_id = ?',
316
+ [node.id],
317
+ )
318
+ const attempts = Number(rows[0]?.failed_password_attempts ?? 0) + 1
319
+ const lockedOut = maxAttempts > 0 && attempts >= maxAttempts
320
+ await this.#db.exec(
321
+ `UPDATE member SET failed_password_attempts = ?, is_locked_out = ?${lockedOut ? ', last_lockout_date = ?' : ''}
322
+ WHERE node_id = ?`,
323
+ lockedOut
324
+ ? [attempts, this.#db.dialect.boolValue(true), DbDate.toDb(new Date()), node.id]
325
+ : [attempts, this.#db.dialect.boolValue(false), node.id],
326
+ )
327
+ return { lockedOut }
328
+ }
329
+
330
+ async delete(key: string): Promise<boolean> {
331
+ const node = await this.#nodes.byKey(key)
332
+ if (!node || node.objectType !== ObjectTypes.Member) return false
333
+ await this.#db.exec('DELETE FROM member_group_member WHERE member_id = ?', [node.id])
334
+ await this.#db.exec(
335
+ 'DELETE FROM external_login_token WHERE external_login_id IN (SELECT id FROM external_login WHERE user_or_member_key = ?)',
336
+ [node.key],
337
+ )
338
+ await this.#db.exec('DELETE FROM external_login WHERE user_or_member_key = ?', [node.key])
339
+ await this.#db.exec('DELETE FROM member WHERE node_id = ?', [node.id])
340
+ return this.content.delete(key)
341
+ }
342
+
343
+ /** The Members collection: Umbraco's member filter, with the same columns and defaults. */
344
+ async filter(criteria: MemberFilter): Promise<{ total: number; items: MemberCredentials[] }> {
345
+ const wheres: string[] = []
346
+ const values: unknown[] = []
347
+ if (criteria.memberTypeKey) {
348
+ wheres.push('tn.unique_id = ?')
349
+ values.push(normaliseUuid(criteria.memberTypeKey))
350
+ }
351
+ if (criteria.isApproved !== undefined) {
352
+ wheres.push('m.is_approved = ?')
353
+ values.push(this.#db.dialect.boolValue(criteria.isApproved))
354
+ }
355
+ if (criteria.isLockedOut !== undefined) {
356
+ wheres.push('m.is_locked_out = ?')
357
+ values.push(this.#db.dialect.boolValue(criteria.isLockedOut))
358
+ }
359
+ if (criteria.memberGroupName) {
360
+ wheres.push(
361
+ `EXISTS (SELECT 1 FROM member_group_member mg JOIN node g ON g.id = mg.member_group_id
362
+ WHERE mg.member_id = m.node_id AND g.text = ?)`,
363
+ )
364
+ values.push(criteria.memberGroupName)
365
+ }
366
+ const text = criteria.filter?.trim()
367
+ if (text) {
368
+ wheres.push('(LOWER(m.email) LIKE ? OR LOWER(m.login_name) LIKE ? OR LOWER(n.text) LIKE ?)')
369
+ const like = `%${text.toLowerCase()}%`
370
+ values.push(like, like, like)
371
+ }
372
+ const where = wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : ''
373
+ const order = ORDER_COLUMNS[(criteria.orderBy ?? '').toLowerCase()] ?? ORDER_COLUMNS.username
374
+ const direction = criteria.orderDirection === 'Descending' ? 'DESC' : 'ASC'
375
+
376
+ const counted = await this.#db.query<{ total: number }>(
377
+ `SELECT COUNT(*) AS total FROM member m
378
+ JOIN node n ON n.id = m.node_id
379
+ JOIN content c ON c.node_id = m.node_id
380
+ JOIN content_type ct ON ct.node_id = c.content_type_id
381
+ JOIN node tn ON tn.id = ct.node_id
382
+ ${where}`,
383
+ values,
384
+ )
385
+ const rows = await this.#db.query<MemberRow>(
386
+ `${SELECT} ${where} ORDER BY ${order} ${direction}, n.id LIMIT ? OFFSET ?`,
387
+ [...values, criteria.take, criteria.skip],
388
+ )
389
+ const items = []
390
+ for (const row of rows) items.push(await this.#hydrate(row))
391
+ return { total: Number(counted[0]?.total ?? 0), items }
392
+ }
393
+
394
+ /** The member picker's search: name, username or e-mail, within the types allowed. */
395
+ async search(
396
+ query: string,
397
+ options: { allowedMemberTypeKeys?: readonly string[]; skip: number; take: number },
398
+ ): Promise<{ total: number; items: MemberCredentials[] }> {
399
+ const allowed = options.allowedMemberTypeKeys ?? []
400
+ const page = await this.filter({
401
+ filter: query,
402
+ skip: 0,
403
+ take: Number.MAX_SAFE_INTEGER,
404
+ orderBy: 'name',
405
+ })
406
+ const items =
407
+ allowed.length === 0
408
+ ? page.items
409
+ : page.items.filter((member) =>
410
+ allowed.map(normaliseUuid).includes(normaliseUuid(member.contentTypeKey)),
411
+ )
412
+ return {
413
+ total: items.length,
414
+ items: items.slice(options.skip, options.skip + options.take),
415
+ }
416
+ }
417
+
418
+ /** Several members by key, in the order asked, for the item lookup. */
419
+ async items(keys: readonly string[]): Promise<MemberCredentials[]> {
420
+ const found = []
421
+ for (const key of keys) {
422
+ const member = await this.byKey(key)
423
+ if (member) found.push(member)
424
+ }
425
+ return found
426
+ }
427
+ }