@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.
- package/LICENSE.md +165 -0
- package/migrations/0000_initial_schema.sql +538 -0
- package/migrations/0001_seed_usergroups.sql +111 -0
- package/migrations/0002_group_promotions.sql +25 -0
- package/migrations/0003_counter_maintenance.sql +34 -0
- package/migrations/0004_post_render_cache.sql +7 -0
- package/migrations/0005_reports.sql +51 -0
- package/migrations/0006_warnings.sql +91 -0
- package/migrations/0007_notifications.sql +44 -0
- package/migrations/0008_subscriptions.sql +48 -0
- package/migrations/0009_member_preferences.sql +13 -0
- package/migrations/0010_profile_fields.sql +53 -0
- package/migrations/0011_private_messages.sql +63 -0
- package/migrations/0012_user_relations.sql +18 -0
- package/migrations/0013_reputation.sql +45 -0
- package/migrations/0014_signatures.sql +16 -0
- package/migrations/0015_admin_sessions.sql +24 -0
- package/migrations/0016_attachments.sql +51 -0
- package/migrations/0017_avatars.sql +33 -0
- package/migrations/0018_mass_mail.sql +15 -0
- package/migrations/0019_word_filters.sql +8 -0
- package/migrations/0020_searches.sql +15 -0
- package/migrations/0021_presence_stats.sql +18 -0
- package/migrations/0022_api_tokens_webhooks.sql +58 -0
- package/migrations/0023_install_state.sql +6 -0
- package/migrations/0024_component_versions.sql +13 -0
- package/migrations/0025_import.sql +25 -0
- package/migrations/0026_polls.sql +40 -0
- package/migrations/0027_drafts.sql +13 -0
- package/migrations/0028_board_vocabulary.sql +32 -0
- package/migrations/0029_announcements.sql +16 -0
- package/migrations/0030_antispam.sql +18 -0
- package/migrations/0031_markdown.sql +24 -0
- package/migrations/0032_theme_enablement.sql +1 -0
- package/migrations/0033_group_identity.sql +5 -0
- package/migrations/0034_search_document_version.sql +4 -0
- package/migrations/0035_categories_hold_no_threads.sql +1 -0
- package/migrations/0036_expiring_group_grants.sql +6 -0
- package/migrations/0037_automatic_timezone.sql +3 -0
- package/migrations/0038_primary_group_grants.sql +3 -0
- package/migrations/0039_moderator_log_forum_scope.sql +1 -0
- package/migrations/0040_moderator_restore_grants.sql +1 -0
- package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
- package/migrations/0042_drop_placebo_delete_others.sql +2 -0
- package/migrations/0043_federated_identity.sql +28 -0
- package/migrations/0044_second_factor.sql +34 -0
- package/migrations/0045_drop_dead_task_due_index.sql +1 -0
- package/migrations/0046_member_locale.sql +1 -0
- package/migrations/0047_navigation_items.sql +34 -0
- package/migrations/0048_web_push.sql +16 -0
- package/migrations/0049_plugin_health.sql +7 -0
- package/migrations/0050_drop_forum_password_grants.sql +1 -0
- package/migrations/0051_glossy_barracuda.sql +1 -0
- package/migrations/0052_many_professor_monster.sql +2 -0
- package/migrations/0053_strange_maximus.sql +14 -0
- package/migrations/0054_legacy_import_sources.sql +25 -0
- package/migrations/0055_poll_parity.sql +14 -0
- package/migrations/0056_marketplace_catalog.sql +11 -0
- package/migrations/meta/0000_snapshot.json +4412 -0
- package/migrations/meta/0006_snapshot.json +5411 -0
- package/migrations/meta/0007_snapshot.json +5634 -0
- package/migrations/meta/0008_snapshot.json +5779 -0
- package/migrations/meta/0009_snapshot.json +5816 -0
- package/migrations/meta/0010_snapshot.json +6117 -0
- package/migrations/meta/0011_snapshot.json +6406 -0
- package/migrations/meta/0012_snapshot.json +6517 -0
- package/migrations/meta/0013_snapshot.json +6722 -0
- package/migrations/meta/0014_snapshot.json +6777 -0
- package/migrations/meta/0015_snapshot.json +6914 -0
- package/migrations/meta/0016_snapshot.json +7192 -0
- package/migrations/meta/0017_snapshot.json +7248 -0
- package/migrations/meta/0018_snapshot.json +7355 -0
- package/migrations/meta/0019_snapshot.json +7419 -0
- package/migrations/meta/0020_snapshot.json +7562 -0
- package/migrations/meta/0021_snapshot.json +7653 -0
- package/migrations/meta/0031_snapshot.json +8162 -0
- package/migrations/meta/0032_snapshot.json +8169 -0
- package/migrations/meta/0033_snapshot.json +8193 -0
- package/migrations/meta/0034_snapshot.json +8221 -0
- package/migrations/meta/0036_snapshot.json +8262 -0
- package/migrations/meta/0037_snapshot.json +8262 -0
- package/migrations/meta/0038_snapshot.json +8293 -0
- package/migrations/meta/0039_snapshot.json +8308 -0
- package/migrations/meta/0041_snapshot.json +8287 -0
- package/migrations/meta/0042_snapshot.json +8274 -0
- package/migrations/meta/0043_snapshot.json +8521 -0
- package/migrations/meta/0044_snapshot.json +8814 -0
- package/migrations/meta/0045_snapshot.json +8797 -0
- package/migrations/meta/0046_snapshot.json +8804 -0
- package/migrations/meta/0047_snapshot.json +9018 -0
- package/migrations/meta/0048_snapshot.json +9145 -0
- package/migrations/meta/0049_snapshot.json +9190 -0
- package/migrations/meta/0050_snapshot.json +9126 -0
- package/migrations/meta/0051_snapshot.json +9132 -0
- package/migrations/meta/0052_snapshot.json +9148 -0
- package/migrations/meta/0053_snapshot.json +9268 -0
- package/migrations/meta/0054_snapshot.json +9378 -0
- package/migrations/meta/0056_snapshot.json +9447 -0
- package/migrations/meta/_journal.json +405 -0
- package/package.json +64 -0
- package/src/account-repos.ts +580 -0
- package/src/actor-builder.ts +130 -0
- package/src/admin-repo.ts +85 -0
- package/src/admin-session-repo.ts +205 -0
- package/src/admin-undo-repo.ts +128 -0
- package/src/announcement-repo.ts +176 -0
- package/src/antispam-repo.ts +145 -0
- package/src/api-repo.ts +276 -0
- package/src/attachment-admin-repo.ts +161 -0
- package/src/attachment-repo.ts +240 -0
- package/src/authorization-source.ts +138 -0
- package/src/avatar-repo.ts +185 -0
- package/src/ban-repos.ts +146 -0
- package/src/client.ts +85 -0
- package/src/content-admin-repo.ts +298 -0
- package/src/content-counters.ts +152 -0
- package/src/counter-recount.ts +263 -0
- package/src/cursor.ts +16 -0
- package/src/denormalised-username.ts +34 -0
- package/src/discovery-repo.ts +140 -0
- package/src/draft-repo.ts +59 -0
- package/src/feed-repo.ts +219 -0
- package/src/forum-admin-repo.ts +310 -0
- package/src/forum-repo.ts +206 -0
- package/src/group-admin-repo.ts +256 -0
- package/src/group-identity-repo.ts +84 -0
- package/src/identity-link-repo.ts +145 -0
- package/src/import-repo.ts +123 -0
- package/src/import-sink.ts +1377 -0
- package/src/index.ts +316 -0
- package/src/inline-moderation.ts +344 -0
- package/src/install-repo.ts +67 -0
- package/src/latest-repo.ts +115 -0
- package/src/maintenance-repo.ts +54 -0
- package/src/marketplace-repo.ts +111 -0
- package/src/member-profile-repo.ts +82 -0
- package/src/member-settings-repo.ts +160 -0
- package/src/message-repo.ts +384 -0
- package/src/migrate-run.ts +18 -0
- package/src/migrate.ts +129 -0
- package/src/modcp-repo.ts +253 -0
- package/src/moderation-queue.ts +279 -0
- package/src/navigation-repo.ts +288 -0
- package/src/notification-repo.ts +402 -0
- package/src/orphan-keys.ts +19 -0
- package/src/outbox-repo.ts +78 -0
- package/src/permission-version.ts +24 -0
- package/src/permissions-map.ts +52 -0
- package/src/pglite.fixture.ts +87 -0
- package/src/plugin-data.ts +88 -0
- package/src/plugin-grants.ts +369 -0
- package/src/plugin-health-repo.ts +80 -0
- package/src/plugin-purge-repo.ts +91 -0
- package/src/plugin-users.ts +39 -0
- package/src/poll-repo.ts +334 -0
- package/src/post-repo.ts +261 -0
- package/src/post-writes.ts +227 -0
- package/src/presence-repo.ts +220 -0
- package/src/profile-field-repo.ts +182 -0
- package/src/promotion-repo.ts +152 -0
- package/src/read-state-repo.ts +81 -0
- package/src/relation-repo.ts +113 -0
- package/src/render-backfill.ts +110 -0
- package/src/render-signature.ts +33 -0
- package/src/report-repo.ts +349 -0
- package/src/reputation-repo.ts +295 -0
- package/src/result-rows.ts +13 -0
- package/src/row-values.ts +7 -0
- package/src/schema/content.ts +451 -0
- package/src/schema/identity.ts +699 -0
- package/src/schema/index.ts +10 -0
- package/src/schema/messages.ts +76 -0
- package/src/schema/permission-columns.ts +34 -0
- package/src/schema/platform.ts +376 -0
- package/src/schema/structure.ts +164 -0
- package/src/search-repo.ts +359 -0
- package/src/search-store.ts +113 -0
- package/src/seed-groups.ts +11 -0
- package/src/settings-repo.ts +32 -0
- package/src/signature-repo.ts +102 -0
- package/src/sql-lists.ts +17 -0
- package/src/staff-groups.ts +50 -0
- package/src/stats-repo.ts +151 -0
- package/src/subscription-repo.ts +350 -0
- package/src/system-health-repo.ts +130 -0
- package/src/task-repo.ts +104 -0
- package/src/theme-admin-repo.ts +172 -0
- package/src/theme-repo.ts +43 -0
- package/src/thread-audience.ts +45 -0
- package/src/thread-counters.ts +107 -0
- package/src/thread-repo.ts +255 -0
- package/src/thread-surgery.ts +282 -0
- package/src/thread-tools.ts +311 -0
- package/src/thread-views.ts +41 -0
- package/src/thread-writes.ts +356 -0
- package/src/two-factor-repo.ts +235 -0
- package/src/upgrade-repo.ts +66 -0
- package/src/user-admin-repo.ts +367 -0
- package/src/user-bulk-repo.ts +397 -0
- package/src/user-merge-map.ts +111 -0
- package/src/user-merge-repo.ts +304 -0
- package/src/visibility-counters.ts +190 -0
- package/src/visibility.ts +17 -0
- package/src/vocabulary-repo.ts +54 -0
- 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
|
+
}
|