sentri 2.0.0 → 2.1.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/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Dialect } from 'kysely';
1
2
  import { Request, ErrorRequestHandler, RequestHandler, Router } from 'express';
2
3
 
3
4
  /**
@@ -20,11 +21,11 @@ import { Request, ErrorRequestHandler, RequestHandler, Router } from 'express';
20
21
  type SentriErrorCode = 'INVALID_CREDENTIALS' | 'USER_NOT_FOUND' | 'USER_ALREADY_EXISTS' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'FORBIDDEN' | 'UNAUTHORIZED' | 'INVALID_ROLE' | 'VALIDATION_ERROR' | 'CONFIGURATION_ERROR';
21
22
  /**
22
23
  * Default HTTP status codes for built-in error codes.
23
- * Custom codes that are not in this map default to 500.
24
+ * Custom codes not in this map default to 500.
24
25
  *
25
26
  * @internal
26
27
  */
27
- declare const AUTH_ERROR_STATUS: Record<string, number>;
28
+ declare const SENTRI_ERROR_STATUS: Record<string, number>;
28
29
  /**
29
30
  * Base error class for all authentication and authorization failures in sentri.
30
31
  *
@@ -49,14 +50,12 @@ declare const AUTH_ERROR_STATUS: Record<string, number>;
49
50
  * ```typescript
50
51
  * import { SentriError } from 'sentri';
51
52
  *
52
- * // Domain error with a custom code and explicit HTTP status
53
53
  * export class PaymentError extends SentriError {
54
54
  * constructor(message: string) {
55
55
  * super('PAYMENT_FAILED', message, 402);
56
56
  * }
57
57
  * }
58
58
  *
59
- * // Throw it anywhere in your routes — auth.errorHandler() catches it
60
59
  * router.post('/checkout', auth.protect(), async (req, res) => {
61
60
  * const ok = await chargeCard(req.body.cardToken);
62
61
  * if (!ok) throw new PaymentError('Card declined');
@@ -89,528 +88,20 @@ declare class SentriError extends Error {
89
88
  * subclassing with a custom `code`.
90
89
  */
91
90
  readonly statusCode: number;
92
- /**
93
- * @param code - Machine-readable error code. Use a built-in {@link SentriErrorCode}
94
- * or any string for custom subclasses.
95
- * @param message - Human-readable description of the error.
96
- * @param statusCode - HTTP status to use in the response. For built-in codes
97
- * this is derived automatically; for custom codes it defaults to `500`.
98
- */
99
91
  constructor(code: SentriErrorCode | (string & {}), message: string, statusCode?: number);
100
92
  }
101
93
 
102
- /** Standard API response envelope returned by all built-in router endpoints. */
103
94
  interface ApiResponse<T = null> {
104
95
  error: boolean;
105
96
  statusCode: number;
106
97
  message: string;
107
98
  data: T | null;
108
99
  }
109
- /** Shape of a user row returned by the adapter — used internally by the library. */
110
- interface UserRecord {
111
- id: string;
112
- /**
113
- * The credential identifier for this user (email, username, phone number, etc.).
114
- * The adapter decides which column(s) this maps to.
115
- */
116
- identifier: string;
117
- passwordHash: string;
118
- /** Role names currently assigned to the user. */
119
- roles: string[];
120
- }
121
- /** Shape of a session row returned by the adapter. */
122
- interface SessionRecord {
123
- id: string;
124
- userId: string;
125
- expiresAt: Date;
126
- createdAt: Date;
127
- }
128
- /** Data the library passes to the adapter when creating a new user. */
129
- interface CreateUserData {
130
- /**
131
- * The credential identifier supplied at registration (email, username, phone, etc.).
132
- * Store this in whichever column(s) your schema uses for login lookup.
133
- */
134
- identifier: string;
135
- passwordHash: string;
136
- /** Validated role names to assign at creation. */
137
- roles: string[];
138
- }
139
- /**
140
- * The database adapter interface the library depends on.
141
- *
142
- * Implement this to connect the library to any ORM or data layer.
143
- *
144
- * The library uses a single `identifier` string for credentials — your adapter
145
- * decides what that means: email column, username column, phone column, or a
146
- * query across multiple columns.
147
- */
148
- interface AuthAdapter {
149
- user: {
150
- /**
151
- * Find a user by their login identifier.
152
- *
153
- * The adapter decides which column(s) to query — email, username, phone,
154
- * or a combined lookup (`WHERE email = $1 OR username = $1`).
155
- * Returns `null` if not found.
156
- */
157
- findByIdentifier(identifier: string): Promise<UserRecord | null>;
158
- /** Find a user by their primary key. Returns `null` if not found. */
159
- findById(id: string): Promise<UserRecord | null>;
160
- /**
161
- * Persist a new user with the given identifier, hashed password, and roles.
162
- * The adapter maps `identifier` to the appropriate column(s) in your schema.
163
- */
164
- create(data: CreateUserData): Promise<{
165
- id: string;
166
- }>;
167
- /**
168
- * Replace the complete role list for a user.
169
- * Called by `assignRoles` after merging the new roles with the existing ones.
170
- */
171
- updateRoles(userId: string, roles: string[]): Promise<void>;
172
- };
173
- session: {
174
- /**
175
- * Persist a new session and return its generated ID.
176
- * `expiresAt` is computed from `refreshExpiresIn` in config.
177
- */
178
- create(data: {
179
- userId: string;
180
- expiresAt: Date;
181
- }): Promise<{
182
- id: string;
183
- }>;
184
- /**
185
- * Find a session by its ID, including the associated user.
186
- * Returns `null` if the session does not exist (i.e. has been revoked).
187
- */
188
- findById(sessionId: string): Promise<(SessionRecord & {
189
- user: UserRecord;
190
- }) | null>;
191
- /** Delete a single session. Used during logout and token rotation. */
192
- delete(sessionId: string): Promise<void>;
193
- /** Delete all sessions belonging to a user. Used for "logout from all devices". */
194
- deleteAllForUser(userId: string): Promise<void>;
195
- };
196
- }
197
- /**
198
- * Custom service functions for the built-in auth router.
199
- *
200
- * Each key matches the internal service function name. When provided, the
201
- * custom function replaces the default service call for that route while the
202
- * router still handles request parsing, input validation, and response formatting.
203
- *
204
- * The function signatures mirror the internal services exactly but without the
205
- * `config` parameter — the library passes config at bind time.
206
- *
207
- * @example
208
- * createAuth({
209
- * // ...
210
- * router: {
211
- * login: async (input) => {
212
- * // add OTP check, custom user lookup, etc.
213
- * // must return AuthResult
214
- * },
215
- * register: async (input) => {
216
- * // send welcome email, set default profile, etc.
217
- * // must return RegisterResult
218
- * },
219
- * },
220
- * });
221
- */
222
- interface RouterHandlers {
223
- /**
224
- * Replaces the default register service (`POST /register`).
225
- *
226
- * The router validates the request body (identifier, password, roles) first,
227
- * then calls this function with the parsed input. Must return a `RegisterResult`.
228
- * If omitted, the library's built-in registration logic runs instead.
229
- *
230
- * @example
231
- * register: async (input) => {
232
- * const result = await defaultRegister(input);
233
- * if (result.success) {
234
- * await emailService.sendWelcome(input.identifier);
235
- * }
236
- * return result;
237
- * }
238
- */
239
- register?: (input: RegisterInput) => Promise<RegisterResult>;
240
- /**
241
- * Replaces the default login service.
242
- *
243
- * The router validates the request body (identifier, password) first,
244
- * then calls this function with the parsed input. Must return an `AuthResult`.
245
- * If omitted, the library's built-in login logic runs instead.
246
- *
247
- * @example
248
- * login: async (input) => {
249
- * // verify OTP before issuing tokens
250
- * const otpValid = await redis.get(`otp:${input.identifier}`);
251
- * if (!otpValid) {
252
- * return { success: false, error: new SentriError('INVALID_CREDENTIALS', 'OTP required') };
253
- * }
254
- * return defaultLogin(input);
255
- * }
256
- */
257
- login?: (input: LoginInput) => Promise<AuthResult>;
258
- /**
259
- * Replaces the default refresh service.
260
- *
261
- * Receives the raw refresh token string extracted from the cookie.
262
- * Must return a `RefreshResult`. If omitted, the built-in session-rotation
263
- * logic runs instead.
264
- *
265
- * @example
266
- * refresh: async (refreshToken) => {
267
- * const result = await defaultRefresh(refreshToken);
268
- * if (result.success) {
269
- * await auditLog.record('token_rotated', result.user.id);
270
- * }
271
- * return result;
272
- * }
273
- */
274
- refresh?: (refreshToken: string) => Promise<RefreshResult>;
275
- /**
276
- * Replaces the default logout service.
277
- *
278
- * Receives the raw refresh token from the cookie, or `undefined` if no cookie
279
- * was present. The router clears the cookie after this function resolves.
280
- * If omitted, the built-in session deletion logic runs instead.
281
- *
282
- * @example
283
- * logout: async (refreshToken) => {
284
- * if (refreshToken) {
285
- * await defaultLogout(refreshToken);
286
- * await auditLog.record('logout', refreshToken);
287
- * }
288
- * }
289
- */
290
- logout?: (refreshToken: string | undefined) => Promise<void>;
291
- /**
292
- * Replaces the default logoutAll service.
293
- *
294
- * Receives the authenticated user's ID (from `req.user`, set by `protect()`).
295
- * If omitted, the built-in "delete all sessions" logic runs instead.
296
- *
297
- * @example
298
- * logoutAll: async (userId) => {
299
- * await defaultLogoutAll(userId);
300
- * await notifyService.push(userId, 'You have been signed out from all devices.');
301
- * }
302
- */
303
- logoutAll?: (userId: string) => Promise<void>;
304
- /**
305
- * Replaces the default assignRoles service.
306
- *
307
- * The router validates the request body and params first, then calls this
308
- * function with the target `userId` and the validated `roles` array.
309
- * Must return an `AssignRolesResult`. If omitted, the built-in role-merge
310
- * logic runs instead.
311
- *
312
- * @example
313
- * assignRoles: async (userId, roles) => {
314
- * const result = await defaultAssignRoles(userId, roles);
315
- * if (result.success) {
316
- * await auditLog.record('roles_assigned', { userId, roles });
317
- * }
318
- * return result;
319
- * }
320
- */
321
- assignRoles?: (userId: string, roles: string[]) => Promise<AssignRolesResult>;
322
- }
323
- /**
324
- * Lifecycle hooks called by the built-in auth router at key points in the
325
- * authentication flow. All hooks are optional and fire as side effects —
326
- * returning a rejected Promise from a hook **does not** abort the request.
327
- *
328
- * Common uses: audit logging, metrics, rate-limit counters, notifications.
329
- *
330
- * @example
331
- * createAuth({
332
- * hooks: {
333
- * onLogin: (user) => logger.info('login', { userId: user.id }),
334
- * onFailedLogin: (identifier) => rateLimiter.hit(identifier),
335
- * onLogout: (userId) => cache.invalidate(userId),
336
- * },
337
- * });
338
- */
339
- interface AuthHooks {
340
- /**
341
- * Called after a successful login.
342
- * Receives the authenticated user. Use for audit logs, login notifications, etc.
343
- */
344
- onLogin?: (user: AuthUser) => void | Promise<void>;
345
- /**
346
- * Called after a failed login attempt (wrong password or unknown identifier).
347
- * Receives the identifier that was attempted and the error.
348
- * Use to increment rate-limit counters or alert on repeated failures.
349
- */
350
- onFailedLogin?: (identifier: string, error: SentriError) => void | Promise<void>;
351
- /**
352
- * Called after a successful logout (single session or all sessions).
353
- * Receives the user ID. Use for audit logs or cache invalidation.
354
- */
355
- onLogout?: (userId: string) => void | Promise<void>;
356
- }
357
- /**
358
- * Configuration passed to {@link createAuth}.
359
- *
360
- * Only `secret`, `validRoles`, and `adapter` are required.
361
- * All other fields have sensible defaults.
362
- *
363
- * @example
364
- * createAuth({
365
- * secret: process.env.JWT_SECRET!,
366
- * validRoles: ['user', 'admin'] as const,
367
- * adapter: myAdapter,
368
- * });
369
- */
370
- interface AuthConfig<TRole extends string = string> {
371
- /** Secret used to sign JWT tokens. Must not be empty. Keep this in an env variable. */
372
- secret: string;
373
- /**
374
- * How long access tokens are valid.
375
- * Accepts a duration string (`'15m'`, `'1h'`) or seconds as a number.
376
- * @default '15m'
377
- */
378
- accessExpiresIn?: string | number;
379
- /**
380
- * How long refresh tokens / sessions are valid.
381
- * Accepts a duration string (`'7d'`, `'30d'`) or seconds as a number.
382
- * @default '7d'
383
- */
384
- refreshExpiresIn?: string | number;
385
- /**
386
- * HMAC signing algorithm used for JWTs.
387
- * @default 'HS256'
388
- */
389
- algorithm?: 'HS256' | 'HS384' | 'HS512';
390
- /**
391
- * bcrypt cost factor. Higher = slower hashing but more secure.
392
- * @default 12
393
- */
394
- saltRounds?: number;
395
- /**
396
- * Exhaustive list of role names your application uses.
397
- * Registration will be rejected with `INVALID_ROLE` if a role outside this list is requested.
398
- * Use `as const` to get TypeScript union-type safety on `authorize()`.
399
- *
400
- * @example
401
- * validRoles: ['user', 'admin', 'moderator'] as const
402
- */
403
- validRoles: readonly TRole[];
404
- /** ORM adapter that connects the library to your database. */
405
- adapter: AuthAdapter;
406
- /**
407
- * API key required to call `POST /register`.
408
- *
409
- * When set, the `/register` endpoint expects an `X-Api-Key` header whose
410
- * value matches this string exactly. Requests without the header, or with
411
- * the wrong value, are rejected with HTTP 401 (`UNAUTHORIZED`).
412
- *
413
- * Use this to restrict self-registration — for example, only your own
414
- * back-office service or admin panel should be able to create new accounts,
415
- * so you never expose user registration to arbitrary callers.
416
- *
417
- * @example
418
- * createAuth({
419
- * // ...
420
- * apiKey: process.env.REGISTER_API_KEY!,
421
- * });
422
- *
423
- * // Client must send:
424
- * // POST /auth/register
425
- * // X-Api-Key: <value of REGISTER_API_KEY>
426
- */
427
- apiKey?: string;
428
- /**
429
- * Custom service functions for individual routes in the built-in auth router.
430
- *
431
- * The router still handles request parsing, validation, and response formatting.
432
- * Only the core service logic is replaced by your function.
433
- *
434
- * @example
435
- * createAuth({
436
- * // ...
437
- * router: {
438
- * login: async (input) => {
439
- * // verify OTP, then delegate to default or return custom result
440
- * return { success: true, accessToken, refreshToken, user };
441
- * },
442
- * register: async (input) => {
443
- * // send welcome email after successful registration
444
- * const result = await defaultRegister(input);
445
- * if (result.success) await emailService.sendWelcome(input.identifier);
446
- * return result;
447
- * },
448
- * },
449
- * });
450
- */
451
- router?: RouterHandlers;
452
- /**
453
- * When set, the built-in router (`auth.router()`) stores the refresh token
454
- * in an httpOnly cookie instead of returning it in the response body.
455
- *
456
- * The `refreshToken` field is omitted from `/login` and `/refresh` responses.
457
- * The `/logout` and `/logout-all` routes automatically clear the cookie.
458
- *
459
- * No extra middleware (e.g. `cookie-parser`) is required.
460
- *
461
- * @example
462
- * createAuth({
463
- * // ...
464
- * cookie: { secure: process.env.NODE_ENV === 'production' },
465
- * });
466
- */
467
- cookie?: CookieConfig;
468
- /**
469
- * Lifecycle hooks called at key points in the auth flow.
470
- *
471
- * All hooks fire as side effects — a rejected hook Promise is silently
472
- * swallowed so a broken hook can never take down a login request.
473
- *
474
- * @example
475
- * createAuth({
476
- * hooks: {
477
- * onLogin: async (user) => {
478
- * await auditLog.record('login', user.id);
479
- * },
480
- * onFailedLogin: (identifier) => {
481
- * rateLimiter.hit(`login:${identifier}`);
482
- * },
483
- * },
484
- * });
485
- */
486
- hooks?: AuthHooks;
487
- /**
488
- * Optional guard called by `protect()` after verifying the access token's
489
- * signature and expiry. Return `true` to reject the token immediately, even
490
- * if it is cryptographically valid.
491
- *
492
- * Use this for **immediate token revocation** — for example, store revoked
493
- * `sessionId` values in Redis and check membership here:
494
- *
495
- * ```typescript
496
- * isTokenRevoked: async (sessionId) =>
497
- * await redis.sismember('revoked_sessions', sessionId),
498
- * ```
499
- *
500
- * **Performance note:** this function is called on every protected request.
501
- * Keep it fast (e.g. a single Redis GET/SISMEMBER) or the latency benefit
502
- * of stateless access tokens is lost. If you can tolerate a short revocation
503
- * window equal to `accessExpiresIn`, omit this hook entirely.
504
- *
505
- * @param sessionId - The `sessionId` embedded in the access token at login time.
506
- * @returns `true` if the token should be rejected, `false` (or `undefined`) to allow.
507
- */
508
- isTokenRevoked?: (sessionId: string) => boolean | Promise<boolean>;
509
- /**
510
- * When set, the built-in router also stores the **access token** in a
511
- * non-httpOnly cookie so browser JavaScript can read it via
512
- * `document.cookie` or the `getCurrentAccessToken` helper.
513
- *
514
- * `protect()` reads the access token from this cookie when no
515
- * `Authorization: Bearer` header is present, so clients can skip the
516
- * manual header management entirely.
517
- *
518
- * The cookie's `max-age` is derived automatically from `config.accessExpiresIn`.
519
- * When `protect()` performs a silent refresh, the access token cookie is
520
- * updated in the same response.
521
- *
522
- * @example
523
- * createAuth({
524
- * accessExpiresIn: '5m',
525
- * accessCookie: { secure: process.env.NODE_ENV === 'production' },
526
- * cookie: { secure: process.env.NODE_ENV === 'production' },
527
- * });
528
- */
529
- accessCookie?: AccessCookieConfig;
530
- }
531
- /**
532
- * Cookie settings for storing the refresh token in an httpOnly cookie.
533
- * All fields are optional — defaults are chosen for security.
534
- */
535
- interface CookieConfig {
536
- /**
537
- * Name of the cookie.
538
- * @default 'refresh_token'
539
- */
540
- name?: string;
541
- /**
542
- * Prevents JavaScript from reading the cookie (`document.cookie`).
543
- * @default true
544
- */
545
- httpOnly?: boolean;
546
- /**
547
- * Restricts the cookie to HTTPS connections.
548
- * Set to `true` in production.
549
- * @default false
550
- */
551
- secure?: boolean;
552
- /**
553
- * Controls cross-site request behaviour.
554
- * @default 'strict'
555
- */
556
- sameSite?: 'strict' | 'lax' | 'none';
557
- /**
558
- * URL path the cookie is scoped to.
559
- * @default '/'
560
- */
561
- path?: string;
562
- }
563
- /**
564
- * Cookie settings for storing the access token in a **non**-httpOnly cookie.
565
- *
566
- * Because `httpOnly` is intentionally `false`, browser JavaScript can read this
567
- * cookie via `document.cookie` or the `getCurrentAccessToken` helper, which makes
568
- * it convenient for SPAs that need to attach the token to outgoing requests.
569
- *
570
- * The cookie's `max-age` is derived automatically from `config.accessExpiresIn`.
571
- *
572
- * All fields are optional — safe defaults are applied.
573
- */
574
- interface AccessCookieConfig {
575
- /**
576
- * Name of the access token cookie.
577
- * @default 'access_token'
578
- */
579
- name?: string;
580
- /**
581
- * Restricts the cookie to HTTPS connections.
582
- * Set to `true` in production.
583
- * @default false
584
- */
585
- secure?: boolean;
586
- /**
587
- * Controls cross-site request behaviour.
588
- * @default 'strict'
589
- */
590
- sameSite?: 'strict' | 'lax' | 'none';
591
- /**
592
- * URL path the cookie is scoped to.
593
- * @default '/'
594
- */
595
- path?: string;
596
- }
597
- /**
598
- * The user payload injected as `req.user` after `protect()` runs.
599
- *
600
- * Access tokens issued by sentri >= 1.1.0 embed a `sessionId` that is
601
- * validated against the database on every request. Tokens from older
602
- * versions that lack this claim are accepted but bypass session validation.
603
- */
604
100
  interface AuthUser<TRole extends string = string> {
605
101
  id: string;
606
- /**
607
- * The credential identifier for this user (email, username, phone, etc.).
608
- * Reflects whatever value was passed as `identifier` at registration or login.
609
- */
610
102
  identifier: string;
611
103
  roles: TRole[];
612
104
  }
613
- /** Return type of `register`. */
614
105
  type RegisterResult<TRole extends string = string> = {
615
106
  success: true;
616
107
  user: AuthUser<TRole>;
@@ -618,7 +109,6 @@ type RegisterResult<TRole extends string = string> = {
618
109
  success: false;
619
110
  error: SentriError;
620
111
  };
621
- /** Return type of `login`. */
622
112
  type AuthResult<TRole extends string = string> = {
623
113
  success: true;
624
114
  accessToken: string;
@@ -628,7 +118,6 @@ type AuthResult<TRole extends string = string> = {
628
118
  success: false;
629
119
  error: SentriError;
630
120
  };
631
- /** Return type of `assignRoles`. */
632
121
  type AssignRolesResult<TRole extends string = string> = {
633
122
  success: true;
634
123
  user: AuthUser<TRole>;
@@ -636,7 +125,26 @@ type AssignRolesResult<TRole extends string = string> = {
636
125
  success: false;
637
126
  error: SentriError;
638
127
  };
639
- /** Return type of `refresh`. */
128
+ type GetUserResult<TRole extends string = string> = {
129
+ success: true;
130
+ user: AuthUser<TRole>;
131
+ } | {
132
+ success: false;
133
+ error: SentriError;
134
+ };
135
+ type ChangeIdentifierResult<TRole extends string = string> = {
136
+ success: true;
137
+ user: AuthUser<TRole>;
138
+ } | {
139
+ success: false;
140
+ error: SentriError;
141
+ };
142
+ type ChangePasswordResult = {
143
+ success: true;
144
+ } | {
145
+ success: false;
146
+ error: SentriError;
147
+ };
640
148
  type RefreshResult<TRole extends string = string> = {
641
149
  success: true;
642
150
  accessToken: string;
@@ -646,26 +154,87 @@ type RefreshResult<TRole extends string = string> = {
646
154
  success: false;
647
155
  error: SentriError;
648
156
  };
649
- /** Input for `register`. */
650
157
  interface RegisterInput<TRole extends string = string> {
651
- /**
652
- * The user's login credential — email, username, phone number, or any unique string.
653
- * The adapter maps this to the appropriate column in your database.
654
- */
655
158
  identifier: string;
656
159
  password: string;
657
- /** Roles to assign at creation. Must be a subset of `validRoles`. */
658
160
  roles?: TRole[];
659
161
  }
660
- /** Input for `login`. */
661
162
  interface LoginInput {
662
- /**
663
- * The user's login credential — email, username, phone number, or any unique string.
664
- * The adapter's `findByIdentifier` handles the lookup.
665
- */
666
163
  identifier: string;
667
164
  password: string;
668
165
  }
166
+ interface CookieConfig {
167
+ name?: string;
168
+ httpOnly?: boolean;
169
+ secure?: boolean;
170
+ sameSite?: 'strict' | 'lax' | 'none';
171
+ path?: string;
172
+ }
173
+ interface AccessCookieConfig {
174
+ name?: string;
175
+ secure?: boolean;
176
+ sameSite?: 'strict' | 'lax' | 'none';
177
+ path?: string;
178
+ }
179
+ interface AuthHooks {
180
+ onLogin?: (user: AuthUser) => void | Promise<void>;
181
+ onFailedLogin?: (identifier: string, error: SentriError) => void | Promise<void>;
182
+ onLogout?: (userId: string) => void | Promise<void>;
183
+ }
184
+ interface RouterHandlers {
185
+ register?: (input: RegisterInput) => Promise<RegisterResult>;
186
+ login?: (input: LoginInput) => Promise<AuthResult>;
187
+ refresh?: (refreshToken: string) => Promise<RefreshResult>;
188
+ logout?: (refreshToken: string | undefined) => Promise<void>;
189
+ logoutAll?: (userId: string) => Promise<void>;
190
+ assignRoles?: (userId: string, roles: string[]) => Promise<AssignRolesResult>;
191
+ changeIdentifier?: (userId: string, newIdentifier: string) => Promise<ChangeIdentifierResult>;
192
+ changePassword?: (userId: string, currentPassword: string, newPassword: string) => Promise<ChangePasswordResult>;
193
+ }
194
+ interface ServerAuthConfig<TRole extends string = string> {
195
+ mode: 'server';
196
+ /** Kysely Dialect (e.g. PostgresDialect, MysqlDialect, SqliteDialect). */
197
+ dialect: Dialect;
198
+ /**
199
+ * JWT signing secret.
200
+ * - HS256/HS384/HS512: plain string, minimum 32 characters.
201
+ * - RS256/RS384/RS512: RSA private key in PEM format.
202
+ */
203
+ secret: string;
204
+ /**
205
+ * JWT signing algorithm.
206
+ * Use RS256/RS384/RS512 to enable the GET /keys endpoint for SSO.
207
+ * @default 'HS256'
208
+ */
209
+ algorithm?: 'HS256' | 'HS384' | 'HS512' | 'RS256' | 'RS384' | 'RS512';
210
+ validRoles: readonly TRole[];
211
+ /** @default '15m' */
212
+ accessExpiresIn?: string | number;
213
+ /** @default '7d' */
214
+ refreshExpiresIn?: string | number;
215
+ /** @default 12 */
216
+ saltRounds?: number;
217
+ apiKey?: string;
218
+ cookie?: CookieConfig;
219
+ accessCookie?: AccessCookieConfig;
220
+ hooks?: AuthHooks;
221
+ router?: RouterHandlers;
222
+ isTokenRevoked?: (sessionId: string) => boolean | Promise<boolean>;
223
+ /**
224
+ * Redis connection URL (e.g. `redis://localhost:6379`).
225
+ * When set, `auth.idempotencyMiddleware()` uses Redis as the cache backend
226
+ * instead of an in-memory Map — required for multi-process deployments.
227
+ */
228
+ redisUrl?: string;
229
+ }
230
+ interface ClientAuthConfig<TRole extends string = string> {
231
+ mode: 'client';
232
+ /** URL of the auth server's public key endpoint (e.g. https://auth.myapp.com/auth/keys). */
233
+ keyUri: string;
234
+ /** Optional — only needed for TypeScript type safety on authorize(). */
235
+ validRoles?: readonly TRole[];
236
+ }
237
+ type AuthConfig<TRole extends string = string> = ServerAuthConfig<TRole> | ClientAuthConfig<TRole>;
669
238
 
670
239
  /** A function that determines whether the current request is permitted. */
671
240
  type PermitCheck = (request: Request) => boolean | Promise<boolean>;
@@ -766,288 +335,218 @@ interface ErrorHandlerOptions {
766
335
  */
767
336
  declare function createErrorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler;
768
337
 
338
+ interface IdempotencyOptions {
339
+ /** @default 300_000 (5 minutes) */
340
+ ttl?: number;
341
+ /** @default 'X-Idempotency-Key' */
342
+ header?: string;
343
+ /** @default ['POST', 'PUT', 'PATCH'] */
344
+ methods?: string[];
345
+ /**
346
+ * Max in-memory entries (ignored when redisUrl is set).
347
+ * @default 10_000
348
+ */
349
+ maxSize?: number;
350
+ /**
351
+ * Redis connection URL (e.g. `redis://localhost:6379`).
352
+ * When set, uses Redis as the cache backend instead of in-memory Map.
353
+ */
354
+ redisUrl?: string;
355
+ }
769
356
  /**
770
- * The bound auth client returned by {@link createAuth}.
357
+ * Middleware that deduplicates non-idempotent requests.
358
+ *
359
+ * When a request arrives with a matching idempotency key header, the cached
360
+ * response is replayed immediately — the handler is not called again.
361
+ * Responses are only cached for 2xx status codes.
771
362
  *
772
- * All methods are pre-configured with the options passed to `createAuth` —
773
- * you never need to pass config around yourself.
363
+ * Two backends are available:
364
+ * - **In-memory** (default) zero dependencies, single-process only.
365
+ * - **Redis** — set `redisUrl` to share state across processes/instances.
366
+ *
367
+ * When using `createAuthServer()`, prefer `auth.idempotencyMiddleware()` instead —
368
+ * it automatically inherits the `redisUrl` from server config.
369
+ *
370
+ * @example
371
+ * // Standalone usage
372
+ * app.use(createIdempotencyMiddleware({ ttl: 60_000 }));
774
373
  *
775
- * `TRole` is inferred from `validRoles` and narrows role strings to your
776
- * application's exact union type everywhere (authorize, req.user, etc.).
374
+ * @example
375
+ * // Multi-process (Redis)
376
+ * app.use(createIdempotencyMiddleware({ redisUrl: 'redis://localhost:6379' }));
777
377
  */
378
+ declare function createIdempotencyMiddleware(options?: IdempotencyOptions): RequestHandler;
379
+
778
380
  interface AuthClient<TRole extends string = string> {
779
- /**
780
- * Express middleware factory that enforces authentication.
781
- *
782
- * Reads the `Authorization: Bearer <token>` header, verifies the access token,
783
- * confirms the session is still active in the database, and injects the decoded
784
- * payload as `request.user`. Calls `next(SentriError)` on any failure.
785
- *
786
- * @example
787
- * router.get('/me', auth.protect(), (request, response) => {
788
- * response.json(request.user);
789
- * });
790
- */
381
+ /** JWT authentication middleware. Reads Bearer token or access_token cookie. */
791
382
  protect(): RequestHandler;
792
- /**
793
- * Express middleware factory that enforces role-based access.
794
- *
795
- * Must be used **after** `protect()`. Passes if the authenticated user has
796
- * at least one of the specified roles; otherwise calls `next(SentriError)` with
797
- * code `FORBIDDEN`.
798
- *
799
- * @example
800
- * router.delete('/posts/:id', auth.protect(), auth.authorize('admin'), handler);
801
- */
383
+ /** Role-based access middleware. Must follow protect(). */
802
384
  authorize(...roles: TRole[]): RequestHandler;
803
- /**
804
- * Express middleware factory for resource-level permission checks.
805
- *
806
- * Must be used **after** `protect()`. Evaluates a check function against the
807
- * current request and calls `next(SentriError)` with `FORBIDDEN` if it returns `false`.
808
- *
809
- * Accepts either a bare check function or an options object with an optional
810
- * `roles` list whose members bypass the check entirely.
811
- *
812
- * @example
813
- * // User can only update their own profile
814
- * router.put('/users/:id',
815
- * auth.protect(),
816
- * auth.permit((request) => request.user!.id === request.params['id']),
817
- * handler,
818
- * );
819
- *
820
- * @example
821
- * // Admins bypass the check; others must own the resource
822
- * router.delete('/posts/:id',
823
- * auth.protect(),
824
- * auth.permit({
825
- * roles: ['admin'],
826
- * check: async (request) => {
827
- * const post = await db.post.findUnique({ where: { id: request.params['id'] } });
828
- * return post?.authorId === request.user!.id;
829
- * },
830
- * }),
831
- * handler,
832
- * );
833
- */
385
+ /** Resource-level permission middleware. Must follow protect(). */
834
386
  permit(check: PermitCheck): RequestHandler;
835
387
  permit(options: PermitOptions<TRole>): RequestHandler;
836
- /** Hash a plain-text password using the configured `saltRounds`. */
388
+ /** Global error handler middleware. Mount after all routes. */
389
+ errorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler;
390
+ }
391
+ interface ServerAuthClient<TRole extends string = string> extends AuthClient<TRole> {
392
+ /** Hash a plain-text password. */
837
393
  hashPassword(plain: string): Promise<string>;
838
- /** Compare a plain-text password against a stored bcrypt hash. */
394
+ /** Compare a plain-text password against a bcrypt hash. */
839
395
  verifyPassword(plain: string, hash: string): Promise<boolean>;
840
- /** Sign an access token for the given user payload. */
396
+ /** Sign an access token. */
841
397
  signAccessToken(payload: AuthUser<TRole>): string;
842
- /** Sign a refresh token bound to a session ID. */
398
+ /** Sign a refresh token. */
843
399
  signRefreshToken(sessionId: string): string;
844
- /** Verify and decode an access token. Throws `SentriError` if invalid or expired. */
400
+ /** Verify an access token. */
845
401
  verifyAccessToken(token: string): AuthUser<TRole>;
846
- /** Verify and decode a refresh token. Throws `SentriError` if invalid or expired. */
402
+ /** Verify a refresh token. */
847
403
  verifyRefreshToken(token: string): {
848
404
  sessionId: string;
849
405
  };
850
- /**
851
- * Extract the raw access token string from an incoming Express request.
852
- *
853
- * Checks two sources in order:
854
- * 1. `Authorization: Bearer <token>` header
855
- * 2. Access token cookie (name from `config.accessCookie.name`, default `'access_token'`)
856
- *
857
- * Returns `undefined` when neither source is present.
858
- *
859
- * Useful in custom middleware or logging where you need the raw token without
860
- * running full JWT verification.
861
- *
862
- * @example
863
- * app.use((req, _res, next) => {
864
- * const token = auth.getCurrentAccessToken(req);
865
- * if (token) logger.debug('access token present');
866
- * next();
867
- * });
868
- */
406
+ /** Extract the raw access token from an Express request. */
869
407
  getCurrentAccessToken(request: Request): string | undefined;
870
408
  /**
871
- * Returns a pre-built Express Router with all standard auth endpoints mounted.
872
- *
873
- * Endpoints:
874
- * - `POST /register` register a new user. Requires `X-Api-Key` header when `config.apiKey` is set.
875
- * - `POST /login` — authenticate, sets refresh token cookie, returns `{ accessToken, user }`
876
- * - `POST /refresh` — rotate refresh token, returns new `{ accessToken }`
877
- * - `POST /logout` — delete the current session; the bound access token is immediately rejected by `protect()`
878
- * - `POST /logout-all` — delete all sessions for the user (requires valid access token)
879
- * - `GET /me` — return the authenticated user
880
- * - `POST /users/:userId/roles` — assign roles (requires admin)
881
- *
882
- * Requires `express.json()` before the router.
883
- *
884
- * @example
885
- * app.use(express.json());
886
- * app.use('/auth', auth.router());
409
+ * Pre-built Express Router with auth endpoints:
410
+ * POST /register, POST /login, POST /refresh, POST /logout,
411
+ * POST /logout-all, GET /me, POST /users/:userId/roles,
412
+ * GET /keys (only when algorithm is RS256/RS384/RS512)
887
413
  */
888
414
  router(): Router;
889
415
  /**
890
- * Returns an Express error-handling middleware that formats every `SentriError`
891
- * (and any subclass) into the standard sentri response envelope:
892
- *
893
- * ```json
894
- * { "error": true, "statusCode": 401, "code": "UNAUTHORIZED", "message": "...", "data": null }
895
- * ```
896
- *
897
- * Mount it **after all your routes** so it acts as the global catch-all for
898
- * both sentri errors and your own `SentriError` subclasses.
899
- *
900
- * @example
901
- * import { SentriError } from 'sentri';
902
- *
903
- * // Define app-specific errors by extending SentriError
904
- * class NotFoundError extends SentriError {
905
- * constructor(resource: string) {
906
- * super('NOT_FOUND', `${resource} not found`, 404);
907
- * }
908
- * }
909
- *
910
- * app.use('/auth', auth.router());
911
- * app.use('/api', apiRouter);
912
- *
913
- * // Catches errors from sentri AND your own subclasses
914
- * app.use(auth.errorHandler());
915
- *
916
- * @example
917
- * // With optional unhandled-error logger
918
- * app.use(auth.errorHandler({
919
- * onUnhandled: (err) => logger.error('Unexpected error', { err }),
920
- * }));
921
- */
922
- errorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler;
416
+ * Run database migrations to create sentri_users and sentri_sessions tables.
417
+ * Safe to call on every startup uses IF NOT EXISTS.
418
+ */
419
+ migrate(): Promise<void>;
420
+ /**
421
+ * Idempotency middleware. Caches successful responses and replays them for
422
+ * duplicate requests with the same idempotency key header.
423
+ * Uses Redis backend when `redisUrl` is set in server config; otherwise in-memory.
424
+ */
425
+ idempotencyMiddleware(options?: IdempotencyOptions): RequestHandler;
426
+ /** Register a new user. */
427
+ register(input: RegisterInput<TRole>): Promise<RegisterResult<TRole>>;
428
+ /** Authenticate a user, returns access + refresh tokens. */
429
+ login(input: LoginInput): Promise<AuthResult<TRole>>;
430
+ /** Rotate a refresh token, returns new token pair. */
431
+ refresh(refreshToken: string): Promise<RefreshResult<TRole>>;
432
+ /** Invalidate the session associated with the given refresh token. */
433
+ logout(refreshToken: string): Promise<void>;
434
+ /** Invalidate all sessions for a user. */
435
+ logoutAll(userId: string): Promise<void>;
436
+ /** Fetch a user by ID. Returns `success: false` if not found. */
437
+ getUser(userId: string): Promise<GetUserResult<TRole>>;
438
+ /** Change a user's identifier (username/email). */
439
+ changeIdentifier(userId: string, newIdentifier: string): Promise<ChangeIdentifierResult<TRole>>;
440
+ /** Change a user's password and revoke all their sessions. */
441
+ changePassword(userId: string, currentPassword: string, newPassword: string): Promise<ChangePasswordResult>;
442
+ /** Add roles to a user (existing roles are preserved). */
443
+ assignRoles(userId: string, roles: TRole[]): Promise<AssignRolesResult<TRole>>;
444
+ }
445
+ interface ClientAuthClient<TRole extends string = string> extends AuthClient<TRole> {
923
446
  }
924
447
  /**
925
- * Create a fully configured auth client for your application.
448
+ * Create a Sentri auth client.
926
449
  *
927
- * Pass your config once here and use the returned client everywhere it
928
- * binds all library functions to your settings so you never need to pass
929
- * config manually.
450
+ * Pass `mode: 'server'` to get a full auth server with database, JWT signing,
451
+ * and built-in endpoints. Pass `mode: 'client'` to get a stateless verifier
452
+ * that validates tokens via the server's JWKS endpoint.
930
453
  *
931
- * The generic parameter `TRole` is inferred automatically from `validRoles`
932
- * when you use `as const`:
454
+ * For server mode with PostgreSQL, prefer `createAuthServer()` it handles
455
+ * RSA key generation and database setup automatically.
933
456
  *
934
457
  * @example
935
- * export const auth = createAuth({
458
+ * // Server mode
459
+ * const auth = createAuth({
460
+ * mode: 'server',
461
+ * dialect: new PostgresDialect({ pool: new Pool({ connectionString: DATABASE_URL }) }),
936
462
  * secret: process.env.JWT_SECRET!,
937
- * validRoles: ['user', 'admin', 'moderator'] as const,
938
- * adapter: myAdapter,
463
+ * validRoles: ['user', 'admin'] as const,
939
464
  * });
940
465
  *
941
- * // auth.authorize('admin') is type-safe — 'superuser' would be a compile error.
466
+ * @example
467
+ * // Client mode
468
+ * const auth = createAuth({
469
+ * mode: 'client',
470
+ * keyUri: 'https://auth.myapp.com/auth/keys',
471
+ * });
942
472
  */
943
- declare function createAuth<TRole extends string = string>(config: AuthConfig<TRole>): AuthClient<TRole>;
473
+ declare function createAuth<TRole extends string = string>(config: ServerAuthConfig<TRole>): ServerAuthClient<TRole>;
474
+ declare function createAuth<TRole extends string = string>(config: ClientAuthConfig<TRole>): ClientAuthClient<TRole>;
944
475
 
945
- /**
946
- * Options for {@link createIdempotencyMiddleware}.
947
- */
948
- interface IdempotencyOptions {
949
- /**
950
- * How long a cached response is kept, in milliseconds.
951
- * @default 300_000 (5 minutes)
952
- */
953
- ttl?: number;
954
- /**
955
- * Name of the request header that carries the idempotency key.
956
- * @default 'X-Idempotency-Key'
957
- */
958
- header?: string;
959
- /**
960
- * HTTP methods to apply idempotency checking to.
961
- * @default ['POST', 'PUT', 'PATCH']
962
- */
963
- methods?: string[];
476
+ type PostgresConfig = {
477
+ connectionString: string;
478
+ max?: number;
479
+ } | {
480
+ host?: string;
481
+ port?: number;
482
+ database: string;
483
+ user: string;
484
+ password: string;
485
+ max?: number;
486
+ };
487
+
488
+ interface CreateServerOptions<TRole extends string = string> {
489
+ validRoles: readonly TRole[];
490
+ db: PostgresConfig;
491
+ accessExpiresIn?: string | number;
492
+ refreshExpiresIn?: string | number;
493
+ saltRounds?: number;
494
+ apiKey?: string;
495
+ cookie?: CookieConfig;
496
+ accessCookie?: AccessCookieConfig;
497
+ hooks?: AuthHooks;
498
+ router?: RouterHandlers;
499
+ isTokenRevoked?: (sessionId: string) => boolean | Promise<boolean>;
964
500
  /**
965
- * Maximum number of cached entries. When the limit is reached, the oldest
966
- * entry (by insertion order) is evicted before a new one is stored. This
967
- * prevents unbounded memory growth under high traffic with unique keys.
968
- * @default 10_000
501
+ * Redis connection URL (e.g. `redis://localhost:6379`).
502
+ * When set, `auth.idempotencyMiddleware()` uses Redis as the cache backend.
969
503
  */
970
- maxSize?: number;
504
+ redisUrl?: string;
971
505
  }
972
506
  /**
973
- * Creates an Express middleware that makes mutating operations idempotent.
507
+ * Create a Sentri auth server for PostgreSQL.
974
508
  *
975
- * When a request includes an idempotency-key header (default `X-Idempotency-Key`),
976
- * the middleware:
977
- * 1. Attaches the key as `req.requestId` and echoes it in the `X-Request-Id` response header.
978
- * 2. Checks an in-memory cache for a prior successful response under that key.
979
- * - **Cache hit**: replies immediately with the stored response and sets
980
- * `X-Idempotent-Replayed: true` — no handler runs.
981
- * - **Cache miss**: intercepts `response.json()` to store the result after the
982
- * handler completes (only 2xx responses are cached).
983
- *
984
- * The cache is per-process and in-memory with a configurable TTL. It is suitable
985
- * for protecting create/update operations against duplicate submissions (network
986
- * retries, double-clicks) within the same server process. For multi-process
987
- * deployments consider using a shared cache such as Redis.
988
- *
989
- * Requests without the idempotency-key header, or using non-mutating methods, pass
990
- * straight through to the next middleware.
509
+ * Convenience wrapper over `createAuth()` that:
510
+ * - Accepts plain PostgreSQL connection params instead of a Kysely dialect
511
+ * - Generates an RSA-2048 key pair at startup (RS256, ephemeral per process)
512
+ * - Exposes `GET /keys` (JWKS) automatically for SSO with client-mode apps
991
513
  *
992
514
  * @example
993
- * import { createIdempotencyMiddleware } from 'sentri';
994
- *
995
- * // Apply globally before routes
996
- * app.use(createIdempotencyMiddleware());
997
- *
998
- * // Or scoped to a router with a shorter TTL
999
- * apiRouter.use(createIdempotencyMiddleware({ ttl: 60_000 }));
1000
- */
1001
- declare function createIdempotencyMiddleware(options?: IdempotencyOptions): RequestHandler;
1002
-
1003
- /**
1004
- * Extract the access token from an incoming Express request.
1005
- *
1006
- * Looks in two places, in order:
1007
- * 1. `Authorization: Bearer <token>` header
1008
- * 2. The access token cookie (name from `config.accessCookie.name`, default `'access_token'`)
1009
- *
1010
- * Returns `undefined` when neither source is present.
1011
- *
1012
- * This is the server-side companion to reading `document.cookie` on the client.
1013
- * Use it when you need the raw token string for custom middleware or logging.
515
+ * const auth = createAuthServer({
516
+ * validRoles: ['user', 'admin'] as const,
517
+ * db: { connectionString: process.env.DATABASE_URL! },
518
+ * });
519
+ * await auth.migrate();
520
+ * app.use('/auth', auth.router());
1014
521
  *
1015
522
  * @example
1016
- * import { getCurrentAccessToken } from 'sentri';
1017
- *
1018
- * app.get('/debug/token', (req, res) => {
1019
- * const token = getCurrentAccessToken(req, config);
1020
- * res.json({ token });
523
+ * // With Redis idempotency cache (multi-process deployments)
524
+ * const auth = createAuthServer({
525
+ * validRoles: ['user', 'admin'] as const,
526
+ * db: { connectionString: process.env.DATABASE_URL! },
527
+ * redisUrl: process.env.REDIS_URL,
1021
528
  * });
529
+ * app.use(auth.idempotencyMiddleware());
1022
530
  */
1023
- declare function getCurrentAccessToken(request: Request, config: AuthConfig): string | undefined;
531
+ declare function createAuthServer<TRole extends string = string>(options: CreateServerOptions<TRole>): ServerAuthClient<TRole>;
1024
532
 
1025
533
  /**
1026
- * Register a new user.
1027
- *
1028
- * Validates that every requested role is in `validRoles`, rejects duplicate
1029
- * identifiers, hashes the password with bcrypt, creates the user record via
1030
- * the adapter, and returns the created user.
1031
- *
1032
- * No tokens are issued — the caller should invoke `login` after registration
1033
- * if immediate authentication is desired.
1034
- *
1035
- * @param input - Registration data: identifier, plain-text password, and optional roles.
1036
- * @param config - Auth configuration containing the adapter and role definitions.
1037
- * @returns `{ success: true, user }` on success, or `{ success: false, error }` with
1038
- * code `INVALID_ROLE` or `USER_ALREADY_EXISTS` on failure.
534
+ * Extract the raw access token string from an Express request.
535
+ * Reads `Authorization: Bearer <token>` header first; falls back to the
536
+ * `access_token` cookie (or the name set in `accessCookie.name`).
537
+ * Returns `undefined` when no token is present.
1039
538
  */
1040
- declare function register(input: RegisterInput, config: AuthConfig): Promise<RegisterResult>;
539
+ declare function getCurrentAccessToken(request: Request, config: ServerAuthConfig): string | undefined;
540
+
541
+ declare function register(input: RegisterInput, config: ServerAuthConfig): Promise<RegisterResult>;
1041
542
 
1042
543
  declare global {
1043
544
  namespace Express {
1044
545
  interface Request {
1045
- /** Decoded user payload injected by `protect()` on every authenticated request. */
1046
546
  user?: AuthUser;
1047
- /** Idempotency key from `X-Idempotency-Key` header, attached by `createIdempotencyMiddleware()`. */
1048
547
  requestId?: string;
1049
548
  }
1050
549
  }
1051
550
  }
1052
551
 
1053
- export { AUTH_ERROR_STATUS, type AccessCookieConfig, type ApiResponse, type AssignRolesResult, type AuthAdapter, type AuthClient, type AuthConfig, type AuthHooks, type AuthResult, type AuthUser, type CookieConfig, type CreateUserData, type ErrorHandlerOptions, type IdempotencyOptions, type LoginInput, type PermitCheck, type PermitOptions, type RefreshResult, type RegisterInput, type RegisterResult, type RouterHandlers, SentriError, type SentriErrorCode, type SessionRecord, type UserRecord, createAuth, createErrorHandler, createIdempotencyMiddleware, getCurrentAccessToken, register };
552
+ export { type AccessCookieConfig, type ApiResponse, type AssignRolesResult, type AuthClient, type AuthConfig, type AuthHooks, type AuthResult, type AuthUser, type ChangeIdentifierResult, type ChangePasswordResult, type ClientAuthClient, type ClientAuthConfig, type CookieConfig, type CreateServerOptions, type ErrorHandlerOptions, type GetUserResult, type IdempotencyOptions, type LoginInput, type PermitCheck, type PermitOptions, type PostgresConfig, type RefreshResult, type RegisterInput, type RegisterResult, type RouterHandlers, SENTRI_ERROR_STATUS, SentriError, type SentriErrorCode, type ServerAuthClient, type ServerAuthConfig, createAuth, createAuthServer, createErrorHandler, createIdempotencyMiddleware, getCurrentAccessToken, register };