@xcelsior/auth-adapter-knex 1.0.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/src/knex.ts ADDED
@@ -0,0 +1,351 @@
1
+ import type { Knex } from 'knex';
2
+ import type {
3
+ IStorageProvider,
4
+ User,
5
+ Session,
6
+ UserFilter,
7
+ FindUsersOptions,
8
+ FindUsersResult,
9
+ } from '@xcelsior/auth';
10
+
11
+ export interface KnexConfig {
12
+ /** Pre-configured Knex instance */
13
+ knex: Knex;
14
+ /** Table name for users */
15
+ tableName: string;
16
+ /** Table name for sessions */
17
+ sessionsTableName: string;
18
+ /** Optional schema (for PostgreSQL) */
19
+ schema?: string;
20
+ }
21
+
22
+ export class KnexStorageProvider implements IStorageProvider {
23
+ private knex: Knex;
24
+ private tableName: string;
25
+ private sessionsTableName: string;
26
+ private schema?: string;
27
+
28
+ constructor(config: KnexConfig) {
29
+ this.knex = config.knex;
30
+ this.tableName = config.tableName;
31
+ this.sessionsTableName = config.sessionsTableName;
32
+ this.schema = config.schema;
33
+ }
34
+
35
+ private get table() {
36
+ const query = this.knex(this.tableName);
37
+ if (this.schema) {
38
+ return query.withSchema(this.schema);
39
+ }
40
+ return query;
41
+ }
42
+
43
+ private get sessionsTable() {
44
+ const query = this.knex(this.sessionsTableName);
45
+ if (this.schema) {
46
+ return query.withSchema(this.schema);
47
+ }
48
+ return query;
49
+ }
50
+
51
+ // ==================== User Methods ====================
52
+
53
+ async createUser(user: User): Promise<void> {
54
+ await this.table.insert(this.toDbUser(user));
55
+ }
56
+
57
+ async getUserById(id: string): Promise<User | null> {
58
+ const row = await this.table.where({ id }).first();
59
+ return row ? this.fromDbUser(row) : null;
60
+ }
61
+
62
+ async getUserByEmail(email: string): Promise<User | null> {
63
+ const row = await this.table.where({ email }).first();
64
+ return row ? this.fromDbUser(row) : null;
65
+ }
66
+
67
+ async getUserByResetPasswordToken(resetPasswordToken: string): Promise<User | null> {
68
+ const row = await this.table.where({ reset_password_token: resetPasswordToken }).first();
69
+ return row ? this.fromDbUser(row) : null;
70
+ }
71
+
72
+ async getUserByVerifyEmailToken(verifyEmailToken: string): Promise<User | null> {
73
+ const row = await this.table.where({ verification_token: verifyEmailToken }).first();
74
+ return row ? this.fromDbUser(row) : null;
75
+ }
76
+
77
+ async updateUser(id: string, updates: Partial<User>): Promise<void> {
78
+ const dbUpdates = this.toDbUpdates(updates);
79
+
80
+ if (Object.keys(dbUpdates).length === 0) {
81
+ return;
82
+ }
83
+
84
+ await this.table.where({ id }).update(dbUpdates);
85
+ }
86
+
87
+ async deleteUser(id: string): Promise<void> {
88
+ // Delete all user sessions first
89
+ await this.deleteAllUserSessions(id);
90
+ // Then delete the user
91
+ await this.table.where({ id }).delete();
92
+ }
93
+
94
+ async findUsers(filter: UserFilter, options: FindUsersOptions = {}): Promise<FindUsersResult> {
95
+ const limit = options.limit ?? 50;
96
+
97
+ let query = this.table.clone();
98
+
99
+ // Email exact match
100
+ if (filter.email) {
101
+ query = query.where('email', filter.email);
102
+ }
103
+
104
+ // Email contains (partial match)
105
+ if (filter.emailContains) {
106
+ query = query.where('email', 'like', `%${filter.emailContains}%`);
107
+ }
108
+
109
+ // Email verification status
110
+ if (filter.isEmailVerified !== undefined) {
111
+ query = query.where('is_email_verified', filter.isEmailVerified);
112
+ }
113
+
114
+ // Roles filtering - user must have ALL specified roles
115
+ if (filter.roles && filter.roles.length > 0) {
116
+ for (const role of filter.roles) {
117
+ // Use JSON contains - works for PostgreSQL (jsonb) and MySQL (json)
118
+ // For SQLite, we fall back to LIKE on the JSON string
119
+ query = query.whereRaw(
120
+ this.knex.client.config.client === 'sqlite3' ? `roles LIKE ?` : `roles @> ?`,
121
+ this.knex.client.config.client === 'sqlite3'
122
+ ? [`%"${role}"%`]
123
+ : [JSON.stringify([role])]
124
+ );
125
+ }
126
+ }
127
+
128
+ // HasAnyRole - user must have at least ONE of specified roles
129
+ if (filter.hasAnyRole && filter.hasAnyRole.length > 0) {
130
+ query = query.where((builder: Knex.QueryBuilder) => {
131
+ for (const role of filter.hasAnyRole!) {
132
+ builder.orWhereRaw(
133
+ this.knex.client.config.client === 'sqlite3'
134
+ ? `roles LIKE ?`
135
+ : `roles @> ?`,
136
+ this.knex.client.config.client === 'sqlite3'
137
+ ? [`%"${role}"%`]
138
+ : [JSON.stringify([role])]
139
+ );
140
+ }
141
+ });
142
+ }
143
+
144
+ // Apply pagination
145
+ if (options.cursor) {
146
+ const cursorData = JSON.parse(Buffer.from(options.cursor, 'base64').toString());
147
+ query = query.where('id', '>', cursorData.lastId);
148
+ }
149
+
150
+ // Order by id for consistent pagination
151
+ query = query.orderBy('id', 'asc').limit(limit + 1);
152
+
153
+ const rows = await query;
154
+ const hasMore = rows.length > limit;
155
+ const resultRows = hasMore ? rows.slice(0, limit) : rows;
156
+ const users = resultRows.map((row: Record<string, unknown>) => this.fromDbUser(row));
157
+
158
+ let nextCursor: string | undefined;
159
+ if (hasMore && resultRows.length > 0) {
160
+ const lastUser = resultRows[resultRows.length - 1];
161
+ nextCursor = Buffer.from(JSON.stringify({ lastId: lastUser.id })).toString('base64');
162
+ }
163
+
164
+ return { users, nextCursor };
165
+ }
166
+
167
+ // ==================== Session Methods ====================
168
+
169
+ async createSession(session: Session): Promise<void> {
170
+ await this.sessionsTable.insert(this.toDbSession(session));
171
+ }
172
+
173
+ async getSessionById(id: string): Promise<Session | null> {
174
+ const row = await this.sessionsTable.where({ id }).first();
175
+ return row ? this.fromDbSession(row) : null;
176
+ }
177
+
178
+ async getSessionsByUserId(userId: string): Promise<Session[]> {
179
+ const rows = await this.sessionsTable.where({ user_id: userId });
180
+ return rows.map((row: Record<string, unknown>) => this.fromDbSession(row));
181
+ }
182
+
183
+ async updateSession(id: string, updates: Partial<Session>): Promise<void> {
184
+ const dbUpdates = this.toDbSessionUpdates(updates);
185
+
186
+ if (Object.keys(dbUpdates).length === 0) {
187
+ return;
188
+ }
189
+
190
+ await this.sessionsTable.where({ id }).update(dbUpdates);
191
+ }
192
+
193
+ async deleteSession(id: string): Promise<void> {
194
+ await this.sessionsTable.where({ id }).delete();
195
+ }
196
+
197
+ async deleteAllUserSessions(userId: string): Promise<void> {
198
+ await this.sessionsTable.where({ user_id: userId }).delete();
199
+ }
200
+
201
+ async deleteExpiredSessions(): Promise<void> {
202
+ const now = Date.now();
203
+ await this.sessionsTable.where('expires_at', '<', now).delete();
204
+ }
205
+
206
+ // ==================== User Mapping Helpers ====================
207
+
208
+ /**
209
+ * Convert User object to database row format (snake_case)
210
+ */
211
+ private toDbUser(user: User): Record<string, unknown> {
212
+ return {
213
+ id: user.id,
214
+ email: user.email,
215
+ first_name: user.firstName ?? null,
216
+ last_name: user.lastName ?? null,
217
+ password_hash: user.passwordHash,
218
+ roles: JSON.stringify(user.roles),
219
+ is_email_verified: user.isEmailVerified,
220
+ verification_token: user.verificationToken ?? null,
221
+ reset_password_token: user.resetPasswordToken ?? null,
222
+ reset_password_expires: user.resetPasswordExpires ?? null,
223
+ created_at: user.createdAt,
224
+ updated_at: user.updatedAt,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Convert database row to User object (camelCase)
230
+ */
231
+ private fromDbUser(row: Record<string, unknown>): User {
232
+ return {
233
+ id: row.id as string,
234
+ email: row.email as string,
235
+ firstName: row.first_name as string | undefined,
236
+ lastName: row.last_name as string | undefined,
237
+ passwordHash: row.password_hash as string,
238
+ roles: typeof row.roles === 'string' ? JSON.parse(row.roles) : row.roles,
239
+ isEmailVerified: Boolean(row.is_email_verified),
240
+ verificationToken: row.verification_token as string | undefined,
241
+ resetPasswordToken: row.reset_password_token as string | undefined,
242
+ resetPasswordExpires: row.reset_password_expires as number | undefined,
243
+ createdAt: row.created_at as number,
244
+ updatedAt: row.updated_at as number,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Convert partial User updates to database format
250
+ */
251
+ private toDbUpdates(updates: Partial<User>): Record<string, unknown> {
252
+ const dbUpdates: Record<string, unknown> = {};
253
+
254
+ if (updates.email !== undefined) {
255
+ dbUpdates.email = updates.email;
256
+ }
257
+ if ('firstName' in updates) {
258
+ dbUpdates.first_name = updates.firstName ?? null;
259
+ }
260
+ if ('lastName' in updates) {
261
+ dbUpdates.last_name = updates.lastName ?? null;
262
+ }
263
+ if (updates.passwordHash !== undefined) {
264
+ dbUpdates.password_hash = updates.passwordHash;
265
+ }
266
+ if (updates.roles !== undefined) {
267
+ dbUpdates.roles = JSON.stringify(updates.roles);
268
+ }
269
+ if (updates.isEmailVerified !== undefined) {
270
+ dbUpdates.is_email_verified = updates.isEmailVerified;
271
+ }
272
+ if ('verificationToken' in updates) {
273
+ dbUpdates.verification_token = updates.verificationToken ?? null;
274
+ }
275
+ if ('resetPasswordToken' in updates) {
276
+ dbUpdates.reset_password_token = updates.resetPasswordToken ?? null;
277
+ }
278
+ if ('resetPasswordExpires' in updates) {
279
+ dbUpdates.reset_password_expires = updates.resetPasswordExpires ?? null;
280
+ }
281
+ if (updates.updatedAt !== undefined) {
282
+ dbUpdates.updated_at = updates.updatedAt;
283
+ }
284
+
285
+ return dbUpdates;
286
+ }
287
+
288
+ // ==================== Session Mapping Helpers ====================
289
+
290
+ /**
291
+ * Convert Session object to database row format (snake_case)
292
+ */
293
+ private toDbSession(session: Session): Record<string, unknown> {
294
+ return {
295
+ id: session.id,
296
+ user_id: session.userId,
297
+ refresh_token_hash: session.refreshTokenHash,
298
+ user_agent: session.userAgent ?? null,
299
+ ip_address: session.ipAddress ?? null,
300
+ device_name: session.deviceName ?? null,
301
+ created_at: session.createdAt,
302
+ last_used_at: session.lastUsedAt,
303
+ expires_at: session.expiresAt,
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Convert database row to Session object (camelCase)
309
+ */
310
+ private fromDbSession(row: Record<string, unknown>): Session {
311
+ return {
312
+ id: row.id as string,
313
+ userId: row.user_id as string,
314
+ refreshTokenHash: row.refresh_token_hash as string,
315
+ userAgent: row.user_agent as string | undefined,
316
+ ipAddress: row.ip_address as string | undefined,
317
+ deviceName: row.device_name as string | undefined,
318
+ createdAt: row.created_at as number,
319
+ lastUsedAt: row.last_used_at as number,
320
+ expiresAt: row.expires_at as number,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Convert partial Session updates to database format
326
+ */
327
+ private toDbSessionUpdates(updates: Partial<Session>): Record<string, unknown> {
328
+ const dbUpdates: Record<string, unknown> = {};
329
+
330
+ if (updates.refreshTokenHash !== undefined) {
331
+ dbUpdates.refresh_token_hash = updates.refreshTokenHash;
332
+ }
333
+ if ('userAgent' in updates) {
334
+ dbUpdates.user_agent = updates.userAgent ?? null;
335
+ }
336
+ if ('ipAddress' in updates) {
337
+ dbUpdates.ip_address = updates.ipAddress ?? null;
338
+ }
339
+ if ('deviceName' in updates) {
340
+ dbUpdates.device_name = updates.deviceName ?? null;
341
+ }
342
+ if (updates.lastUsedAt !== undefined) {
343
+ dbUpdates.last_used_at = updates.lastUsedAt;
344
+ }
345
+ if (updates.expiresAt !== undefined) {
346
+ dbUpdates.expires_at = updates.expiresAt;
347
+ }
348
+
349
+ return dbUpdates;
350
+ }
351
+ }
@@ -0,0 +1,78 @@
1
+ import type { Knex } from 'knex';
2
+
3
+ /**
4
+ * SQL migration helper to create the users table
5
+ * Can be used with Knex migrations
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * // In your migration file
10
+ * import { createUsersTableMigration, createSessionsTableMigration } from '@xcelsior/auth-adapter-knex';
11
+ *
12
+ * export async function up(knex: Knex): Promise<void> {
13
+ * await createUsersTableMigration(knex, 'users');
14
+ * await createSessionsTableMigration(knex, 'sessions');
15
+ * }
16
+ *
17
+ * export async function down(knex: Knex): Promise<void> {
18
+ * await knex.schema.dropTableIfExists('sessions');
19
+ * await knex.schema.dropTableIfExists('users');
20
+ * }
21
+ * ```
22
+ */
23
+ export async function createUsersTableMigration(
24
+ knex: Knex,
25
+ tableName: string,
26
+ schema?: string
27
+ ): Promise<void> {
28
+ const schemaBuilder = schema ? knex.schema.withSchema(schema) : knex.schema;
29
+
30
+ await schemaBuilder.createTable(tableName, (table) => {
31
+ table.string('id').primary();
32
+ table.string('email').notNullable().unique();
33
+ table.string('first_name').nullable();
34
+ table.string('last_name').nullable();
35
+ table.string('password_hash').notNullable();
36
+ table.jsonb('roles').notNullable();
37
+ table.boolean('is_email_verified').notNullable().defaultTo(false);
38
+ table.string('verification_token').nullable();
39
+ table.string('reset_password_token').nullable();
40
+ table.bigInteger('reset_password_expires').nullable();
41
+ table.bigInteger('created_at').notNullable();
42
+ table.bigInteger('updated_at').notNullable();
43
+
44
+ // Indexes for common lookups
45
+ table.index('email');
46
+ table.index('verification_token');
47
+ table.index('reset_password_token');
48
+ });
49
+ }
50
+
51
+ /**
52
+ * SQL migration helper to create the sessions table
53
+ * Can be used with Knex migrations
54
+ */
55
+ export async function createSessionsTableMigration(
56
+ knex: Knex,
57
+ tableName: string,
58
+ schema?: string
59
+ ): Promise<void> {
60
+ const schemaBuilder = schema ? knex.schema.withSchema(schema) : knex.schema;
61
+
62
+ await schemaBuilder.createTable(tableName, (table) => {
63
+ table.string('id').primary();
64
+ table.string('user_id').notNullable();
65
+ table.string('refresh_token_hash').notNullable();
66
+ table.string('user_agent').nullable();
67
+ table.string('ip_address').nullable();
68
+ table.string('device_name').nullable();
69
+ table.bigInteger('created_at').notNullable();
70
+ table.bigInteger('last_used_at').notNullable();
71
+ table.bigInteger('expires_at').notNullable();
72
+
73
+ // Index for querying sessions by user
74
+ table.index('user_id');
75
+ // Index for cleaning up expired sessions
76
+ table.index('expires_at');
77
+ });
78
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['cjs', 'esm'],
6
+ dts: true,
7
+ splitting: false,
8
+ sourcemap: true,
9
+ clean: true,
10
+ });