@emdash-cms/auth 0.0.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 (48) hide show
  1. package/dist/adapters/kysely.d.mts +62 -0
  2. package/dist/adapters/kysely.d.mts.map +1 -0
  3. package/dist/adapters/kysely.mjs +379 -0
  4. package/dist/adapters/kysely.mjs.map +1 -0
  5. package/dist/authenticate-D5UgaoTH.d.mts +124 -0
  6. package/dist/authenticate-D5UgaoTH.d.mts.map +1 -0
  7. package/dist/authenticate-j5GayLXB.mjs +373 -0
  8. package/dist/authenticate-j5GayLXB.mjs.map +1 -0
  9. package/dist/index.d.mts +444 -0
  10. package/dist/index.d.mts.map +1 -0
  11. package/dist/index.mjs +728 -0
  12. package/dist/index.mjs.map +1 -0
  13. package/dist/oauth/providers/github.d.mts +12 -0
  14. package/dist/oauth/providers/github.d.mts.map +1 -0
  15. package/dist/oauth/providers/github.mjs +55 -0
  16. package/dist/oauth/providers/github.mjs.map +1 -0
  17. package/dist/oauth/providers/google.d.mts +7 -0
  18. package/dist/oauth/providers/google.d.mts.map +1 -0
  19. package/dist/oauth/providers/google.mjs +38 -0
  20. package/dist/oauth/providers/google.mjs.map +1 -0
  21. package/dist/passkey/index.d.mts +2 -0
  22. package/dist/passkey/index.mjs +3 -0
  23. package/dist/types-Bu4irX9A.d.mts +35 -0
  24. package/dist/types-Bu4irX9A.d.mts.map +1 -0
  25. package/dist/types-CiSNpRI9.mjs +60 -0
  26. package/dist/types-CiSNpRI9.mjs.map +1 -0
  27. package/dist/types-HtRc90Wi.d.mts +208 -0
  28. package/dist/types-HtRc90Wi.d.mts.map +1 -0
  29. package/package.json +72 -0
  30. package/src/adapters/kysely.ts +715 -0
  31. package/src/config.ts +214 -0
  32. package/src/index.ts +135 -0
  33. package/src/invite.ts +205 -0
  34. package/src/magic-link/index.ts +150 -0
  35. package/src/oauth/consumer.ts +324 -0
  36. package/src/oauth/providers/github.ts +68 -0
  37. package/src/oauth/providers/google.ts +34 -0
  38. package/src/oauth/types.ts +36 -0
  39. package/src/passkey/authenticate.ts +183 -0
  40. package/src/passkey/index.ts +27 -0
  41. package/src/passkey/register.ts +232 -0
  42. package/src/passkey/types.ts +120 -0
  43. package/src/rbac.test.ts +141 -0
  44. package/src/rbac.ts +205 -0
  45. package/src/signup.ts +210 -0
  46. package/src/tokens.test.ts +141 -0
  47. package/src/tokens.ts +238 -0
  48. package/src/types.ts +352 -0
@@ -0,0 +1,715 @@
1
+ /**
2
+ * Kysely database adapter for @emdash-cms/auth
3
+ */
4
+
5
+ import type { Kysely, Insertable, Selectable, Updateable } from "kysely";
6
+ import { ulid } from "ulidx";
7
+
8
+ import {
9
+ Role,
10
+ toRoleLevel,
11
+ toDeviceType,
12
+ toTokenType,
13
+ type AuthAdapter,
14
+ type User,
15
+ type NewUser,
16
+ type UpdateUser,
17
+ type Credential,
18
+ type NewCredential,
19
+ type AuthToken,
20
+ type NewAuthToken,
21
+ type TokenType,
22
+ type OAuthAccount,
23
+ type NewOAuthAccount,
24
+ type AllowedDomain,
25
+ type RoleLevel,
26
+ } from "../types.js";
27
+
28
+ // ============================================================================
29
+ // Database schema types
30
+ // ============================================================================
31
+
32
+ export interface AuthTables {
33
+ users: UserTable;
34
+ credentials: CredentialTable;
35
+ auth_tokens: AuthTokenTable;
36
+ oauth_accounts: OAuthAccountTable;
37
+ allowed_domains: AllowedDomainTable;
38
+ }
39
+
40
+ interface UserTable {
41
+ id: string;
42
+ email: string;
43
+ name: string | null;
44
+ avatar_url: string | null;
45
+ role: number;
46
+ email_verified: number;
47
+ disabled: number;
48
+ data: string | null;
49
+ created_at: string;
50
+ updated_at: string;
51
+ }
52
+
53
+ interface CredentialTable {
54
+ id: string;
55
+ user_id: string;
56
+ public_key: Uint8Array;
57
+ counter: number;
58
+ device_type: string;
59
+ backed_up: number;
60
+ transports: string | null;
61
+ name: string | null;
62
+ created_at: string;
63
+ last_used_at: string;
64
+ }
65
+
66
+ interface AuthTokenTable {
67
+ hash: string;
68
+ user_id: string | null;
69
+ email: string | null;
70
+ type: string;
71
+ role: number | null;
72
+ invited_by: string | null;
73
+ expires_at: string;
74
+ created_at: string;
75
+ }
76
+
77
+ interface OAuthAccountTable {
78
+ provider: string;
79
+ provider_account_id: string;
80
+ user_id: string;
81
+ created_at: string;
82
+ }
83
+
84
+ interface AllowedDomainTable {
85
+ domain: string;
86
+ default_role: number;
87
+ enabled: number;
88
+ created_at: string;
89
+ }
90
+
91
+ // ============================================================================
92
+ // Adapter implementation
93
+ // ============================================================================
94
+
95
+ export function createKyselyAdapter<T extends AuthTables>(db: Kysely<T>): AuthAdapter {
96
+ // Type cast to work with generic Kysely instance
97
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- generic Kysely<T extends AuthTables> narrowed to concrete AuthTables for internal queries
98
+ const kdb = db as unknown as Kysely<AuthTables>;
99
+
100
+ return {
101
+ // ========================================================================
102
+ // Users
103
+ // ========================================================================
104
+
105
+ async getUserById(id: string): Promise<User | null> {
106
+ const row = await kdb.selectFrom("users").selectAll().where("id", "=", id).executeTakeFirst();
107
+
108
+ return row ? rowToUser(row) : null;
109
+ },
110
+
111
+ async getUserByEmail(email: string): Promise<User | null> {
112
+ const row = await kdb
113
+ .selectFrom("users")
114
+ .selectAll()
115
+ .where("email", "=", email.toLowerCase())
116
+ .executeTakeFirst();
117
+
118
+ return row ? rowToUser(row) : null;
119
+ },
120
+
121
+ async createUser(user: NewUser): Promise<User> {
122
+ const now = new Date().toISOString();
123
+ const id = ulid();
124
+
125
+ const row: Insertable<UserTable> = {
126
+ id,
127
+ email: user.email.toLowerCase(),
128
+ name: user.name ?? null,
129
+ avatar_url: user.avatarUrl ?? null,
130
+ role: user.role ?? Role.SUBSCRIBER,
131
+ email_verified: user.emailVerified ? 1 : 0,
132
+ disabled: 0,
133
+ data: user.data ? JSON.stringify(user.data) : null,
134
+ created_at: now,
135
+ updated_at: now,
136
+ };
137
+
138
+ await kdb.insertInto("users").values(row).execute();
139
+
140
+ return {
141
+ id,
142
+ email: row.email,
143
+ name: user.name ?? null,
144
+ avatarUrl: user.avatarUrl ?? null,
145
+ role: toRoleLevel(row.role),
146
+ emailVerified: row.email_verified === 1,
147
+ disabled: false,
148
+ data: user.data ?? null,
149
+ createdAt: new Date(now),
150
+ updatedAt: new Date(now),
151
+ };
152
+ },
153
+
154
+ async updateUser(id: string, data: UpdateUser): Promise<void> {
155
+ const update: Updateable<UserTable> = {
156
+ updated_at: new Date().toISOString(),
157
+ };
158
+
159
+ if (data.email !== undefined) update.email = data.email.toLowerCase();
160
+ if (data.name !== undefined) update.name = data.name;
161
+ if (data.avatarUrl !== undefined) update.avatar_url = data.avatarUrl;
162
+ if (data.role !== undefined) update.role = data.role;
163
+ if (data.emailVerified !== undefined) update.email_verified = data.emailVerified ? 1 : 0;
164
+ if (data.disabled !== undefined) update.disabled = data.disabled ? 1 : 0;
165
+ if (data.data !== undefined) update.data = data.data ? JSON.stringify(data.data) : null;
166
+
167
+ await kdb.updateTable("users").set(update).where("id", "=", id).execute();
168
+ },
169
+
170
+ async deleteUser(id: string): Promise<void> {
171
+ await kdb.deleteFrom("users").where("id", "=", id).execute();
172
+ },
173
+
174
+ async countUsers(): Promise<number> {
175
+ const result = await kdb
176
+ .selectFrom("users")
177
+ .select((eb) => eb.fn.countAll<number>().as("count"))
178
+ .executeTakeFirstOrThrow();
179
+
180
+ return result.count;
181
+ },
182
+
183
+ async getUsers(options?: {
184
+ search?: string;
185
+ role?: number;
186
+ cursor?: string;
187
+ limit?: number;
188
+ }): Promise<{
189
+ items: Array<
190
+ User & {
191
+ lastLogin: Date | null;
192
+ credentialCount: number;
193
+ oauthProviders: string[];
194
+ }
195
+ >;
196
+ nextCursor?: string;
197
+ }> {
198
+ const limit = Math.min(options?.limit ?? 20, 100);
199
+
200
+ let query = kdb
201
+ .selectFrom("users")
202
+ .leftJoin("credentials", "users.id", "credentials.user_id")
203
+ .selectAll("users")
204
+ .select((eb) => [
205
+ eb.fn.count<number>("credentials.id").as("credential_count"),
206
+ eb.fn.max("credentials.last_used_at").as("last_login"),
207
+ ])
208
+ .groupBy("users.id")
209
+ .orderBy("users.created_at", "desc")
210
+ .limit(limit + 1);
211
+
212
+ // Apply filters
213
+ if (options?.search) {
214
+ const searchPattern = `%${options.search}%`;
215
+ query = query.where((eb) =>
216
+ eb.or([
217
+ eb("users.email", "like", searchPattern),
218
+ eb("users.name", "like", searchPattern),
219
+ ]),
220
+ );
221
+ }
222
+
223
+ if (options?.role !== undefined) {
224
+ query = query.where("users.role", "=", options.role);
225
+ }
226
+
227
+ if (options?.cursor) {
228
+ // Get the cursor user's created_at for pagination
229
+ const cursorUser = await kdb
230
+ .selectFrom("users")
231
+ .select("created_at")
232
+ .where("id", "=", options.cursor)
233
+ .executeTakeFirst();
234
+
235
+ if (cursorUser) {
236
+ query = query.where("users.created_at", "<", cursorUser.created_at);
237
+ }
238
+ }
239
+
240
+ const rows = await query.execute();
241
+
242
+ // Get OAuth providers for all users in this batch
243
+ const userIds = rows.slice(0, limit).map((r) => r.id);
244
+ const oauthAccounts =
245
+ userIds.length > 0
246
+ ? await kdb
247
+ .selectFrom("oauth_accounts")
248
+ .select(["user_id", "provider"])
249
+ .where("user_id", "in", userIds)
250
+ .execute()
251
+ : [];
252
+
253
+ // Group OAuth providers by user
254
+ const oauthByUser = new Map<string, string[]>();
255
+ for (const account of oauthAccounts) {
256
+ const providers = oauthByUser.get(account.user_id) ?? [];
257
+ providers.push(account.provider);
258
+ oauthByUser.set(account.user_id, providers);
259
+ }
260
+
261
+ const hasMore = rows.length > limit;
262
+ const items = rows.slice(0, limit).map((row) => ({
263
+ id: row.id,
264
+ email: row.email,
265
+ name: row.name,
266
+ avatarUrl: row.avatar_url,
267
+ role: toRoleLevel(row.role),
268
+ emailVerified: row.email_verified === 1,
269
+ disabled: row.disabled === 1,
270
+ data: row.data ? JSON.parse(row.data) : null,
271
+ createdAt: new Date(row.created_at),
272
+ updatedAt: new Date(row.updated_at),
273
+ lastLogin: row.last_login ? new Date(row.last_login) : null,
274
+ credentialCount: row.credential_count ?? 0,
275
+ oauthProviders: oauthByUser.get(row.id) ?? [],
276
+ }));
277
+
278
+ return {
279
+ items,
280
+ nextCursor: hasMore ? items.at(-1)?.id : undefined,
281
+ };
282
+ },
283
+
284
+ async getUserWithDetails(id: string): Promise<{
285
+ user: User;
286
+ credentials: Credential[];
287
+ oauthAccounts: OAuthAccount[];
288
+ lastLogin: Date | null;
289
+ } | null> {
290
+ const user = await kdb
291
+ .selectFrom("users")
292
+ .selectAll()
293
+ .where("id", "=", id)
294
+ .executeTakeFirst();
295
+
296
+ if (!user) return null;
297
+
298
+ const [credentials, oauthAccounts] = await Promise.all([
299
+ kdb
300
+ .selectFrom("credentials")
301
+ .selectAll()
302
+ .where("user_id", "=", id)
303
+ .orderBy("created_at", "desc")
304
+ .execute(),
305
+ kdb.selectFrom("oauth_accounts").selectAll().where("user_id", "=", id).execute(),
306
+ ]);
307
+
308
+ // Find last login from most recent credential use
309
+ const lastLogin = credentials.reduce<Date | null>((latest, cred) => {
310
+ const lastUsed = new Date(cred.last_used_at);
311
+ return !latest || lastUsed > latest ? lastUsed : latest;
312
+ }, null);
313
+
314
+ return {
315
+ user: rowToUser(user),
316
+ credentials: credentials.map(rowToCredential),
317
+ oauthAccounts: oauthAccounts.map(rowToOAuthAccount),
318
+ lastLogin,
319
+ };
320
+ },
321
+
322
+ async countAdmins(): Promise<number> {
323
+ const result = await kdb
324
+ .selectFrom("users")
325
+ .select((eb) => eb.fn.countAll<number>().as("count"))
326
+ .where("role", "=", Role.ADMIN)
327
+ .where("disabled", "=", 0)
328
+ .executeTakeFirstOrThrow();
329
+
330
+ return result.count;
331
+ },
332
+
333
+ // ========================================================================
334
+ // Credentials
335
+ // ========================================================================
336
+
337
+ async getCredentialById(id: string): Promise<Credential | null> {
338
+ const row = await kdb
339
+ .selectFrom("credentials")
340
+ .selectAll()
341
+ .where("id", "=", id)
342
+ .executeTakeFirst();
343
+
344
+ return row ? rowToCredential(row) : null;
345
+ },
346
+
347
+ async getCredentialsByUserId(userId: string): Promise<Credential[]> {
348
+ const rows = await kdb
349
+ .selectFrom("credentials")
350
+ .selectAll()
351
+ .where("user_id", "=", userId)
352
+ .execute();
353
+
354
+ return rows.map(rowToCredential);
355
+ },
356
+
357
+ async createCredential(credential: NewCredential): Promise<Credential> {
358
+ const now = new Date().toISOString();
359
+
360
+ const row: Insertable<CredentialTable> = {
361
+ id: credential.id,
362
+ user_id: credential.userId,
363
+ public_key: credential.publicKey,
364
+ counter: credential.counter,
365
+ device_type: credential.deviceType,
366
+ backed_up: credential.backedUp ? 1 : 0,
367
+ transports: credential.transports.length > 0 ? JSON.stringify(credential.transports) : null,
368
+ name: credential.name ?? null,
369
+ created_at: now,
370
+ last_used_at: now,
371
+ };
372
+
373
+ await kdb.insertInto("credentials").values(row).execute();
374
+
375
+ return {
376
+ id: credential.id,
377
+ userId: credential.userId,
378
+ publicKey: credential.publicKey,
379
+ counter: credential.counter,
380
+ deviceType: credential.deviceType,
381
+ backedUp: credential.backedUp,
382
+ transports: credential.transports,
383
+ name: credential.name ?? null,
384
+ createdAt: new Date(now),
385
+ lastUsedAt: new Date(now),
386
+ };
387
+ },
388
+
389
+ async updateCredentialCounter(id: string, counter: number): Promise<void> {
390
+ await kdb
391
+ .updateTable("credentials")
392
+ .set({
393
+ counter,
394
+ last_used_at: new Date().toISOString(),
395
+ })
396
+ .where("id", "=", id)
397
+ .execute();
398
+ },
399
+
400
+ async updateCredentialName(id: string, name: string | null): Promise<void> {
401
+ await kdb.updateTable("credentials").set({ name }).where("id", "=", id).execute();
402
+ },
403
+
404
+ async deleteCredential(id: string): Promise<void> {
405
+ await kdb.deleteFrom("credentials").where("id", "=", id).execute();
406
+ },
407
+
408
+ async countCredentialsByUserId(userId: string): Promise<number> {
409
+ const result = await kdb
410
+ .selectFrom("credentials")
411
+ .select((eb) => eb.fn.countAll<number>().as("count"))
412
+ .where("user_id", "=", userId)
413
+ .executeTakeFirstOrThrow();
414
+
415
+ return result.count;
416
+ },
417
+
418
+ // ========================================================================
419
+ // Auth Tokens
420
+ // ========================================================================
421
+
422
+ async createToken(token: NewAuthToken): Promise<void> {
423
+ const row: Insertable<AuthTokenTable> = {
424
+ hash: token.hash,
425
+ user_id: token.userId ?? null,
426
+ email: token.email ?? null,
427
+ type: token.type,
428
+ role: token.role ?? null,
429
+ invited_by: token.invitedBy ?? null,
430
+ expires_at: token.expiresAt.toISOString(),
431
+ created_at: new Date().toISOString(),
432
+ };
433
+
434
+ await kdb.insertInto("auth_tokens").values(row).execute();
435
+ },
436
+
437
+ async getToken(hash: string, type: TokenType): Promise<AuthToken | null> {
438
+ const row = await kdb
439
+ .selectFrom("auth_tokens")
440
+ .selectAll()
441
+ .where("hash", "=", hash)
442
+ .where("type", "=", type)
443
+ .executeTakeFirst();
444
+
445
+ return row ? rowToAuthToken(row) : null;
446
+ },
447
+
448
+ async deleteToken(hash: string): Promise<void> {
449
+ await kdb.deleteFrom("auth_tokens").where("hash", "=", hash).execute();
450
+ },
451
+
452
+ async deleteExpiredTokens(): Promise<void> {
453
+ await kdb
454
+ .deleteFrom("auth_tokens")
455
+ .where("expires_at", "<", new Date().toISOString())
456
+ .execute();
457
+ },
458
+
459
+ // ========================================================================
460
+ // OAuth Accounts
461
+ // ========================================================================
462
+
463
+ async getOAuthAccount(
464
+ provider: string,
465
+ providerAccountId: string,
466
+ ): Promise<OAuthAccount | null> {
467
+ const row = await kdb
468
+ .selectFrom("oauth_accounts")
469
+ .selectAll()
470
+ .where("provider", "=", provider)
471
+ .where("provider_account_id", "=", providerAccountId)
472
+ .executeTakeFirst();
473
+
474
+ return row ? rowToOAuthAccount(row) : null;
475
+ },
476
+
477
+ async getOAuthAccountsByUserId(userId: string): Promise<OAuthAccount[]> {
478
+ const rows = await kdb
479
+ .selectFrom("oauth_accounts")
480
+ .selectAll()
481
+ .where("user_id", "=", userId)
482
+ .execute();
483
+
484
+ return rows.map(rowToOAuthAccount);
485
+ },
486
+
487
+ async createOAuthAccount(account: NewOAuthAccount): Promise<OAuthAccount> {
488
+ const now = new Date().toISOString();
489
+
490
+ const row: Insertable<OAuthAccountTable> = {
491
+ provider: account.provider,
492
+ provider_account_id: account.providerAccountId,
493
+ user_id: account.userId,
494
+ created_at: now,
495
+ };
496
+
497
+ await kdb.insertInto("oauth_accounts").values(row).execute();
498
+
499
+ return {
500
+ provider: account.provider,
501
+ providerAccountId: account.providerAccountId,
502
+ userId: account.userId,
503
+ createdAt: new Date(now),
504
+ };
505
+ },
506
+
507
+ async deleteOAuthAccount(provider: string, providerAccountId: string): Promise<void> {
508
+ await kdb
509
+ .deleteFrom("oauth_accounts")
510
+ .where("provider", "=", provider)
511
+ .where("provider_account_id", "=", providerAccountId)
512
+ .execute();
513
+ },
514
+
515
+ // ========================================================================
516
+ // Allowed Domains
517
+ // ========================================================================
518
+
519
+ async getAllowedDomain(domain: string): Promise<AllowedDomain | null> {
520
+ const row = await kdb
521
+ .selectFrom("allowed_domains")
522
+ .selectAll()
523
+ .where("domain", "=", domain.toLowerCase())
524
+ .executeTakeFirst();
525
+
526
+ return row ? rowToAllowedDomain(row) : null;
527
+ },
528
+
529
+ async getAllowedDomains(): Promise<AllowedDomain[]> {
530
+ const rows = await kdb.selectFrom("allowed_domains").selectAll().execute();
531
+
532
+ return rows.map(rowToAllowedDomain);
533
+ },
534
+
535
+ async createAllowedDomain(domain: string, defaultRole: RoleLevel): Promise<AllowedDomain> {
536
+ const now = new Date().toISOString();
537
+
538
+ const row: Insertable<AllowedDomainTable> = {
539
+ domain: domain.toLowerCase(),
540
+ default_role: defaultRole,
541
+ enabled: 1,
542
+ created_at: now,
543
+ };
544
+
545
+ await kdb.insertInto("allowed_domains").values(row).execute();
546
+
547
+ return {
548
+ domain: row.domain,
549
+ defaultRole,
550
+ enabled: true,
551
+ createdAt: new Date(now),
552
+ };
553
+ },
554
+
555
+ async updateAllowedDomain(
556
+ domain: string,
557
+ enabled: boolean,
558
+ defaultRole?: RoleLevel,
559
+ ): Promise<void> {
560
+ const update: Updateable<AllowedDomainTable> = {
561
+ enabled: enabled ? 1 : 0,
562
+ };
563
+
564
+ if (defaultRole !== undefined) {
565
+ update.default_role = defaultRole;
566
+ }
567
+
568
+ await kdb
569
+ .updateTable("allowed_domains")
570
+ .set(update)
571
+ .where("domain", "=", domain.toLowerCase())
572
+ .execute();
573
+ },
574
+
575
+ async deleteAllowedDomain(domain: string): Promise<void> {
576
+ await kdb.deleteFrom("allowed_domains").where("domain", "=", domain.toLowerCase()).execute();
577
+ },
578
+ };
579
+ }
580
+
581
+ // ============================================================================
582
+ // Row converters
583
+ // ============================================================================
584
+
585
+ function rowToUser(row: Selectable<UserTable>): User {
586
+ return {
587
+ id: row.id,
588
+ email: row.email,
589
+ name: row.name,
590
+ avatarUrl: row.avatar_url,
591
+ role: toRoleLevel(row.role),
592
+ emailVerified: row.email_verified === 1,
593
+ disabled: row.disabled === 1,
594
+ data: row.data ? JSON.parse(row.data) : null,
595
+ createdAt: new Date(row.created_at),
596
+ updatedAt: new Date(row.updated_at),
597
+ };
598
+ }
599
+
600
+ function rowToCredential(row: Selectable<CredentialTable>): Credential {
601
+ return {
602
+ id: row.id,
603
+ userId: row.user_id,
604
+ publicKey: row.public_key,
605
+ counter: row.counter,
606
+ deviceType: toDeviceType(row.device_type),
607
+ backedUp: row.backed_up === 1,
608
+ transports: row.transports ? JSON.parse(row.transports) : [],
609
+ name: row.name,
610
+ createdAt: new Date(row.created_at),
611
+ lastUsedAt: new Date(row.last_used_at),
612
+ };
613
+ }
614
+
615
+ function rowToAuthToken(row: Selectable<AuthTokenTable>): AuthToken {
616
+ return {
617
+ hash: row.hash,
618
+ userId: row.user_id,
619
+ email: row.email,
620
+ type: toTokenType(row.type),
621
+ role: row.role != null ? toRoleLevel(row.role) : null,
622
+ invitedBy: row.invited_by,
623
+ expiresAt: new Date(row.expires_at),
624
+ createdAt: new Date(row.created_at),
625
+ };
626
+ }
627
+
628
+ function rowToOAuthAccount(row: Selectable<OAuthAccountTable>): OAuthAccount {
629
+ return {
630
+ provider: row.provider,
631
+ providerAccountId: row.provider_account_id,
632
+ userId: row.user_id,
633
+ createdAt: new Date(row.created_at),
634
+ };
635
+ }
636
+
637
+ function rowToAllowedDomain(row: Selectable<AllowedDomainTable>): AllowedDomain {
638
+ return {
639
+ domain: row.domain,
640
+ defaultRole: toRoleLevel(row.default_role),
641
+ enabled: row.enabled === 1,
642
+ createdAt: new Date(row.created_at),
643
+ };
644
+ }
645
+
646
+ // ============================================================================
647
+ // Migration SQL
648
+ // ============================================================================
649
+
650
+ export const AUTH_TABLES_SQL = `
651
+ -- Users (no password_hash)
652
+ CREATE TABLE IF NOT EXISTS users (
653
+ id TEXT PRIMARY KEY,
654
+ email TEXT UNIQUE NOT NULL,
655
+ name TEXT,
656
+ avatar_url TEXT,
657
+ role INTEGER NOT NULL DEFAULT 10,
658
+ email_verified INTEGER NOT NULL DEFAULT 0,
659
+ disabled INTEGER NOT NULL DEFAULT 0,
660
+ data TEXT,
661
+ created_at TEXT NOT NULL,
662
+ updated_at TEXT NOT NULL
663
+ );
664
+
665
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
666
+
667
+ -- Passkey credentials
668
+ CREATE TABLE IF NOT EXISTS credentials (
669
+ id TEXT PRIMARY KEY,
670
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
671
+ public_key BLOB NOT NULL,
672
+ counter INTEGER NOT NULL DEFAULT 0,
673
+ device_type TEXT NOT NULL,
674
+ backed_up INTEGER NOT NULL DEFAULT 0,
675
+ transports TEXT,
676
+ name TEXT,
677
+ created_at TEXT NOT NULL,
678
+ last_used_at TEXT NOT NULL
679
+ );
680
+
681
+ CREATE INDEX IF NOT EXISTS idx_credentials_user ON credentials(user_id);
682
+
683
+ -- Auth tokens (magic links, email verification, invites)
684
+ CREATE TABLE IF NOT EXISTS auth_tokens (
685
+ hash TEXT PRIMARY KEY,
686
+ user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
687
+ email TEXT,
688
+ type TEXT NOT NULL,
689
+ role INTEGER,
690
+ invited_by TEXT REFERENCES users(id),
691
+ expires_at TEXT NOT NULL,
692
+ created_at TEXT NOT NULL
693
+ );
694
+
695
+ CREATE INDEX IF NOT EXISTS idx_auth_tokens_email ON auth_tokens(email);
696
+
697
+ -- OAuth accounts (external provider links)
698
+ CREATE TABLE IF NOT EXISTS oauth_accounts (
699
+ provider TEXT NOT NULL,
700
+ provider_account_id TEXT NOT NULL,
701
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
702
+ created_at TEXT NOT NULL,
703
+ PRIMARY KEY (provider, provider_account_id)
704
+ );
705
+
706
+ CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id);
707
+
708
+ -- Allowed domains for self-signup
709
+ CREATE TABLE IF NOT EXISTS allowed_domains (
710
+ domain TEXT PRIMARY KEY,
711
+ default_role INTEGER NOT NULL DEFAULT 20,
712
+ enabled INTEGER NOT NULL DEFAULT 1,
713
+ created_at TEXT NOT NULL
714
+ );
715
+ `;