@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,898 @@
1
+ /**
2
+ * Backoffice users and user groups: accounts, membership, sections, languages,
3
+ * start nodes and permission verbs. Password hashing and the rules about who
4
+ * may change what live with the callers; this is storage.
5
+ */
6
+ import { type GroupGrant, normaliseUuid, type StartNodePath } from '@crombolo/core'
7
+ import type { Db } from '../database.ts'
8
+ import { DbDate, fromDbBool } from '../dialect.ts'
9
+ import { mapNode, type NodeRow } from './nodes.ts'
10
+
11
+ export type UserKind = 'Default' | 'Api'
12
+ export type StartNodeTree = 'document' | 'media' | 'element'
13
+
14
+ /** Umbraco's `UserStartNodeDto.StartNodeTypeValue`. */
15
+ const START_NODE_TYPE: Record<StartNodeTree, number> = { document: 1, media: 2, element: 3 }
16
+ const ROOT_ID = -1
17
+
18
+ /** A user's own start nodes in one tree: root access, and/or specific nodes by key. */
19
+ export interface StartNodeSelection {
20
+ root: boolean
21
+ keys: string[]
22
+ }
23
+
24
+ /** A group's start node in one tree: the root, one node, or none. */
25
+ export interface GroupStartNode {
26
+ root: boolean
27
+ key: string | null
28
+ }
29
+
30
+ export interface UserRecord {
31
+ id: number
32
+ key: string
33
+ name: string
34
+ userName: string
35
+ email: string
36
+ languageIsoCode: string | null
37
+ avatar: string | null
38
+ kind: UserKind
39
+ isApproved: boolean
40
+ isLockedOut: boolean
41
+ failedLoginAttempts: number
42
+ lastLoginDate: Date | undefined
43
+ lastLockoutDate: Date | undefined
44
+ lastPasswordChangeDate: Date | undefined
45
+ invitedDate: Date | undefined
46
+ createDate: Date
47
+ updateDate: Date
48
+ groupKeys: string[]
49
+ startNodes: Record<StartNodeTree, StartNodeSelection>
50
+ }
51
+
52
+ export type UserState = 'Active' | 'Disabled' | 'LockedOut' | 'Invited' | 'Inactive'
53
+
54
+ /** Umbraco's `User.UserState`. */
55
+ export function userState(user: UserRecord): UserState {
56
+ if (!user.lastLoginDate && !user.isApproved && user.invitedDate) return 'Invited'
57
+ if (user.isLockedOut) return 'LockedOut'
58
+ if (!user.isApproved) return 'Disabled'
59
+ if (!user.lastLoginDate) return 'Inactive'
60
+ return 'Active'
61
+ }
62
+
63
+ export interface GranularPermission {
64
+ /** The node (or property type) the verb applies to; null for a context-only verb. */
65
+ key: string | null
66
+ permission: string
67
+ context: string
68
+ }
69
+
70
+ export interface UserGroupRecord {
71
+ id: number
72
+ key: string
73
+ alias: string
74
+ name: string
75
+ description: string | null
76
+ icon: string | null
77
+ /** Stored application aliases (`content`), or a package section's alias as given. */
78
+ sections: string[]
79
+ languages: string[]
80
+ hasAccessToAllLanguages: boolean
81
+ startNodes: Record<StartNodeTree, GroupStartNode>
82
+ permissions: string[]
83
+ granular: GranularPermission[]
84
+ }
85
+
86
+ export interface UserGroupInput {
87
+ key: string
88
+ alias: string
89
+ name: string
90
+ description: string | null
91
+ icon: string | null
92
+ sections: string[]
93
+ languages: string[]
94
+ hasAccessToAllLanguages: boolean
95
+ startNodes: Record<StartNodeTree, GroupStartNode>
96
+ permissions: string[]
97
+ granular: GranularPermission[]
98
+ }
99
+
100
+ export interface UserInput {
101
+ key: string
102
+ name: string
103
+ userName: string
104
+ email: string
105
+ kind: UserKind
106
+ groupKeys: string[]
107
+ languageIsoCode: string | null
108
+ /** Invited users are created unapproved, with an invitation date. */
109
+ invited?: boolean
110
+ }
111
+
112
+ export interface UserUpdate {
113
+ name: string
114
+ userName: string
115
+ email: string
116
+ languageIsoCode: string | null
117
+ groupKeys: string[]
118
+ startNodes: Record<StartNodeTree, StartNodeSelection>
119
+ }
120
+
121
+ /** What access checks need about a user: their groups' grants and every start node's position. */
122
+ export interface UserAccessData {
123
+ groups: GroupGrant[]
124
+ languages: string[]
125
+ hasAccessToAllLanguages: boolean
126
+ groupStartNodes: Record<StartNodeTree, StartNodePath[]>
127
+ userStartNodes: Record<StartNodeTree, StartNodePath[]>
128
+ }
129
+
130
+ const USER_COLUMNS = `id, key, user_name, login, email, language, avatar, kind, is_approved,
131
+ is_locked_out, failed_login_attempts, last_login_date, last_lockout_date,
132
+ last_password_change_date, invited_date, create_date, update_date`
133
+
134
+ const TREES: StartNodeTree[] = ['document', 'media', 'element']
135
+
136
+ const inList = (values: readonly unknown[]) => values.map(() => '?').join(', ')
137
+
138
+ export class UserRepository {
139
+ #db: Db
140
+
141
+ constructor(db: Db) {
142
+ this.#db = db
143
+ }
144
+
145
+ async #nodeKeys(ids: readonly number[]): Promise<Map<number, NodeRow>> {
146
+ const wanted = [...new Set(ids.filter((id) => id > 0))]
147
+ if (wanted.length === 0) return new Map()
148
+ const rows = await this.#db.query(
149
+ `SELECT id, unique_id, parent_id, level, path, sort_order, trashed, text, node_object_type, create_date
150
+ FROM node WHERE id IN (${inList(wanted)})`,
151
+ wanted,
152
+ )
153
+ return new Map(rows.map((row) => [Number(row.id), mapNode(row)]))
154
+ }
155
+
156
+ /** Node ids for keys; undefined when any key is not a node. */
157
+ async nodeIds(keys: readonly string[]): Promise<Map<string, number> | undefined> {
158
+ if (keys.length === 0) return new Map()
159
+ const wanted = keys.map(normaliseUuid)
160
+ const rows = await this.#db.query<{ id: number; unique_id: string }>(
161
+ `SELECT id, unique_id FROM node WHERE unique_id IN (${inList(wanted)})`,
162
+ wanted,
163
+ )
164
+ const map = new Map(rows.map((r) => [normaliseUuid(String(r.unique_id)), Number(r.id)]))
165
+ return wanted.every((k) => map.has(k)) ? map : undefined
166
+ }
167
+
168
+ async #hydrate(rows: Record<string, unknown>[]): Promise<UserRecord[]> {
169
+ if (rows.length === 0) return []
170
+ const ids = rows.map((r) => Number(r.id))
171
+ const memberships = await this.#db.query<{ user_id: number; key: string }>(
172
+ `SELECT m.user_id, g.key FROM user_group_member m JOIN user_group g ON g.id = m.user_group_id
173
+ WHERE m.user_id IN (${inList(ids)}) ORDER BY g.id`,
174
+ ids,
175
+ )
176
+ const starts = await this.#db.query<{
177
+ user_id: number
178
+ start_node: number
179
+ start_node_type: number
180
+ }>(
181
+ `SELECT user_id, start_node, start_node_type FROM user_start_node WHERE user_id IN (${inList(ids)})`,
182
+ ids,
183
+ )
184
+ const nodes = await this.#nodeKeys(starts.map((s) => Number(s.start_node)))
185
+ return rows.map((row) => {
186
+ const id = Number(row.id)
187
+ const startNodes = Object.fromEntries(
188
+ TREES.map((tree) => {
189
+ const mine = starts.filter(
190
+ (s) => Number(s.user_id) === id && Number(s.start_node_type) === START_NODE_TYPE[tree],
191
+ )
192
+ return [
193
+ tree,
194
+ {
195
+ root: mine.some((s) => Number(s.start_node) === ROOT_ID),
196
+ keys: mine.flatMap((s) => {
197
+ const node = nodes.get(Number(s.start_node))
198
+ return node ? [node.key] : []
199
+ }),
200
+ },
201
+ ]
202
+ }),
203
+ ) as Record<StartNodeTree, StartNodeSelection>
204
+ return {
205
+ id,
206
+ key: normaliseUuid(String(row.key)),
207
+ name: String(row.user_name),
208
+ userName: String(row.login),
209
+ email: String(row.email),
210
+ languageIsoCode: (row.language as string | null) ?? null,
211
+ avatar: (row.avatar as string | null) ?? null,
212
+ kind: Number(row.kind) === 1 ? 'Api' : 'Default',
213
+ isApproved: fromDbBool(row.is_approved),
214
+ isLockedOut: fromDbBool(row.is_locked_out),
215
+ failedLoginAttempts: Number(row.failed_login_attempts ?? 0),
216
+ lastLoginDate: DbDate.fromDb(row.last_login_date),
217
+ lastLockoutDate: DbDate.fromDb(row.last_lockout_date),
218
+ lastPasswordChangeDate: DbDate.fromDb(row.last_password_change_date),
219
+ invitedDate: DbDate.fromDb(row.invited_date),
220
+ createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
221
+ updateDate: DbDate.fromDb(row.update_date) ?? new Date(0),
222
+ groupKeys: memberships
223
+ .filter((m) => Number(m.user_id) === id)
224
+ .map((m) => normaliseUuid(String(m.key))),
225
+ startNodes,
226
+ }
227
+ })
228
+ }
229
+
230
+ async all(): Promise<UserRecord[]> {
231
+ return this.#hydrate(
232
+ await this.#db.query(`SELECT ${USER_COLUMNS} FROM user_account ORDER BY id`),
233
+ )
234
+ }
235
+
236
+ async byKeys(keys: readonly string[]): Promise<UserRecord[]> {
237
+ const wanted = keys.filter((k) => /^[0-9a-f-]{32,36}$/i.test(k)).map(normaliseUuid)
238
+ if (wanted.length === 0) return []
239
+ return this.#hydrate(
240
+ await this.#db.query(
241
+ `SELECT ${USER_COLUMNS} FROM user_account WHERE key IN (${inList(wanted)}) ORDER BY id`,
242
+ wanted,
243
+ ),
244
+ )
245
+ }
246
+
247
+ async byKey(key: string): Promise<UserRecord | undefined> {
248
+ return (await this.byKeys([key]))[0]
249
+ }
250
+
251
+ async byId(id: number): Promise<UserRecord | undefined> {
252
+ return (
253
+ await this.#hydrate(
254
+ await this.#db.query(`SELECT ${USER_COLUMNS} FROM user_account WHERE id = ?`, [id]),
255
+ )
256
+ )[0]
257
+ }
258
+
259
+ async byUserName(userName: string): Promise<UserRecord | undefined> {
260
+ return (
261
+ await this.#hydrate(
262
+ await this.#db.query(`SELECT ${USER_COLUMNS} FROM user_account WHERE login = ?`, [
263
+ userName,
264
+ ]),
265
+ )
266
+ )[0]
267
+ }
268
+
269
+ async byEmail(email: string): Promise<UserRecord | undefined> {
270
+ return (
271
+ await this.#hydrate(
272
+ await this.#db.query(`SELECT ${USER_COLUMNS} FROM user_account WHERE email = ?`, [email]),
273
+ )
274
+ )[0]
275
+ }
276
+
277
+ async #groupIds(keys: readonly string[]): Promise<number[] | undefined> {
278
+ if (keys.length === 0) return []
279
+ const wanted = keys.map(normaliseUuid)
280
+ const rows = await this.#db.query<{ id: number }>(
281
+ `SELECT id FROM user_group WHERE key IN (${inList(wanted)})`,
282
+ wanted,
283
+ )
284
+ return rows.length === new Set(wanted).size ? rows.map((r) => Number(r.id)) : undefined
285
+ }
286
+
287
+ async #setGroups(userId: number, groupIds: readonly number[]): Promise<void> {
288
+ await this.#db.exec('DELETE FROM user_group_member WHERE user_id = ?', [userId])
289
+ for (const groupId of new Set(groupIds))
290
+ await this.#db.exec('INSERT INTO user_group_member (user_id, user_group_id) VALUES (?, ?)', [
291
+ userId,
292
+ groupId,
293
+ ])
294
+ }
295
+
296
+ /** Creates the user; undefined when a group does not exist. */
297
+ async create(input: UserInput): Promise<UserRecord | undefined> {
298
+ return this.#db.transaction(async (tx) => {
299
+ const repo = new UserRepository(tx)
300
+ const groupIds = await repo.#groupIds(input.groupKeys)
301
+ if (!groupIds) return undefined
302
+ const now = DbDate.toDb(new Date())
303
+ const bool = (v: boolean) => tx.dialect.boolValue(v)
304
+ await tx.exec(
305
+ `INSERT INTO user_account
306
+ (key, user_name, login, email, security_stamp, language, kind, is_locked_out, is_approved,
307
+ failed_login_attempts, invited_date, create_date, update_date)
308
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
309
+ [
310
+ normaliseUuid(input.key),
311
+ input.name,
312
+ input.userName,
313
+ input.email,
314
+ crypto.randomUUID(),
315
+ input.languageIsoCode,
316
+ input.kind === 'Api' ? 1 : 0,
317
+ bool(false),
318
+ bool(!input.invited),
319
+ 0,
320
+ input.invited ? now : null,
321
+ now,
322
+ now,
323
+ ],
324
+ )
325
+ const created = await repo.byKey(input.key)
326
+ if (!created) return undefined
327
+ await repo.#setGroups(created.id, groupIds)
328
+ return repo.byKey(input.key)
329
+ })
330
+ }
331
+
332
+ /** 'missing-group' or 'missing-node' when a reference does not resolve. */
333
+ async update(
334
+ key: string,
335
+ change: UserUpdate,
336
+ ): Promise<'updated' | 'not-found' | 'missing-group' | Exclude<StartNodeTree, never>> {
337
+ return this.#db.transaction(async (tx) => {
338
+ const repo = new UserRepository(tx)
339
+ const user = await repo.byKey(key)
340
+ if (!user) return 'not-found'
341
+ const groupIds = await repo.#groupIds(change.groupKeys)
342
+ if (!groupIds) return 'missing-group'
343
+ const nodeIds: Array<{ tree: StartNodeTree; id: number }> = []
344
+ for (const tree of TREES) {
345
+ const selection = change.startNodes[tree]
346
+ if (selection.root) nodeIds.push({ tree, id: ROOT_ID })
347
+ const ids = await repo.nodeIds(selection.keys)
348
+ if (!ids) return tree
349
+ for (const id of ids.values()) nodeIds.push({ tree, id })
350
+ }
351
+ await tx.exec(
352
+ 'UPDATE user_account SET user_name = ?, login = ?, email = ?, language = ?, update_date = ? WHERE id = ?',
353
+ [
354
+ change.name,
355
+ change.userName,
356
+ change.email,
357
+ change.languageIsoCode,
358
+ DbDate.toDb(new Date()),
359
+ user.id,
360
+ ],
361
+ )
362
+ await repo.#setGroups(user.id, groupIds)
363
+ await tx.exec('DELETE FROM user_start_node WHERE user_id = ?', [user.id])
364
+ for (const { tree, id } of nodeIds)
365
+ await tx.exec(
366
+ 'INSERT INTO user_start_node (user_id, start_node, start_node_type) VALUES (?, ?, ?)',
367
+ [user.id, id, START_NODE_TYPE[tree]],
368
+ )
369
+ return 'updated'
370
+ })
371
+ }
372
+
373
+ async setGroups(userKeys: readonly string[], groupKeys: readonly string[]): Promise<boolean> {
374
+ return this.#db.transaction(async (tx) => {
375
+ const repo = new UserRepository(tx)
376
+ const groupIds = await repo.#groupIds(groupKeys)
377
+ if (!groupIds) return false
378
+ for (const user of await repo.byKeys(userKeys)) await repo.#setGroups(user.id, groupIds)
379
+ return true
380
+ })
381
+ }
382
+
383
+ async addToGroup(groupKey: string, userKeys: readonly string[]): Promise<void> {
384
+ const [groupId] = (await this.#groupIds([groupKey])) ?? []
385
+ if (groupId === undefined) return
386
+ for (const user of await this.byKeys(userKeys))
387
+ if (!(await this.#groupIds(user.groupKeys))?.includes(groupId))
388
+ await this.#db.exec(
389
+ 'INSERT INTO user_group_member (user_id, user_group_id) VALUES (?, ?)',
390
+ [user.id, groupId],
391
+ )
392
+ }
393
+
394
+ async removeFromGroup(groupKey: string, userKeys: readonly string[]): Promise<void> {
395
+ const [groupId] = (await this.#groupIds([groupKey])) ?? []
396
+ if (groupId === undefined) return
397
+ for (const user of await this.byKeys(userKeys))
398
+ await this.#db.exec('DELETE FROM user_group_member WHERE user_id = ? AND user_group_id = ?', [
399
+ user.id,
400
+ groupId,
401
+ ])
402
+ }
403
+
404
+ async setApproved(keys: readonly string[], approved: boolean): Promise<void> {
405
+ for (const user of await this.byKeys(keys))
406
+ await this.#db.exec('UPDATE user_account SET is_approved = ?, update_date = ? WHERE id = ?', [
407
+ this.#db.dialect.boolValue(approved),
408
+ DbDate.toDb(new Date()),
409
+ user.id,
410
+ ])
411
+ }
412
+
413
+ async unlock(keys: readonly string[]): Promise<void> {
414
+ for (const user of await this.byKeys(keys))
415
+ await this.#db.exec(
416
+ 'UPDATE user_account SET is_locked_out = ?, failed_login_attempts = 0, update_date = ? WHERE id = ?',
417
+ [this.#db.dialect.boolValue(false), DbDate.toDb(new Date()), user.id],
418
+ )
419
+ }
420
+
421
+ async setLanguage(key: string, isoCode: string): Promise<void> {
422
+ await this.#db.exec('UPDATE user_account SET language = ?, update_date = ? WHERE key = ?', [
423
+ isoCode,
424
+ DbDate.toDb(new Date()),
425
+ normaliseUuid(key),
426
+ ])
427
+ }
428
+
429
+ async setAvatar(key: string, avatar: string | null): Promise<void> {
430
+ await this.#db.exec('UPDATE user_account SET avatar = ?, update_date = ? WHERE key = ?', [
431
+ avatar,
432
+ DbDate.toDb(new Date()),
433
+ normaliseUuid(key),
434
+ ])
435
+ }
436
+
437
+ async passwordHash(key: string): Promise<string | null> {
438
+ const rows = await this.#db.query<{ password_hash: string | null }>(
439
+ 'SELECT password_hash FROM user_account WHERE key = ?',
440
+ [normaliseUuid(key)],
441
+ )
442
+ return rows[0]?.password_hash ?? null
443
+ }
444
+
445
+ /** An accepted invitation: the user becomes approved and gets their password. */
446
+ async approve(key: string): Promise<void> {
447
+ await this.setApproved([key], true)
448
+ }
449
+
450
+ /** Deletes the user and everything that is only theirs. */
451
+ async delete(key: string): Promise<void> {
452
+ const user = await this.byKey(key)
453
+ if (!user) return
454
+ await this.#db.transaction(async (tx) => {
455
+ for (const table of [
456
+ 'user_group_member',
457
+ 'user_start_node',
458
+ 'user_data',
459
+ 'user_token',
460
+ 'user_client_credential',
461
+ 'user_notification',
462
+ 'auth_code',
463
+ 'auth_token',
464
+ 'user_login_session',
465
+ ])
466
+ await tx.exec(`DELETE FROM ${table} WHERE user_id = ?`, [user.id])
467
+ await tx.exec('DELETE FROM user_account WHERE id = ?', [user.id])
468
+ })
469
+ }
470
+
471
+ /** Stores a single-use token's hash for `purpose`, replacing any earlier one. */
472
+ async saveToken(
473
+ userId: number,
474
+ purpose: string,
475
+ tokenHash: string,
476
+ expiresAt: Date,
477
+ ): Promise<void> {
478
+ await this.#db.exec('DELETE FROM user_token WHERE user_id = ? AND purpose = ?', [
479
+ userId,
480
+ purpose,
481
+ ])
482
+ await this.#db.exec(
483
+ 'INSERT INTO user_token (token_hash, user_id, purpose, expires_at) VALUES (?, ?, ?, ?)',
484
+ [tokenHash, userId, purpose, DbDate.toDb(expiresAt)],
485
+ )
486
+ }
487
+
488
+ /** Whether the token is the user's live one for `purpose`; consuming removes it. */
489
+ async checkToken(
490
+ userId: number,
491
+ purpose: string,
492
+ tokenHash: string,
493
+ consume: boolean,
494
+ now = new Date(),
495
+ ): Promise<boolean> {
496
+ const rows = await this.#db.query<{ expires_at: unknown }>(
497
+ 'SELECT expires_at FROM user_token WHERE token_hash = ? AND user_id = ? AND purpose = ?',
498
+ [tokenHash, userId, purpose],
499
+ )
500
+ const expires = DbDate.fromDb(rows[0]?.expires_at)
501
+ if (!expires || expires.getTime() < now.getTime()) return false
502
+ if (consume) await this.#db.exec('DELETE FROM user_token WHERE token_hash = ?', [tokenHash])
503
+ return true
504
+ }
505
+
506
+ async clientCredentials(userId: number): Promise<string[]> {
507
+ const rows = await this.#db.query<{ client_id: string }>(
508
+ 'SELECT client_id FROM user_client_credential WHERE user_id = ? ORDER BY client_id',
509
+ [userId],
510
+ )
511
+ return rows.map((r) => String(r.client_id))
512
+ }
513
+
514
+ async clientCredentialOwner(
515
+ clientId: string,
516
+ ): Promise<{ userId: number; secretHash: string } | undefined> {
517
+ const rows = await this.#db.query<{ user_id: number; secret_hash: string }>(
518
+ 'SELECT user_id, secret_hash FROM user_client_credential WHERE client_id = ?',
519
+ [clientId],
520
+ )
521
+ return rows[0]
522
+ ? { userId: Number(rows[0].user_id), secretHash: String(rows[0].secret_hash) }
523
+ : undefined
524
+ }
525
+
526
+ async addClientCredential(
527
+ userId: number,
528
+ clientId: string,
529
+ secretHash: string,
530
+ ): Promise<boolean> {
531
+ if (await this.clientCredentialOwner(clientId)) return false
532
+ await this.#db.exec(
533
+ 'INSERT INTO user_client_credential (client_id, user_id, secret_hash, create_date) VALUES (?, ?, ?, ?)',
534
+ [clientId, userId, secretHash, DbDate.toDb(new Date())],
535
+ )
536
+ return true
537
+ }
538
+
539
+ async removeClientCredential(userId: number, clientId: string): Promise<void> {
540
+ await this.#db.exec('DELETE FROM user_client_credential WHERE user_id = ? AND client_id = ?', [
541
+ userId,
542
+ clientId,
543
+ ])
544
+ }
545
+
546
+ /** The user's groups' grants, languages and start node positions, for access checks. */
547
+ async access(userId: number): Promise<UserAccessData> {
548
+ const user = await this.byId(userId)
549
+ const groups = user ? await new UserGroupRepository(this.#db).byKeys(user.groupKeys) : []
550
+ const nodeIds: Record<string, number> = {}
551
+ for (const group of groups)
552
+ for (const tree of TREES) {
553
+ const key = group.startNodes[tree].key
554
+ if (key) nodeIds[key] = 0
555
+ }
556
+ for (const tree of TREES) for (const key of user?.startNodes[tree].keys ?? []) nodeIds[key] = 0
557
+ const keys = Object.keys(nodeIds)
558
+ const rows =
559
+ keys.length > 0
560
+ ? await this.#db.query(
561
+ `SELECT id, unique_id, parent_id, level, path, sort_order, trashed, text, node_object_type, create_date
562
+ FROM node WHERE unique_id IN (${inList(keys)})`,
563
+ keys,
564
+ )
565
+ : []
566
+ const byKey = new Map(rows.map((r) => [normaliseUuid(String(r.unique_id)), mapNode(r)]))
567
+ const chains = await this.#chains([...byKey.values()])
568
+ const pathOf = (key: string): StartNodePath | undefined => {
569
+ const node = byKey.get(key)
570
+ return node && !node.trashed ? chains.get(node.id) : undefined
571
+ }
572
+ const positions = (selection: { root: boolean; keys: readonly string[] }): StartNodePath[] => [
573
+ ...(selection.root ? [[] as StartNodePath] : []),
574
+ ...selection.keys.flatMap((key) => {
575
+ const path = pathOf(key)
576
+ return path ? [path] : []
577
+ }),
578
+ ]
579
+ const languageRows =
580
+ groups.length > 0
581
+ ? await this.#db.query<{ iso_code: string }>(
582
+ `SELECT DISTINCT l.iso_code FROM user_group_language gl
583
+ JOIN language l ON l.id = gl.language_id
584
+ WHERE gl.user_group_id IN (${inList(groups.map((g) => g.id))})`,
585
+ groups.map((g) => g.id),
586
+ )
587
+ : []
588
+ return {
589
+ groups: groups.map((group) => {
590
+ const granular = new Map<string, string[]>()
591
+ for (const row of group.granular)
592
+ if (row.context === 'Document' && row.key) {
593
+ const verbs = granular.get(row.key) ?? []
594
+ if (row.permission) verbs.push(row.permission)
595
+ granular.set(row.key, verbs)
596
+ }
597
+ return { key: group.key, alias: group.alias, defaults: group.permissions, granular }
598
+ }),
599
+ languages: languageRows.map((l) => String(l.iso_code)),
600
+ hasAccessToAllLanguages: groups.some((g) => g.hasAccessToAllLanguages),
601
+ groupStartNodes: Object.fromEntries(
602
+ TREES.map((tree) => [
603
+ tree,
604
+ groups.flatMap((g) =>
605
+ positions({
606
+ root: g.startNodes[tree].root,
607
+ keys: g.startNodes[tree].key ? [g.startNodes[tree].key as string] : [],
608
+ }),
609
+ ),
610
+ ]),
611
+ ) as Record<StartNodeTree, StartNodePath[]>,
612
+ userStartNodes: Object.fromEntries(
613
+ TREES.map((tree) => [tree, user ? positions(user.startNodes[tree]) : []]),
614
+ ) as Record<StartNodeTree, StartNodePath[]>,
615
+ }
616
+ }
617
+
618
+ /** Each node's ancestor keys then its own, root first. */
619
+ async #chains(nodes: readonly NodeRow[]): Promise<Map<number, string[]>> {
620
+ const ancestorIds = nodes.flatMap((n) =>
621
+ n.path
622
+ .split(',')
623
+ .map(Number)
624
+ .filter((id) => id > 0),
625
+ )
626
+ const all = await this.#nodeKeys(ancestorIds)
627
+ return new Map(
628
+ nodes.map((n) => [
629
+ n.id,
630
+ n.path
631
+ .split(',')
632
+ .map(Number)
633
+ .filter((id) => id > 0)
634
+ .flatMap((id) => {
635
+ const node = all.get(id)
636
+ return node ? [node.key] : []
637
+ }),
638
+ ]),
639
+ )
640
+ }
641
+ }
642
+
643
+ const GROUP_COLUMNS = `id, key, alias, name, description, icon, has_access_to_all_languages,
644
+ start_content_id, start_media_id, start_element_id`
645
+
646
+ const GROUP_START_COLUMN: Record<StartNodeTree, string> = {
647
+ document: 'start_content_id',
648
+ media: 'start_media_id',
649
+ element: 'start_element_id',
650
+ }
651
+
652
+ export class UserGroupRepository {
653
+ #db: Db
654
+
655
+ constructor(db: Db) {
656
+ this.#db = db
657
+ }
658
+
659
+ async #hydrate(rows: Record<string, unknown>[]): Promise<UserGroupRecord[]> {
660
+ if (rows.length === 0) return []
661
+ const ids = rows.map((r) => Number(r.id))
662
+ const keys = rows.map((r) => normaliseUuid(String(r.key)))
663
+ const sections = await this.#db.query<{ user_group_id: number; app_alias: string }>(
664
+ `SELECT user_group_id, app_alias FROM user_group_section WHERE user_group_id IN (${inList(ids)})`,
665
+ ids,
666
+ )
667
+ const languages = await this.#db.query<{ user_group_id: number; iso_code: string }>(
668
+ `SELECT gl.user_group_id, l.iso_code FROM user_group_language gl
669
+ JOIN language l ON l.id = gl.language_id WHERE gl.user_group_id IN (${inList(ids)})
670
+ ORDER BY l.iso_code`,
671
+ ids,
672
+ )
673
+ const permissions = await this.#db.query<{ user_group_key: string; permission: string }>(
674
+ `SELECT user_group_key, permission FROM user_group_permission
675
+ WHERE user_group_key IN (${inList(keys)}) ORDER BY id`,
676
+ keys,
677
+ )
678
+ const granular = await this.#db.query<{
679
+ user_group_key: string
680
+ unique_id: string | null
681
+ permission: string
682
+ context: string
683
+ }>(
684
+ `SELECT user_group_key, unique_id, permission, context FROM user_group_granular_permission
685
+ WHERE user_group_key IN (${inList(keys)}) ORDER BY id`,
686
+ keys,
687
+ )
688
+ const startIds = rows.flatMap((r) =>
689
+ TREES.map((tree) => Number(r[GROUP_START_COLUMN[tree]] ?? 0)),
690
+ )
691
+ const nodes = new Map<number, string>()
692
+ const positive = [...new Set(startIds.filter((id) => id > 0))]
693
+ if (positive.length > 0)
694
+ for (const row of await this.#db.query<{ id: number; unique_id: string }>(
695
+ `SELECT id, unique_id FROM node WHERE id IN (${inList(positive)})`,
696
+ positive,
697
+ ))
698
+ nodes.set(Number(row.id), normaliseUuid(String(row.unique_id)))
699
+
700
+ return rows.map((row) => {
701
+ const id = Number(row.id)
702
+ const key = normaliseUuid(String(row.key))
703
+ const start = (tree: StartNodeTree): GroupStartNode => {
704
+ const value = row[GROUP_START_COLUMN[tree]]
705
+ if (value === null || value === undefined) return { root: false, key: null }
706
+ const nodeId = Number(value)
707
+ if (nodeId === ROOT_ID) return { root: true, key: null }
708
+ return { root: false, key: nodes.get(nodeId) ?? null }
709
+ }
710
+ return {
711
+ id,
712
+ key,
713
+ alias: String(row.alias),
714
+ name: String(row.name),
715
+ description: (row.description as string | null) ?? null,
716
+ icon: (row.icon as string | null) ?? null,
717
+ sections: sections
718
+ .filter((s) => Number(s.user_group_id) === id)
719
+ .map((s) => String(s.app_alias)),
720
+ languages: languages
721
+ .filter((l) => Number(l.user_group_id) === id)
722
+ .map((l) => String(l.iso_code)),
723
+ hasAccessToAllLanguages: fromDbBool(row.has_access_to_all_languages),
724
+ startNodes: {
725
+ document: start('document'),
726
+ media: start('media'),
727
+ element: start('element'),
728
+ },
729
+ permissions: permissions
730
+ .filter((p) => normaliseUuid(String(p.user_group_key)) === key)
731
+ .map((p) => String(p.permission)),
732
+ granular: granular
733
+ .filter((g) => normaliseUuid(String(g.user_group_key)) === key)
734
+ .map((g) => ({
735
+ key: g.unique_id ? normaliseUuid(String(g.unique_id)) : null,
736
+ permission: String(g.permission),
737
+ context: String(g.context),
738
+ })),
739
+ }
740
+ })
741
+ }
742
+
743
+ async all(): Promise<UserGroupRecord[]> {
744
+ return this.#hydrate(
745
+ await this.#db.query(`SELECT ${GROUP_COLUMNS} FROM user_group ORDER BY name`),
746
+ )
747
+ }
748
+
749
+ async byKeys(keys: readonly string[]): Promise<UserGroupRecord[]> {
750
+ const wanted = keys.filter((k) => /^[0-9a-f-]{32,36}$/i.test(k)).map(normaliseUuid)
751
+ if (wanted.length === 0) return []
752
+ return this.#hydrate(
753
+ await this.#db.query(
754
+ `SELECT ${GROUP_COLUMNS} FROM user_group WHERE key IN (${inList(wanted)}) ORDER BY name`,
755
+ wanted,
756
+ ),
757
+ )
758
+ }
759
+
760
+ async byKey(key: string): Promise<UserGroupRecord | undefined> {
761
+ return (await this.byKeys([key]))[0]
762
+ }
763
+
764
+ async byAlias(alias: string): Promise<UserGroupRecord | undefined> {
765
+ return (
766
+ await this.#hydrate(
767
+ await this.#db.query(`SELECT ${GROUP_COLUMNS} FROM user_group WHERE alias = ?`, [alias]),
768
+ )
769
+ )[0]
770
+ }
771
+
772
+ /** How many users each group has, by group key. */
773
+ async memberCounts(): Promise<Map<string, number>> {
774
+ const rows = await this.#db.query<{ key: string; n: number }>(
775
+ `SELECT g.key, COUNT(m.user_id) AS n FROM user_group g
776
+ LEFT JOIN user_group_member m ON m.user_group_id = g.id GROUP BY g.key`,
777
+ )
778
+ return new Map(rows.map((r) => [normaliseUuid(String(r.key)), Number(r.n)]))
779
+ }
780
+
781
+ /**
782
+ * Creates or replaces the group. 'missing-node' names the tree whose start
783
+ * node does not exist; 'missing-language' a language that does not.
784
+ */
785
+ async save(
786
+ input: UserGroupInput,
787
+ ): Promise<'saved' | 'missing-language' | { missingNode: StartNodeTree }> {
788
+ return this.#db.transaction(async (tx) => {
789
+ const users = new UserRepository(tx)
790
+ const starts: Record<StartNodeTree, number | null> = {
791
+ document: null,
792
+ media: null,
793
+ element: null,
794
+ }
795
+ for (const tree of TREES) {
796
+ const start = input.startNodes[tree]
797
+ if (start.root) starts[tree] = ROOT_ID
798
+ else if (start.key) {
799
+ const ids = await users.nodeIds([start.key])
800
+ if (!ids) return { missingNode: tree }
801
+ starts[tree] = ids.get(normaliseUuid(start.key)) ?? null
802
+ }
803
+ }
804
+ const languageIds: number[] = []
805
+ if (input.languages.length > 0) {
806
+ const rows = await tx.query<{ id: number; iso_code: string }>(
807
+ `SELECT id, iso_code FROM language WHERE iso_code IN (${inList(input.languages)})`,
808
+ input.languages,
809
+ )
810
+ if (rows.length !== new Set(input.languages).size) return 'missing-language'
811
+ languageIds.push(...rows.map((r) => Number(r.id)))
812
+ }
813
+ const key = normaliseUuid(input.key)
814
+ const now = DbDate.toDb(new Date())
815
+ const bool = (v: boolean) => tx.dialect.boolValue(v)
816
+ const existing = await new UserGroupRepository(tx).byKey(key)
817
+ if (existing)
818
+ await tx.exec(
819
+ `UPDATE user_group SET alias = ?, name = ?, description = ?, icon = ?, has_access_to_all_languages = ?,
820
+ start_content_id = ?, start_media_id = ?, start_element_id = ?, update_date = ? WHERE id = ?`,
821
+ [
822
+ input.alias,
823
+ input.name,
824
+ input.description,
825
+ input.icon,
826
+ bool(input.hasAccessToAllLanguages),
827
+ starts.document,
828
+ starts.media,
829
+ starts.element,
830
+ now,
831
+ existing.id,
832
+ ],
833
+ )
834
+ else
835
+ await tx.exec(
836
+ `INSERT INTO user_group (key, alias, name, description, icon, has_access_to_all_languages,
837
+ start_content_id, start_media_id, start_element_id, create_date, update_date)
838
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
839
+ [
840
+ key,
841
+ input.alias,
842
+ input.name,
843
+ input.description,
844
+ input.icon,
845
+ bool(input.hasAccessToAllLanguages),
846
+ starts.document,
847
+ starts.media,
848
+ starts.element,
849
+ now,
850
+ now,
851
+ ],
852
+ )
853
+ const id = Number(
854
+ (await tx.query<{ id: number }>('SELECT id FROM user_group WHERE key = ?', [key]))[0]?.id,
855
+ )
856
+ await tx.exec('DELETE FROM user_group_section WHERE user_group_id = ?', [id])
857
+ for (const section of new Set(input.sections))
858
+ await tx.exec('INSERT INTO user_group_section (user_group_id, app_alias) VALUES (?, ?)', [
859
+ id,
860
+ section,
861
+ ])
862
+ await tx.exec('DELETE FROM user_group_language WHERE user_group_id = ?', [id])
863
+ for (const languageId of languageIds)
864
+ await tx.exec(
865
+ 'INSERT INTO user_group_language (user_group_id, language_id) VALUES (?, ?)',
866
+ [id, languageId],
867
+ )
868
+ await tx.exec('DELETE FROM user_group_permission WHERE user_group_key = ?', [key])
869
+ for (const permission of new Set(input.permissions))
870
+ await tx.exec(
871
+ 'INSERT INTO user_group_permission (user_group_key, permission) VALUES (?, ?)',
872
+ [key, permission],
873
+ )
874
+ await tx.exec('DELETE FROM user_group_granular_permission WHERE user_group_key = ?', [key])
875
+ for (const row of input.granular)
876
+ await tx.exec(
877
+ 'INSERT INTO user_group_granular_permission (user_group_key, unique_id, permission, context) VALUES (?, ?, ?, ?)',
878
+ [key, row.key ? normaliseUuid(row.key) : null, row.permission, row.context],
879
+ )
880
+ return 'saved'
881
+ })
882
+ }
883
+
884
+ async delete(key: string): Promise<void> {
885
+ const group = await this.byKey(key)
886
+ if (!group) return
887
+ await this.#db.transaction(async (tx) => {
888
+ await tx.exec('DELETE FROM user_group_member WHERE user_group_id = ?', [group.id])
889
+ await tx.exec('DELETE FROM user_group_section WHERE user_group_id = ?', [group.id])
890
+ await tx.exec('DELETE FROM user_group_language WHERE user_group_id = ?', [group.id])
891
+ await tx.exec('DELETE FROM user_group_permission WHERE user_group_key = ?', [group.key])
892
+ await tx.exec('DELETE FROM user_group_granular_permission WHERE user_group_key = ?', [
893
+ group.key,
894
+ ])
895
+ await tx.exec('DELETE FROM user_group WHERE id = ?', [group.id])
896
+ })
897
+ }
898
+ }