@meith/db 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/LICENSE.md +165 -0
  2. package/migrations/0000_initial_schema.sql +538 -0
  3. package/migrations/0001_seed_usergroups.sql +111 -0
  4. package/migrations/0002_group_promotions.sql +25 -0
  5. package/migrations/0003_counter_maintenance.sql +34 -0
  6. package/migrations/0004_post_render_cache.sql +7 -0
  7. package/migrations/0005_reports.sql +51 -0
  8. package/migrations/0006_warnings.sql +91 -0
  9. package/migrations/0007_notifications.sql +44 -0
  10. package/migrations/0008_subscriptions.sql +48 -0
  11. package/migrations/0009_member_preferences.sql +13 -0
  12. package/migrations/0010_profile_fields.sql +53 -0
  13. package/migrations/0011_private_messages.sql +63 -0
  14. package/migrations/0012_user_relations.sql +18 -0
  15. package/migrations/0013_reputation.sql +45 -0
  16. package/migrations/0014_signatures.sql +16 -0
  17. package/migrations/0015_admin_sessions.sql +24 -0
  18. package/migrations/0016_attachments.sql +51 -0
  19. package/migrations/0017_avatars.sql +33 -0
  20. package/migrations/0018_mass_mail.sql +15 -0
  21. package/migrations/0019_word_filters.sql +8 -0
  22. package/migrations/0020_searches.sql +15 -0
  23. package/migrations/0021_presence_stats.sql +18 -0
  24. package/migrations/0022_api_tokens_webhooks.sql +58 -0
  25. package/migrations/0023_install_state.sql +6 -0
  26. package/migrations/0024_component_versions.sql +13 -0
  27. package/migrations/0025_import.sql +25 -0
  28. package/migrations/0026_polls.sql +40 -0
  29. package/migrations/0027_drafts.sql +13 -0
  30. package/migrations/0028_board_vocabulary.sql +32 -0
  31. package/migrations/0029_announcements.sql +16 -0
  32. package/migrations/0030_antispam.sql +18 -0
  33. package/migrations/0031_markdown.sql +24 -0
  34. package/migrations/0032_theme_enablement.sql +1 -0
  35. package/migrations/0033_group_identity.sql +5 -0
  36. package/migrations/0034_search_document_version.sql +4 -0
  37. package/migrations/0035_categories_hold_no_threads.sql +1 -0
  38. package/migrations/0036_expiring_group_grants.sql +6 -0
  39. package/migrations/0037_automatic_timezone.sql +3 -0
  40. package/migrations/0038_primary_group_grants.sql +3 -0
  41. package/migrations/0039_moderator_log_forum_scope.sql +1 -0
  42. package/migrations/0040_moderator_restore_grants.sql +1 -0
  43. package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
  44. package/migrations/0042_drop_placebo_delete_others.sql +2 -0
  45. package/migrations/0043_federated_identity.sql +28 -0
  46. package/migrations/0044_second_factor.sql +34 -0
  47. package/migrations/0045_drop_dead_task_due_index.sql +1 -0
  48. package/migrations/0046_member_locale.sql +1 -0
  49. package/migrations/0047_navigation_items.sql +34 -0
  50. package/migrations/0048_web_push.sql +16 -0
  51. package/migrations/0049_plugin_health.sql +7 -0
  52. package/migrations/0050_drop_forum_password_grants.sql +1 -0
  53. package/migrations/0051_glossy_barracuda.sql +1 -0
  54. package/migrations/0052_many_professor_monster.sql +2 -0
  55. package/migrations/0053_strange_maximus.sql +14 -0
  56. package/migrations/0054_legacy_import_sources.sql +25 -0
  57. package/migrations/0055_poll_parity.sql +14 -0
  58. package/migrations/0056_marketplace_catalog.sql +11 -0
  59. package/migrations/meta/0000_snapshot.json +4412 -0
  60. package/migrations/meta/0006_snapshot.json +5411 -0
  61. package/migrations/meta/0007_snapshot.json +5634 -0
  62. package/migrations/meta/0008_snapshot.json +5779 -0
  63. package/migrations/meta/0009_snapshot.json +5816 -0
  64. package/migrations/meta/0010_snapshot.json +6117 -0
  65. package/migrations/meta/0011_snapshot.json +6406 -0
  66. package/migrations/meta/0012_snapshot.json +6517 -0
  67. package/migrations/meta/0013_snapshot.json +6722 -0
  68. package/migrations/meta/0014_snapshot.json +6777 -0
  69. package/migrations/meta/0015_snapshot.json +6914 -0
  70. package/migrations/meta/0016_snapshot.json +7192 -0
  71. package/migrations/meta/0017_snapshot.json +7248 -0
  72. package/migrations/meta/0018_snapshot.json +7355 -0
  73. package/migrations/meta/0019_snapshot.json +7419 -0
  74. package/migrations/meta/0020_snapshot.json +7562 -0
  75. package/migrations/meta/0021_snapshot.json +7653 -0
  76. package/migrations/meta/0031_snapshot.json +8162 -0
  77. package/migrations/meta/0032_snapshot.json +8169 -0
  78. package/migrations/meta/0033_snapshot.json +8193 -0
  79. package/migrations/meta/0034_snapshot.json +8221 -0
  80. package/migrations/meta/0036_snapshot.json +8262 -0
  81. package/migrations/meta/0037_snapshot.json +8262 -0
  82. package/migrations/meta/0038_snapshot.json +8293 -0
  83. package/migrations/meta/0039_snapshot.json +8308 -0
  84. package/migrations/meta/0041_snapshot.json +8287 -0
  85. package/migrations/meta/0042_snapshot.json +8274 -0
  86. package/migrations/meta/0043_snapshot.json +8521 -0
  87. package/migrations/meta/0044_snapshot.json +8814 -0
  88. package/migrations/meta/0045_snapshot.json +8797 -0
  89. package/migrations/meta/0046_snapshot.json +8804 -0
  90. package/migrations/meta/0047_snapshot.json +9018 -0
  91. package/migrations/meta/0048_snapshot.json +9145 -0
  92. package/migrations/meta/0049_snapshot.json +9190 -0
  93. package/migrations/meta/0050_snapshot.json +9126 -0
  94. package/migrations/meta/0051_snapshot.json +9132 -0
  95. package/migrations/meta/0052_snapshot.json +9148 -0
  96. package/migrations/meta/0053_snapshot.json +9268 -0
  97. package/migrations/meta/0054_snapshot.json +9378 -0
  98. package/migrations/meta/0056_snapshot.json +9447 -0
  99. package/migrations/meta/_journal.json +405 -0
  100. package/package.json +64 -0
  101. package/src/account-repos.ts +580 -0
  102. package/src/actor-builder.ts +130 -0
  103. package/src/admin-repo.ts +85 -0
  104. package/src/admin-session-repo.ts +205 -0
  105. package/src/admin-undo-repo.ts +128 -0
  106. package/src/announcement-repo.ts +176 -0
  107. package/src/antispam-repo.ts +145 -0
  108. package/src/api-repo.ts +276 -0
  109. package/src/attachment-admin-repo.ts +161 -0
  110. package/src/attachment-repo.ts +240 -0
  111. package/src/authorization-source.ts +138 -0
  112. package/src/avatar-repo.ts +185 -0
  113. package/src/ban-repos.ts +146 -0
  114. package/src/client.ts +85 -0
  115. package/src/content-admin-repo.ts +298 -0
  116. package/src/content-counters.ts +152 -0
  117. package/src/counter-recount.ts +263 -0
  118. package/src/cursor.ts +16 -0
  119. package/src/denormalised-username.ts +34 -0
  120. package/src/discovery-repo.ts +140 -0
  121. package/src/draft-repo.ts +59 -0
  122. package/src/feed-repo.ts +219 -0
  123. package/src/forum-admin-repo.ts +310 -0
  124. package/src/forum-repo.ts +206 -0
  125. package/src/group-admin-repo.ts +256 -0
  126. package/src/group-identity-repo.ts +84 -0
  127. package/src/identity-link-repo.ts +145 -0
  128. package/src/import-repo.ts +123 -0
  129. package/src/import-sink.ts +1377 -0
  130. package/src/index.ts +316 -0
  131. package/src/inline-moderation.ts +344 -0
  132. package/src/install-repo.ts +67 -0
  133. package/src/latest-repo.ts +115 -0
  134. package/src/maintenance-repo.ts +54 -0
  135. package/src/marketplace-repo.ts +111 -0
  136. package/src/member-profile-repo.ts +82 -0
  137. package/src/member-settings-repo.ts +160 -0
  138. package/src/message-repo.ts +384 -0
  139. package/src/migrate-run.ts +18 -0
  140. package/src/migrate.ts +129 -0
  141. package/src/modcp-repo.ts +253 -0
  142. package/src/moderation-queue.ts +279 -0
  143. package/src/navigation-repo.ts +288 -0
  144. package/src/notification-repo.ts +402 -0
  145. package/src/orphan-keys.ts +19 -0
  146. package/src/outbox-repo.ts +78 -0
  147. package/src/permission-version.ts +24 -0
  148. package/src/permissions-map.ts +52 -0
  149. package/src/pglite.fixture.ts +87 -0
  150. package/src/plugin-data.ts +88 -0
  151. package/src/plugin-grants.ts +369 -0
  152. package/src/plugin-health-repo.ts +80 -0
  153. package/src/plugin-purge-repo.ts +91 -0
  154. package/src/plugin-users.ts +39 -0
  155. package/src/poll-repo.ts +334 -0
  156. package/src/post-repo.ts +261 -0
  157. package/src/post-writes.ts +227 -0
  158. package/src/presence-repo.ts +220 -0
  159. package/src/profile-field-repo.ts +182 -0
  160. package/src/promotion-repo.ts +152 -0
  161. package/src/read-state-repo.ts +81 -0
  162. package/src/relation-repo.ts +113 -0
  163. package/src/render-backfill.ts +110 -0
  164. package/src/render-signature.ts +33 -0
  165. package/src/report-repo.ts +349 -0
  166. package/src/reputation-repo.ts +295 -0
  167. package/src/result-rows.ts +13 -0
  168. package/src/row-values.ts +7 -0
  169. package/src/schema/content.ts +451 -0
  170. package/src/schema/identity.ts +699 -0
  171. package/src/schema/index.ts +10 -0
  172. package/src/schema/messages.ts +76 -0
  173. package/src/schema/permission-columns.ts +34 -0
  174. package/src/schema/platform.ts +376 -0
  175. package/src/schema/structure.ts +164 -0
  176. package/src/search-repo.ts +359 -0
  177. package/src/search-store.ts +113 -0
  178. package/src/seed-groups.ts +11 -0
  179. package/src/settings-repo.ts +32 -0
  180. package/src/signature-repo.ts +102 -0
  181. package/src/sql-lists.ts +17 -0
  182. package/src/staff-groups.ts +50 -0
  183. package/src/stats-repo.ts +151 -0
  184. package/src/subscription-repo.ts +350 -0
  185. package/src/system-health-repo.ts +130 -0
  186. package/src/task-repo.ts +104 -0
  187. package/src/theme-admin-repo.ts +172 -0
  188. package/src/theme-repo.ts +43 -0
  189. package/src/thread-audience.ts +45 -0
  190. package/src/thread-counters.ts +107 -0
  191. package/src/thread-repo.ts +255 -0
  192. package/src/thread-surgery.ts +282 -0
  193. package/src/thread-tools.ts +311 -0
  194. package/src/thread-views.ts +41 -0
  195. package/src/thread-writes.ts +356 -0
  196. package/src/two-factor-repo.ts +235 -0
  197. package/src/upgrade-repo.ts +66 -0
  198. package/src/user-admin-repo.ts +367 -0
  199. package/src/user-bulk-repo.ts +397 -0
  200. package/src/user-merge-map.ts +111 -0
  201. package/src/user-merge-repo.ts +304 -0
  202. package/src/visibility-counters.ts +190 -0
  203. package/src/visibility.ts +17 -0
  204. package/src/vocabulary-repo.ts +54 -0
  205. package/src/warning-repo.ts +328 -0
@@ -0,0 +1,580 @@
1
+ import { and, desc, eq, gt, isNull, lt, ne, or, sql } from 'drizzle-orm'
2
+
3
+ import type {
4
+ AccountRecord,
5
+ AccountRepository,
6
+ AccountState,
7
+ AccountStore,
8
+ ActiveSessionRecord,
9
+ CredentialPurpose,
10
+ CredentialTokenRepository,
11
+ LoginAttemptRepository,
12
+ NewAccount,
13
+ RememberRotation,
14
+ RememberTokenRepository,
15
+ SessionLocation,
16
+ SessionRecord,
17
+ SessionRepository,
18
+ } from '@meith/accounts'
19
+ import { withinRotationGrace } from '@meith/accounts'
20
+
21
+ import type { Database } from './client'
22
+ import { PostgresPasskeyRepository, PostgresUserIdentityRepository } from './identity-link-repo'
23
+ import { resultRows } from './result-rows'
24
+ import { credentialTokens, loginAttempts, rememberTokens, sessions, users } from './schema'
25
+ import {
26
+ PostgresAuthEventRepository,
27
+ PostgresRecoveryCodeRepository,
28
+ PostgresTwoFactorRepository,
29
+ } from './two-factor-repo'
30
+
31
+ function toAccountRecord(row: {
32
+ id: number
33
+ username: string
34
+ usernameLower: string
35
+ email: string
36
+ emailLower: string
37
+ passwordHash: string | null
38
+ passwordAlgo: string | null
39
+ state: string
40
+ emailVerifiedAt: Date | null
41
+ primaryGroupId: number | null
42
+ }): AccountRecord {
43
+ return {
44
+ id: row.id,
45
+ username: row.username,
46
+ usernameLower: row.usernameLower,
47
+ email: row.email,
48
+ emailLower: row.emailLower,
49
+ passwordHash: row.passwordHash,
50
+ passwordAlgo: row.passwordAlgo,
51
+ state: row.state as AccountState,
52
+ emailVerifiedAt: row.emailVerifiedAt,
53
+ primaryGroupId: row.primaryGroupId,
54
+ }
55
+ }
56
+
57
+ const ACCOUNT_COLUMNS = {
58
+ id: users.id,
59
+ username: users.username,
60
+ usernameLower: users.usernameLower,
61
+ email: users.email,
62
+ emailLower: users.emailLower,
63
+ passwordHash: users.passwordHash,
64
+ passwordAlgo: users.passwordAlgo,
65
+ state: users.state,
66
+ emailVerifiedAt: users.emailVerifiedAt,
67
+ primaryGroupId: users.primaryGroupId,
68
+ } as const
69
+
70
+ export class PostgresAccountRepository implements AccountRepository {
71
+ constructor(private readonly db: Database) {}
72
+
73
+ async findById(id: number): Promise<AccountRecord | null> {
74
+ const rows = await this.db
75
+ .select(ACCOUNT_COLUMNS)
76
+ .from(users)
77
+ .where(and(eq(users.id, id), isNull(users.deletedAt)))
78
+ .limit(1)
79
+ return rows[0] ? toAccountRecord(rows[0]) : null
80
+ }
81
+
82
+ async findByUsernameLower(usernameLower: string): Promise<AccountRecord | null> {
83
+ const rows = await this.db
84
+ .select(ACCOUNT_COLUMNS)
85
+ .from(users)
86
+ .where(and(eq(users.usernameLower, usernameLower), isNull(users.deletedAt)))
87
+ .limit(1)
88
+ return rows[0] ? toAccountRecord(rows[0]) : null
89
+ }
90
+
91
+ async findByEmailLower(emailLower: string): Promise<AccountRecord | null> {
92
+ const rows = await this.db
93
+ .select(ACCOUNT_COLUMNS)
94
+ .from(users)
95
+ .where(and(eq(users.emailLower, emailLower), isNull(users.deletedAt)))
96
+ .limit(1)
97
+ return rows[0] ? toAccountRecord(rows[0]) : null
98
+ }
99
+
100
+ async create(input: NewAccount): Promise<AccountRecord> {
101
+ const rows = await this.db
102
+ .insert(users)
103
+ .values({
104
+ username: input.username,
105
+ usernameLower: input.usernameLower,
106
+ email: input.email,
107
+ emailLower: input.emailLower,
108
+ passwordHash: input.passwordHash,
109
+ passwordAlgo: input.passwordAlgo,
110
+ state: input.state,
111
+ primaryGroupId: input.primaryGroupId,
112
+ registrationIpPrefix: input.registrationIpPrefix ?? null,
113
+ })
114
+ .returning(ACCOUNT_COLUMNS)
115
+ return toAccountRecord(rows[0]!)
116
+ }
117
+
118
+ async recordLastIpPrefix(userId: number, prefix: string): Promise<void> {
119
+ await this.db
120
+ .update(users)
121
+ .set({ lastIpPrefix: prefix })
122
+ .where(and(eq(users.id, userId), isNull(users.deletedAt)))
123
+ }
124
+
125
+ async updatePassword(userId: number, passwordHash: string, passwordAlgo: string): Promise<void> {
126
+ await this.db
127
+ .update(users)
128
+ .set({ passwordHash, passwordAlgo, passwordChangedAt: new Date() })
129
+ .where(and(eq(users.id, userId), isNull(users.deletedAt)))
130
+ }
131
+
132
+ async setState(userId: number, state: AccountState): Promise<void> {
133
+ await this.db
134
+ .update(users)
135
+ .set({ state })
136
+ .where(and(eq(users.id, userId), isNull(users.deletedAt)))
137
+ }
138
+
139
+ async markEmailVerified(
140
+ userId: number,
141
+ at: Date,
142
+ activate: boolean,
143
+ ): Promise<AccountState | null> {
144
+ const rows = resultRows(
145
+ await this.db.execute(sql`
146
+ with before as (
147
+ select id, state from users where id = ${userId} and deleted_at is null for update
148
+ )
149
+ update users u
150
+ set email_verified_at = coalesce(u.email_verified_at, ${at}),
151
+ state = case
152
+ when ${activate}::boolean and b.state = 'awaiting_activation'
153
+ then 'active'
154
+ else u.state
155
+ end,
156
+ updated_at = now()
157
+ from before b
158
+ where u.id = b.id
159
+ returning b.state as previous_state
160
+ `),
161
+ ) as Array<{ previous_state: string }>
162
+
163
+ const previous = rows[0]?.previous_state
164
+ return previous === undefined ? null : (previous as AccountState)
165
+ }
166
+
167
+ async touchLastActive(userId: number, now: Date, windowSeconds: number): Promise<boolean> {
168
+ const cutoff = new Date(now.getTime() - windowSeconds * 1000)
169
+ const rows = await this.db
170
+ .update(users)
171
+ .set({ lastActiveAt: now })
172
+ .where(
173
+ and(
174
+ eq(users.id, userId),
175
+ isNull(users.deletedAt),
176
+ or(isNull(users.lastActiveAt), lt(users.lastActiveAt, cutoff)),
177
+ ),
178
+ )
179
+ .returning({ id: users.id })
180
+ return rows.length > 0
181
+ }
182
+ }
183
+
184
+ export class PostgresSessionRepository implements SessionRepository {
185
+ constructor(private readonly db: Database) {}
186
+
187
+ async create(input: {
188
+ tokenHash: string
189
+ userId: number
190
+ expiresAt: Date
191
+ ipPrefix?: string | null
192
+ userAgent?: string | null
193
+ }): Promise<SessionRecord> {
194
+ const rows = await this.db
195
+ .insert(sessions)
196
+ .values({
197
+ tokenHash: input.tokenHash,
198
+ userId: input.userId,
199
+ expiresAt: input.expiresAt,
200
+ ipPrefix: input.ipPrefix ?? null,
201
+ userAgent: input.userAgent ?? null,
202
+ })
203
+ .returning(SESSION_COLUMNS)
204
+ return toSessionRecord(rows[0]!)
205
+ }
206
+
207
+ async findByTokenHash(tokenHash: string): Promise<SessionRecord | null> {
208
+ const rows = await this.db
209
+ .select(SESSION_COLUMNS)
210
+ .from(sessions)
211
+ .where(eq(sessions.tokenHash, tokenHash))
212
+ .limit(1)
213
+ const row = rows[0]
214
+ if (!row || row.userId === null) return null
215
+ return toSessionRecord(row)
216
+ }
217
+
218
+ async markCredentialProved(sessionId: number, userId: number, at: Date): Promise<boolean> {
219
+ const rows = await this.db
220
+ .update(sessions)
221
+ .set({ credentialProvedAt: at })
222
+ .where(
223
+ and(
224
+ eq(sessions.id, sessionId),
225
+ eq(sessions.userId, userId),
226
+ isNull(sessions.revokedAt),
227
+ isNull(sessions.supersededBySessionId),
228
+ gt(sessions.expiresAt, at),
229
+ ),
230
+ )
231
+ .returning({ id: sessions.id })
232
+ return rows.length > 0
233
+ }
234
+
235
+ async listActiveForUser(userId: number, now: Date): Promise<readonly ActiveSessionRecord[]> {
236
+ return this.db
237
+ .select({
238
+ id: sessions.id,
239
+ createdAt: sessions.createdAt,
240
+ lastSeenAt: sessions.lastSeenAt,
241
+ expiresAt: sessions.expiresAt,
242
+ ipPrefix: sessions.ipPrefix,
243
+ userAgent: sessions.userAgent,
244
+ })
245
+ .from(sessions)
246
+ .where(
247
+ and(eq(sessions.userId, userId), isNull(sessions.revokedAt), gt(sessions.expiresAt, now)),
248
+ )
249
+ .orderBy(desc(sessions.lastSeenAt))
250
+ }
251
+
252
+ async revoke(sessionId: number): Promise<void> {
253
+ await this.db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, sessionId))
254
+ }
255
+
256
+ async revokeOwned(userId: number, sessionId: number, now: Date): Promise<boolean> {
257
+ const rows = await this.db
258
+ .update(sessions)
259
+ .set({ revokedAt: now })
260
+ .where(
261
+ and(eq(sessions.id, sessionId), eq(sessions.userId, userId), isNull(sessions.revokedAt)),
262
+ )
263
+ .returning({ id: sessions.id })
264
+ return rows.length > 0
265
+ }
266
+
267
+ async revokeAllForUserExcept(userId: number, sessionId: number | null): Promise<number> {
268
+ const rows = await this.db
269
+ .update(sessions)
270
+ .set({ revokedAt: new Date() })
271
+ .where(
272
+ and(
273
+ eq(sessions.userId, userId),
274
+ isNull(sessions.revokedAt),
275
+ sessionId === null ? undefined : ne(sessions.id, sessionId),
276
+ ),
277
+ )
278
+ .returning({ id: sessions.id })
279
+ return rows.length
280
+ }
281
+
282
+ async revokeAllForUser(userId: number): Promise<void> {
283
+ await this.db
284
+ .update(sessions)
285
+ .set({ revokedAt: new Date() })
286
+ .where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt)))
287
+ }
288
+
289
+ async supersede(oldSessionId: number, newSessionId: number, now: Date): Promise<void> {
290
+ await this.db
291
+ .update(sessions)
292
+ .set({ supersededBySessionId: newSessionId, revokedAt: now })
293
+ .where(and(eq(sessions.id, oldSessionId), isNull(sessions.revokedAt)))
294
+ }
295
+
296
+ async touchLocation(
297
+ sessionId: number,
298
+ location: SessionLocation,
299
+ now: Date,
300
+ windowSeconds: number,
301
+ ): Promise<boolean> {
302
+ const cutoff = new Date(now.getTime() - windowSeconds * 1000)
303
+ const rows = await this.db
304
+ .update(sessions)
305
+ .set({
306
+ locationPath: location.path,
307
+ locationForumId: location.forumId,
308
+ locationThreadId: location.threadId,
309
+ lastSeenAt: now,
310
+ })
311
+ .where(
312
+ and(
313
+ eq(sessions.id, sessionId),
314
+ isNull(sessions.revokedAt),
315
+ lt(sessions.lastSeenAt, cutoff),
316
+ ),
317
+ )
318
+ .returning({ id: sessions.id })
319
+ return rows.length > 0
320
+ }
321
+ }
322
+
323
+ const SESSION_COLUMNS = {
324
+ id: sessions.id,
325
+ userId: sessions.userId,
326
+ expiresAt: sessions.expiresAt,
327
+ revokedAt: sessions.revokedAt,
328
+ supersededBySessionId: sessions.supersededBySessionId,
329
+ credentialProvedAt: sessions.credentialProvedAt,
330
+ lastSeenAt: sessions.lastSeenAt,
331
+ } as const
332
+
333
+ function toSessionRecord(row: {
334
+ id: number
335
+ userId: number | null
336
+ expiresAt: Date
337
+ revokedAt: Date | null
338
+ supersededBySessionId: number | null
339
+ credentialProvedAt: Date | null
340
+ lastSeenAt: Date
341
+ }): SessionRecord {
342
+ return {
343
+ id: row.id,
344
+ userId: row.userId!,
345
+ expiresAt: row.expiresAt,
346
+ revokedAt: row.revokedAt,
347
+ supersededBySessionId: row.supersededBySessionId,
348
+ credentialProvedAt: row.credentialProvedAt,
349
+ lastSeenAt: row.lastSeenAt,
350
+ }
351
+ }
352
+
353
+ export class PostgresCredentialTokenRepository implements CredentialTokenRepository {
354
+ constructor(private readonly db: Database) {}
355
+
356
+ async issue(input: {
357
+ tokenHash: string
358
+ userId: number
359
+ purpose: CredentialPurpose
360
+ payload?: string | null
361
+ expiresAt: Date
362
+ }): Promise<void> {
363
+ await this.db.insert(credentialTokens).values({
364
+ tokenHash: input.tokenHash,
365
+ userId: input.userId,
366
+ purpose: input.purpose,
367
+ payload: input.payload ?? null,
368
+ expiresAt: input.expiresAt,
369
+ })
370
+ }
371
+
372
+ async peek(
373
+ tokenHash: string,
374
+ purpose: CredentialPurpose,
375
+ now: Date,
376
+ ): Promise<{ userId: number; payload: string | null } | null> {
377
+ const rows = await this.db
378
+ .select({ userId: credentialTokens.userId, payload: credentialTokens.payload })
379
+ .from(credentialTokens)
380
+ .where(
381
+ and(
382
+ eq(credentialTokens.tokenHash, tokenHash),
383
+ eq(credentialTokens.purpose, purpose),
384
+ isNull(credentialTokens.consumedAt),
385
+ gt(credentialTokens.expiresAt, now),
386
+ ),
387
+ )
388
+ .limit(1)
389
+ return rows[0] ?? null
390
+ }
391
+
392
+ async consume(
393
+ tokenHash: string,
394
+ purpose: CredentialPurpose,
395
+ now: Date,
396
+ ): Promise<{ userId: number; payload: string | null } | null> {
397
+ const rows = await this.db
398
+ .update(credentialTokens)
399
+ .set({ consumedAt: now })
400
+ .where(
401
+ and(
402
+ eq(credentialTokens.tokenHash, tokenHash),
403
+ eq(credentialTokens.purpose, purpose),
404
+ isNull(credentialTokens.consumedAt),
405
+ gt(credentialTokens.expiresAt, now),
406
+ ),
407
+ )
408
+ .returning({
409
+ userId: credentialTokens.userId,
410
+ payload: credentialTokens.payload,
411
+ })
412
+ const row = rows[0]
413
+ return row ? { userId: row.userId, payload: row.payload } : null
414
+ }
415
+
416
+ async revokeAllForUser(userId: number, purpose: CredentialPurpose): Promise<void> {
417
+ await this.db
418
+ .update(credentialTokens)
419
+ .set({ consumedAt: new Date() })
420
+ .where(
421
+ and(
422
+ eq(credentialTokens.userId, userId),
423
+ eq(credentialTokens.purpose, purpose),
424
+ isNull(credentialTokens.consumedAt),
425
+ ),
426
+ )
427
+ }
428
+ }
429
+
430
+ export class PostgresLoginAttemptRepository implements LoginAttemptRepository {
431
+ constructor(private readonly db: Database) {}
432
+
433
+ async record(bucket: string, succeeded: boolean, at: Date): Promise<void> {
434
+ await this.db.insert(loginAttempts).values({ bucket, succeeded, occurredAt: at })
435
+ }
436
+
437
+ async countFailuresSince(bucket: string, since: Date): Promise<number> {
438
+ const rows = await this.db
439
+ .select({ id: loginAttempts.id })
440
+ .from(loginAttempts)
441
+ .where(
442
+ and(
443
+ eq(loginAttempts.bucket, bucket),
444
+ eq(loginAttempts.succeeded, false),
445
+ gt(loginAttempts.occurredAt, since),
446
+ ),
447
+ )
448
+ return rows.length
449
+ }
450
+
451
+ async clear(bucket: string): Promise<void> {
452
+ await this.db.delete(loginAttempts).where(eq(loginAttempts.bucket, bucket))
453
+ }
454
+ }
455
+
456
+ export class PostgresRememberTokenRepository implements RememberTokenRepository {
457
+ constructor(private readonly db: Database) {}
458
+
459
+ async issue(input: {
460
+ tokenHash: string
461
+ familyId: string
462
+ userId: number
463
+ expiresAt: Date
464
+ }): Promise<void> {
465
+ await this.db.insert(rememberTokens).values({
466
+ tokenHash: input.tokenHash,
467
+ familyId: input.familyId,
468
+ userId: input.userId,
469
+ expiresAt: input.expiresAt,
470
+ })
471
+ }
472
+
473
+ async rotate(input: {
474
+ presentedHash: string
475
+ nextHash: string
476
+ now: Date
477
+ nextExpiresAt: Date
478
+ }): Promise<RememberRotation> {
479
+ const claimed = await this.db
480
+ .update(rememberTokens)
481
+ .set({ usedAt: input.now })
482
+ .where(
483
+ and(
484
+ eq(rememberTokens.tokenHash, input.presentedHash),
485
+ isNull(rememberTokens.usedAt),
486
+ isNull(rememberTokens.revokedAt),
487
+ gt(rememberTokens.expiresAt, input.now),
488
+ ),
489
+ )
490
+ .returning({ familyId: rememberTokens.familyId, userId: rememberTokens.userId })
491
+
492
+ if (claimed[0]) {
493
+ const { familyId, userId } = claimed[0]
494
+ await this.db.insert(rememberTokens).values({
495
+ tokenHash: input.nextHash,
496
+ familyId,
497
+ userId,
498
+ expiresAt: input.nextExpiresAt,
499
+ })
500
+ return { status: 'rotated', userId, familyId }
501
+ }
502
+
503
+ const existing = await this.db
504
+ .select({
505
+ familyId: rememberTokens.familyId,
506
+ userId: rememberTokens.userId,
507
+ expiresAt: rememberTokens.expiresAt,
508
+ usedAt: rememberTokens.usedAt,
509
+ revokedAt: rememberTokens.revokedAt,
510
+ })
511
+ .from(rememberTokens)
512
+ .where(eq(rememberTokens.tokenHash, input.presentedHash))
513
+ .limit(1)
514
+
515
+ const row = existing[0]
516
+ if (!row || row.expiresAt.getTime() <= input.now.getTime()) {
517
+ return { status: 'invalid' }
518
+ }
519
+
520
+ if (withinRotationGrace(row, input.now)) {
521
+ await this.db.insert(rememberTokens).values({
522
+ tokenHash: input.nextHash,
523
+ familyId: row.familyId,
524
+ userId: row.userId,
525
+ expiresAt: input.nextExpiresAt,
526
+ })
527
+ return { status: 'rotated', userId: row.userId, familyId: row.familyId }
528
+ }
529
+
530
+ return { status: 'reuse', userId: row.userId, familyId: row.familyId }
531
+ }
532
+
533
+ async revokeFamily(familyId: string, reason: string, now: Date): Promise<void> {
534
+ await this.db
535
+ .update(rememberTokens)
536
+ .set({ revokedAt: now, revokedReason: reason })
537
+ .where(and(eq(rememberTokens.familyId, familyId), isNull(rememberTokens.revokedAt)))
538
+ }
539
+
540
+ async revokeAllForUser(userId: number, reason: string, now: Date): Promise<void> {
541
+ await this.db
542
+ .update(rememberTokens)
543
+ .set({ revokedAt: now, revokedReason: reason })
544
+ .where(and(eq(rememberTokens.userId, userId), isNull(rememberTokens.revokedAt)))
545
+ }
546
+
547
+ async findByTokenHash(tokenHash: string): Promise<{
548
+ familyId: string
549
+ userId: number
550
+ usedAt: Date | null
551
+ revokedAt: Date | null
552
+ } | null> {
553
+ const rows = await this.db
554
+ .select({
555
+ familyId: rememberTokens.familyId,
556
+ userId: rememberTokens.userId,
557
+ usedAt: rememberTokens.usedAt,
558
+ revokedAt: rememberTokens.revokedAt,
559
+ })
560
+ .from(rememberTokens)
561
+ .where(eq(rememberTokens.tokenHash, tokenHash))
562
+ .limit(1)
563
+ return rows[0] ?? null
564
+ }
565
+ }
566
+
567
+ export function createPostgresAccountStore(db: Database): AccountStore {
568
+ return {
569
+ accounts: new PostgresAccountRepository(db),
570
+ sessions: new PostgresSessionRepository(db),
571
+ tokens: new PostgresCredentialTokenRepository(db),
572
+ loginAttempts: new PostgresLoginAttemptRepository(db),
573
+ remember: new PostgresRememberTokenRepository(db),
574
+ identities: new PostgresUserIdentityRepository(db),
575
+ passkeys: new PostgresPasskeyRepository(db),
576
+ twoFactor: new PostgresTwoFactorRepository(db),
577
+ recoveryCodes: new PostgresRecoveryCodeRepository(db),
578
+ authEvents: new PostgresAuthEventRepository(db),
579
+ }
580
+ }
@@ -0,0 +1,130 @@
1
+ import { and, eq, inArray, isNull } from 'drizzle-orm'
2
+
3
+ import type { Actor, ActorSource, ActorState } from '@meith/authorization'
4
+ import { combinePermissionSets } from '@meith/authorization'
5
+ import { emptyPermissionSet } from '@meith/core'
6
+
7
+ import type { Database } from './client'
8
+ import { groupRowToPermissionSet } from './permissions-map'
9
+ import { cacheVersions, userGroupMemberships, usergroups, users } from './schema'
10
+
11
+ function mapState(dbState: string): ActorState | 'deleted' {
12
+ switch (dbState) {
13
+ case 'active':
14
+ return 'active'
15
+ case 'awaiting_activation':
16
+ case 'awaiting_approval':
17
+ return 'awaiting_activation'
18
+ case 'banned':
19
+ return 'banned'
20
+ default:
21
+ return 'deleted'
22
+ }
23
+ }
24
+
25
+ export interface ActorBuilderConfig {
26
+ readonly guestGroupId: number
27
+ }
28
+
29
+ export class ActorBuilder implements ActorSource {
30
+ constructor(
31
+ private readonly db: Database,
32
+ private readonly config: ActorBuilderConfig,
33
+ ) {}
34
+
35
+ async buildGuest(): Promise<Actor> {
36
+ const groupIds = [this.config.guestGroupId]
37
+ const global = await this.combineGroups(groupIds)
38
+ return {
39
+ userId: null,
40
+ groupIds,
41
+ primaryGroupId: this.config.guestGroupId,
42
+ state: 'guest',
43
+ global,
44
+ permissionVersion: await this.permissionVersion(),
45
+ }
46
+ }
47
+
48
+ async buildForUser(userId: number): Promise<Actor | null> {
49
+ const userRows = await this.db
50
+ .select({
51
+ id: users.id,
52
+ state: users.state,
53
+ primaryGroupId: users.primaryGroupId,
54
+ })
55
+ .from(users)
56
+ .where(and(eq(users.id, userId), isNull(users.deletedAt)))
57
+ .limit(1)
58
+
59
+ const user = userRows[0]
60
+ if (!user) return null
61
+
62
+ const state = mapState(user.state)
63
+ if (state === 'deleted') return null
64
+
65
+ const now = new Date()
66
+ const membershipRows = await this.db
67
+ .select({
68
+ groupId: userGroupMemberships.groupId,
69
+ expiresAt: userGroupMemberships.expiresAt,
70
+ previousPrimaryGroupId: userGroupMemberships.previousPrimaryGroupId,
71
+ })
72
+ .from(userGroupMemberships)
73
+ .where(eq(userGroupMemberships.userId, userId))
74
+
75
+ const live = membershipRows.filter(
76
+ (r) => r.expiresAt === null || r.expiresAt.getTime() > now.getTime(),
77
+ )
78
+
79
+ const held = user.primaryGroupId
80
+ const lapsed = membershipRows.find(
81
+ (r) =>
82
+ r.groupId === held &&
83
+ r.previousPrimaryGroupId !== null &&
84
+ r.expiresAt !== null &&
85
+ r.expiresAt.getTime() <= now.getTime(),
86
+ )
87
+
88
+ const primaryGroupId = lapsed?.previousPrimaryGroupId ?? held
89
+ const groupIds = dedupe([primaryGroupId, ...live.map((r) => r.groupId)])
90
+
91
+ const global = await this.combineGroups(groupIds)
92
+
93
+ return {
94
+ userId: user.id,
95
+ groupIds,
96
+ primaryGroupId,
97
+ state,
98
+ global,
99
+ permissionVersion: await this.permissionVersion(),
100
+ }
101
+ }
102
+
103
+ private async combineGroups(groupIds: readonly number[]) {
104
+ if (groupIds.length === 0) return emptyPermissionSet()
105
+
106
+ const rows = await this.db
107
+ .select()
108
+ .from(usergroups)
109
+ .where(inArray(usergroups.id, [...groupIds]))
110
+
111
+ if (rows.length === 0) return emptyPermissionSet()
112
+
113
+ return combinePermissionSets(
114
+ rows.map((row) => groupRowToPermissionSet(row as Record<string, unknown>)),
115
+ )
116
+ }
117
+
118
+ private async permissionVersion(): Promise<number> {
119
+ const rows = await this.db
120
+ .select({ version: cacheVersions.version })
121
+ .from(cacheVersions)
122
+ .where(eq(cacheVersions.key, 'permissions'))
123
+ .limit(1)
124
+ return rows[0]?.version ?? 1
125
+ }
126
+ }
127
+
128
+ function dedupe(ids: readonly number[]): number[] {
129
+ return [...new Set(ids)]
130
+ }