sentri 2.1.0 → 4.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/README.md CHANGED
@@ -14,12 +14,14 @@ Auth and authorization library for Express. Supports two modes:
14
14
  - [Server Mode](#server-mode)
15
15
  - [Client Mode](#client-mode)
16
16
  - [SSO Flow](#sso-flow)
17
+ - [Multi-Identifier](#multi-identifier)
17
18
  - [Configuration](#configuration)
18
19
  - [Endpoints](#endpoints)
19
20
  - [Middleware](#middleware)
20
21
  - [Token Utilities](#token-utilities)
21
22
  - [Error Handling](#error-handling)
22
23
  - [Request Idempotency](#request-idempotency)
24
+ - [Logger](#logger)
23
25
  - [Migration Guide](#migration-guide)
24
26
 
25
27
  ---
@@ -165,7 +167,8 @@ createAuth({
165
167
  isTokenRevoked: async (sessionId) => await redis.sismember('revoked', sessionId),
166
168
  router: { // override built-in service functions
167
169
  login, register, refresh, logout, logoutAll, assignRoles,
168
- changeIdentifier, changePassword,
170
+ bulkCreateIdentifiers, bulkUpdateIdentifiers, bulkDeleteIdentifiers,
171
+ changePrimaryIdentifier, changePassword,
169
172
  },
170
173
  });
171
174
  ```
@@ -177,9 +180,10 @@ createAuth({
177
180
  await auth.migrate();
178
181
  ```
179
182
 
180
- Creates two tables:
181
- - `sentri_users` — id, identifier, password_hash, roles (JSON), created_at
183
+ Creates three tables:
184
+ - `sentri_users` — id, password_hash, roles (JSON), created_at
182
185
  - `sentri_sessions` — id, user_id, expires_at, created_at
186
+ - `sentri_identifiers` — id, user_id, type, value (globally unique), is_primary, created_at
183
187
 
184
188
  ---
185
189
 
@@ -223,6 +227,143 @@ Client apps point `keyUri` at this endpoint and receive the public key automatic
223
227
 
224
228
  ---
225
229
 
230
+ ## Multi-Identifier
231
+
232
+ Each user can have multiple identifiers — email, username, phone number, or any custom type. All identifier values are **globally unique** regardless of type.
233
+
234
+ One identifier per user is marked as **primary** and its value is embedded in the JWT payload. Regardless of which identifier a user logs in with, the JWT always contains the primary identifier.
235
+
236
+ ### Registration
237
+
238
+ Provide at least one identifier. The first entry becomes the primary.
239
+
240
+ ```
241
+ POST /register
242
+ Content-Type: application/json
243
+
244
+ {
245
+ "identifiers": [
246
+ { "type": "email", "value": "rizz@example.com" },
247
+ { "type": "username", "value": "rizz" }
248
+ ],
249
+ "password": "secret123",
250
+ "roles": ["user"]
251
+ }
252
+ ```
253
+
254
+ **Response:**
255
+ ```json
256
+ {
257
+ "error": false,
258
+ "statusCode": 201,
259
+ "message": "User registered successfully",
260
+ "data": {
261
+ "user": {
262
+ "id": "uuid",
263
+ "identifier": "rizz@example.com",
264
+ "identifierType": "email",
265
+ "roles": ["user"],
266
+ "identifiers": [
267
+ { "id": "uuid-1", "type": "email", "value": "rizz@example.com", "isPrimary": true },
268
+ { "id": "uuid-2", "type": "username", "value": "rizz", "isPrimary": false }
269
+ ]
270
+ }
271
+ }
272
+ }
273
+ ```
274
+
275
+ ### Login
276
+
277
+ Send any of the user's identifier values — Sentri searches all types automatically.
278
+
279
+ ```
280
+ POST /login
281
+ Content-Type: application/json
282
+
283
+ { "identifier": "rizz", "password": "secret123" }
284
+ ```
285
+
286
+ ### Bulk Create Identifiers
287
+
288
+ ```
289
+ POST /me/identifiers
290
+ Authorization: Bearer <token>
291
+ Content-Type: application/json
292
+
293
+ {
294
+ "identifiers": [
295
+ { "type": "phone", "value": "+628123456789" }
296
+ ]
297
+ }
298
+ ```
299
+
300
+ ### Bulk Update Identifiers
301
+
302
+ ```
303
+ PUT /me/identifiers
304
+ Authorization: Bearer <token>
305
+ Content-Type: application/json
306
+
307
+ {
308
+ "identifiers": [
309
+ { "id": "uuid-2", "type": "username", "value": "newrizz" }
310
+ ]
311
+ }
312
+ ```
313
+
314
+ ### Bulk Delete Identifiers
315
+
316
+ ```
317
+ DELETE /me/identifiers
318
+ Authorization: Bearer <token>
319
+ Content-Type: application/json
320
+
321
+ { "ids": ["uuid-2"] }
322
+ ```
323
+
324
+ At least one identifier must remain after deletion.
325
+
326
+ ### Change Primary Identifier
327
+
328
+ ```
329
+ PATCH /me/identifiers/primary
330
+ Authorization: Bearer <token>
331
+ Content-Type: application/json
332
+
333
+ { "id": "uuid-2" }
334
+ ```
335
+
336
+ The new primary value will be embedded in the JWT on the next login or token refresh.
337
+
338
+ ### Programmatic API
339
+
340
+ ```typescript
341
+ const auth = createAuth({ mode: 'server', ... });
342
+
343
+ // Register with multiple identifiers
344
+ await auth.register({
345
+ identifiers: [
346
+ { type: 'email', value: 'rizz@example.com' },
347
+ { type: 'username', value: 'rizz' },
348
+ ],
349
+ password: 'secret123',
350
+ });
351
+
352
+ // Add identifiers after registration
353
+ await auth.bulkCreateIdentifiers(userId, [{ type: 'phone', value: '+628123456789' }]);
354
+
355
+ // Update identifiers
356
+ await auth.bulkUpdateIdentifiers(userId, [{ id: 'uuid-2', type: 'username', value: 'newrizz' }]);
357
+
358
+ // Delete identifiers
359
+ await auth.bulkDeleteIdentifiers(userId, ['uuid-2']);
360
+
361
+ // Change primary
362
+ await auth.changePrimaryIdentifier(userId, 'uuid-3');
363
+ ```
364
+
365
+ ---
366
+
226
367
  ## Configuration
227
368
 
228
369
  ### `algorithm`
@@ -259,8 +400,11 @@ After login, both cookies are set automatically. `protect()` reads the access to
259
400
  | `POST` | `/refresh` | — | Rotate refresh token |
260
401
  | `POST` | `/logout` | — | Invalidate current session |
261
402
  | `POST` | `/logout-all` | ✓ | Invalidate all sessions |
262
- | `GET` | `/me` | ✓ | Return authenticated user |
263
- | `PATCH` | `/me/identifier` | ✓ self | Change identifier (username/email) |
403
+ | `GET` | `/me` | ✓ | Return authenticated user with all identifiers |
404
+ | `POST` | `/me/identifiers` | ✓ self | Add identifiers in bulk |
405
+ | `PUT` | `/me/identifiers` | ✓ self | Update identifiers in bulk |
406
+ | `DELETE` | `/me/identifiers` | ✓ self | Delete identifiers in bulk |
407
+ | `PATCH` | `/me/identifiers/primary` | ✓ self | Change primary identifier |
264
408
  | `PATCH` | `/me/password` | ✓ self | Change password — revokes all sessions |
265
409
  | `POST` | `/users/:userId/roles` | ✓ admin | Assign roles to user |
266
410
  | `GET` | `/keys` | — | Public key in JWKS format (RS256 only) |
@@ -270,16 +414,6 @@ All responses use the envelope:
270
414
  { "error": false, "statusCode": 200, "message": "...", "data": { ... } }
271
415
  ```
272
416
 
273
- ### Change Identifier
274
-
275
- ```
276
- PATCH /me/identifier
277
- Authorization: Bearer <token>
278
- Content-Type: application/json
279
-
280
- { "identifier": "new@example.com" }
281
- ```
282
-
283
417
  ### Change Password
284
418
 
285
419
  ```
@@ -302,6 +436,7 @@ Verifies the JWT and sets `req.user`. In server mode, also performs silent token
302
436
 
303
437
  ```typescript
304
438
  app.get('/dashboard', auth.protect(), (req, res) => res.json(req.user));
439
+ // req.user: { id, identifier, identifierType, roles, identifiers? }
305
440
  ```
306
441
 
307
442
  Token is read from `Authorization: Bearer <token>` header or `access_token` cookie.
@@ -348,7 +483,7 @@ Available on `ServerAuthClient` only:
348
483
  const auth = createAuth({ mode: 'server', ... });
349
484
 
350
485
  // Sign
351
- const accessToken = auth.signAccessToken({ id, identifier, roles });
486
+ const accessToken = auth.signAccessToken({ id, identifier, identifierType, roles });
352
487
  const refreshToken = auth.signRefreshToken(sessionId);
353
488
 
354
489
  // Verify
@@ -376,8 +511,10 @@ app.use(auth.errorHandler()); // must be last
376
511
  | Code | HTTP | Meaning |
377
512
  |---|---|---|
378
513
  | `INVALID_CREDENTIALS` | 401 | Wrong identifier or password |
379
- | `USER_ALREADY_EXISTS` | 409 | Duplicate identifier at registration |
380
514
  | `USER_NOT_FOUND` | 404 | User does not exist |
515
+ | `USER_ALREADY_EXISTS` | 409 | Duplicate identifier at registration |
516
+ | `IDENTIFIER_NOT_FOUND` | 404 | Referenced identifier ID does not exist or belongs to another user |
517
+ | `IDENTIFIER_ALREADY_EXISTS` | 409 | Identifier value already taken by another user |
381
518
  | `TOKEN_EXPIRED` | 401 | JWT `exp` is in the past |
382
519
  | `TOKEN_INVALID` | 401 | Bad signature or malformed JWT |
383
520
  | `UNAUTHORIZED` | 401 | No valid token or session not found |
@@ -453,8 +590,151 @@ app.use(createIdempotencyMiddleware({ redisUrl: 'redis://localhost:6379' }));
453
590
 
454
591
  ---
455
592
 
593
+ ## Logger
594
+
595
+ Sentri produces structured JSON log entries for every auth event. Logging is **opt-in** — when no logger is configured, Sentri is completely silent (zero overhead).
596
+
597
+ ### Activating the logger
598
+
599
+ Pass any object that implements `{ info, warn, error }` via the `logger` field in your config.
600
+
601
+ **pino** (recommended for production):
602
+ ```typescript
603
+ import pino from 'pino';
604
+
605
+ const auth = createAuth({
606
+ mode: 'server',
607
+ // ...other config
608
+ logger: pino(),
609
+ });
610
+ ```
611
+
612
+ **winston**:
613
+ ```typescript
614
+ import winston from 'winston';
615
+
616
+ const auth = createAuth({
617
+ mode: 'server',
618
+ // ...other config
619
+ logger: winston.createLogger({
620
+ transports: [new winston.transports.Console()],
621
+ }),
622
+ });
623
+ ```
624
+
625
+ **console** (zero setup, good for development):
626
+ ```typescript
627
+ const auth = createAuth({
628
+ mode: 'server',
629
+ // ...other config
630
+ logger: console,
631
+ });
632
+ ```
633
+
634
+ Works identically in **client mode**:
635
+ ```typescript
636
+ const auth = createAuth({
637
+ mode: 'client',
638
+ keyUri: 'https://auth.myapp.com/auth/keys',
639
+ logger: pino(),
640
+ });
641
+ ```
642
+
643
+ ### Customising the service name
644
+
645
+ By default every log entry contains `"service": "sentri"`. Override it with `loggerService`:
646
+
647
+ ```typescript
648
+ const auth = createAuth({
649
+ // ...
650
+ logger: pino(),
651
+ loggerService: 'auth-service',
652
+ });
653
+ ```
654
+
655
+ ### Request ID correlation
656
+
657
+ If you mount a request-ID middleware **before** Sentri, the `requestId` is automatically included in every log entry for that request:
658
+
659
+ ```typescript
660
+ import { randomUUID } from 'crypto';
661
+
662
+ app.use((req, _res, next) => {
663
+ req.requestId = randomUUID();
664
+ next();
665
+ });
666
+
667
+ app.use('/auth', auth.router());
668
+ ```
669
+
670
+ Sample pino output:
671
+ ```json
672
+ {"level":30,"time":1719484800000,"service":"sentri","event":"auth.login.success","userId":"usr_abc","duration_ms":42,"requestId":"d4e5f6"}
673
+ {"level":40,"time":1719484801000,"service":"sentri","event":"auth.authorize.denied","userId":"usr_abc","userRoles":["user"],"requiredRoles":["admin"],"requestId":"d4e5f7"}
674
+ ```
675
+
676
+ ### Log events
677
+
678
+ | Event | Level | Emitted by | Key fields |
679
+ |-------|-------|------------|-----------|
680
+ | `auth.protect.success` | info | `protect()` | `userId`, `mode` |
681
+ | `auth.protect.failure` | warn | `protect()` | `errorCode`, `mode` |
682
+ | `auth.protect.token_revoked` | warn | `protect()` | `userId`, `mode` |
683
+ | `auth.protect.auto_refresh` | info | `protect()` | `userId`, `mode` |
684
+ | `auth.authorize.passed` | info | `authorize()` | `userId`, `userRoles`, `requiredRoles` |
685
+ | `auth.authorize.denied` | warn | `authorize()` | `userId`, `userRoles`, `requiredRoles` |
686
+ | `auth.authorize.unauthenticated` | warn | `authorize()` | `requiredRoles` |
687
+ | `auth.permit.passed` | info | `permit()` | `userId` |
688
+ | `auth.permit.denied` | warn | `permit()` | `userId` |
689
+ | `auth.permit.role_bypass` | info | `permit()` | `userId`, `bypassedByRole` |
690
+ | `auth.permit.unauthenticated` | warn | `permit()` | — |
691
+ | `auth.register.success` | info | router | `userId`, `duration_ms` |
692
+ | `auth.register.failure` | warn | router | `errorCode`, `duration_ms` |
693
+ | `auth.login.success` | info | router | `userId`, `duration_ms` |
694
+ | `auth.login.failure` | warn | router | `errorCode`, `duration_ms` |
695
+ | `auth.refresh.success` | info | router | `userId`, `duration_ms` |
696
+ | `auth.refresh.failure` | warn | router | `errorCode`, `duration_ms` |
697
+ | `auth.logout` | info | router | `duration_ms` |
698
+ | `auth.logout_all` | info | router | `userId`, `duration_ms` |
699
+ | `auth.password.changed` | info | router | `userId`, `duration_ms` |
700
+ | `auth.password.change_failure` | warn | router | `userId`, `errorCode`, `duration_ms` |
701
+ | `auth.roles.assigned` | info | router | `targetUserId`, `roles`, `duration_ms` |
702
+ | `auth.roles.assign_failure` | warn | router | `targetUserId`, `errorCode`, `duration_ms` |
703
+ | `auth.identifiers.created` | info | router | `userId`, `count`, `duration_ms` |
704
+ | `auth.identifiers.updated` | info | router | `userId`, `count`, `duration_ms` |
705
+ | `auth.identifiers.deleted` | info | router | `userId`, `duration_ms` |
706
+ | `auth.identifiers.primary_changed` | info | router | `userId`, `duration_ms` |
707
+
708
+ All entries include `service` (configurable via `loggerService`) and `requestId` when available.
709
+
710
+ ### `SentriLogger` interface
711
+
712
+ ```typescript
713
+ import type { SentriLogger } from 'sentri';
714
+
715
+ const myLogger: SentriLogger = {
716
+ info(data: Record<string, unknown>) { /* ... */ },
717
+ warn(data: Record<string, unknown>) { /* ... */ },
718
+ error(data: Record<string, unknown>) { /* ... */ },
719
+ };
720
+ ```
721
+
722
+ ---
723
+
456
724
  ## Migration Guide
457
725
 
726
+ ### 4.0.0 Breaking Changes
727
+
728
+ | What changed | Action required |
729
+ |---|---|
730
+ | `sentri_users` no longer has an `identifier` column | Drop and recreate tables — identities now live in `sentri_identifiers` |
731
+ | New `sentri_identifiers` table | Created automatically by `auth.migrate()` |
732
+ | `RegisterInput.identifier` → `RegisterInput.identifiers` (array) | Update register calls to pass `identifiers: [{ type, value }]` |
733
+ | `AuthUser` now includes `identifierType` | Update any code reading `req.user` to expect this new field |
734
+ | `PATCH /me/identifier` removed | Use `PUT /me/identifiers` for updates, `PATCH /me/identifiers/primary` for primary change |
735
+ | `changeIdentifier()` removed from `ServerAuthClient` | Use `bulkUpdateIdentifiers()` or `changePrimaryIdentifier()` |
736
+ | New error codes: `IDENTIFIER_NOT_FOUND`, `IDENTIFIER_ALREADY_EXISTS` | Handle these in your error handlers as needed |
737
+
458
738
  ### 3.0.0 Breaking Changes
459
739
 
460
740
  | What changed | Action required |
package/dist/cli.js CHANGED
@@ -34,6 +34,12 @@ export const sentriAuth = createAuthServer<Role>({
34
34
 
35
35
  // -- Token revocation (optional) --------------------------------------------
36
36
  // isTokenRevoked: async (sessionId) => false,
37
+
38
+ // -- Logger (optional) ------------------------------------------------------
39
+ // Accepts any logger compatible with pino, winston, or console.
40
+ // When omitted, sentri produces no log output.
41
+ // logger: console,
42
+ // loggerService: 'sentri', // default: 'sentri'
37
43
  });
38
44
  `,d=`# -- Server -------------------------------------------------------------------
39
45
  PORT=3000
@@ -66,14 +72,20 @@ export const sentriAuth = createAuth<Role>({
66
72
  // -- Roles (optional) -------------------------------------------------------
67
73
  // Only used for TypeScript type safety on authorize() \u2014 not validated at runtime.
68
74
  validRoles: ['admin', 'user'],
75
+
76
+ // -- Logger (optional) ------------------------------------------------------
77
+ // Accepts any logger compatible with pino, winston, or console.
78
+ // When omitted, sentri produces no log output.
79
+ // logger: console,
80
+ // loggerService: 'auth-service', // default: 'sentri'
69
81
  });
70
- `,v=`# -- Server -------------------------------------------------------------------
82
+ `,g=`# -- Server -------------------------------------------------------------------
71
83
  PORT=3000
72
84
 
73
85
  # -- Auth Server --------------------------------------------------------------
74
86
  # URL of the auth server's GET /auth/keys endpoint (JWKS).
75
87
  AUTH_KEY_URI=http://localhost:3000/auth/keys
76
- `;function c(){console.log(`
88
+ `;function a(){console.log(`
77
89
  sentri \u2014 auth/authorization library for Express
78
90
 
79
91
  Usage:
@@ -86,9 +98,9 @@ Commands:
86
98
  Examples:
87
99
  npx sentri init server
88
100
  npx sentri init client
89
- `);}async function o(e,t,n){if(existsSync(e)){console.log(` skip ${n} (already exists)`);return}await writeFile(e,t,"utf8"),console.log(` create ${n}`);}async function E(){let e=process.cwd(),t=join(e,"src","lib");await mkdir(t,{recursive:true}),console.log(`
101
+ `);}async function s(e,o,n){if(existsSync(e)){console.log(` skip ${n} (already exists)`);return}await writeFile(e,o,"utf8"),console.log(` create ${n}`);}async function v(){let e=process.cwd(),o=join(e,"src","lib");await mkdir(o,{recursive:true}),console.log(`
90
102
  Generating sentri server mode files...
91
- `),await o(join(t,"sentri.ts"),u,"src/lib/sentri.ts"),await o(join(e,".env.example"),d,".env.example"),console.log(`
103
+ `),await s(join(o,"sentri.ts"),u,"src/lib/sentri.ts"),await s(join(e,".env.example"),d,".env.example"),console.log(`
92
104
  Done. Next steps:
93
105
 
94
106
  1. Copy .env.example to .env and fill in your values
@@ -103,9 +115,9 @@ Done. Next steps:
103
115
  3. Protect routes:
104
116
 
105
117
  app.get('/me', sentriAuth.protect(), (req, res) => res.json(req.user));
106
- `);}async function S(){let e=process.cwd(),t=join(e,"src","lib");await mkdir(t,{recursive:true}),console.log(`
118
+ `);}async function h(){let e=process.cwd(),o=join(e,"src","lib");await mkdir(o,{recursive:true}),console.log(`
107
119
  Generating sentri client mode files...
108
- `),await o(join(t,"sentri.ts"),m,"src/lib/sentri.ts"),await o(join(e,".env.example"),v,".env.example"),console.log(`
120
+ `),await s(join(o,"sentri.ts"),m,"src/lib/sentri.ts"),await s(join(e,".env.example"),g,".env.example"),console.log(`
109
121
  Done. Next steps:
110
122
 
111
123
  1. Copy .env.example to .env and set AUTH_KEY_URI to your auth server's /auth/keys endpoint
@@ -115,4 +127,4 @@ Done. Next steps:
115
127
 
116
128
  app.get('/protected', sentriAuth.protect(), (req, res) => res.json(req.user));
117
129
  app.use(sentriAuth.errorHandler());
118
- `);}var h=process.argv.slice(2),[s,i]=h;(!s||s==="--help"||s==="-h")&&(c(),process.exit(0));s==="init"?i==="server"?E().catch(e=>{console.error(e),process.exit(1);}):i==="client"?S().catch(e=>{console.error(e),process.exit(1);}):(console.error("Usage: npx sentri init <server|client>"),process.exit(1)):(console.error(`Unknown command: ${s}`),c(),process.exit(1));
130
+ `);}var E=process.argv.slice(2),[r,i]=E;(!r||r==="--help"||r==="-h")&&(a(),process.exit(0));r==="init"?i==="server"?v().catch(e=>{console.error(e),process.exit(1);}):i==="client"?h().catch(e=>{console.error(e),process.exit(1);}):(console.error("Usage: npx sentri init <server|client>"),process.exit(1)):(console.error(`Unknown command: ${r}`),a(),process.exit(1));
package/dist/index.d.ts CHANGED
@@ -7,6 +7,8 @@ import { Request, ErrorRequestHandler, RequestHandler, Router } from 'express';
7
7
  * - `INVALID_CREDENTIALS` — identifier or password did not match (intentionally vague to prevent user enumeration)
8
8
  * - `USER_NOT_FOUND` — an operation required a user that does not exist
9
9
  * - `USER_ALREADY_EXISTS` — registration was attempted with an identifier already in the database
10
+ * - `IDENTIFIER_NOT_FOUND` — a referenced identifier ID does not exist or does not belong to the user
11
+ * - `IDENTIFIER_ALREADY_EXISTS` — the identifier value is already taken by another user
10
12
  * - `TOKEN_EXPIRED` — the JWT was valid but its `exp` claim is in the past
11
13
  * - `TOKEN_INVALID` — the JWT could not be verified (bad signature, malformed, wrong type)
12
14
  * - `FORBIDDEN` — the user is authenticated but lacks the required role
@@ -18,7 +20,7 @@ import { Request, ErrorRequestHandler, RequestHandler, Router } from 'express';
18
20
  * When you extend {@link SentriError} for your own error types you can use any
19
21
  * string as `code` — it does not need to be one of these built-in values.
20
22
  */
21
- type SentriErrorCode = 'INVALID_CREDENTIALS' | 'USER_NOT_FOUND' | 'USER_ALREADY_EXISTS' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'FORBIDDEN' | 'UNAUTHORIZED' | 'INVALID_ROLE' | 'VALIDATION_ERROR' | 'CONFIGURATION_ERROR';
23
+ type SentriErrorCode = 'INVALID_CREDENTIALS' | 'USER_NOT_FOUND' | 'USER_ALREADY_EXISTS' | 'IDENTIFIER_NOT_FOUND' | 'IDENTIFIER_ALREADY_EXISTS' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'FORBIDDEN' | 'UNAUTHORIZED' | 'INVALID_ROLE' | 'VALIDATION_ERROR' | 'CONFIGURATION_ERROR';
22
24
  /**
23
25
  * Default HTTP status codes for built-in error codes.
24
26
  * Custom codes not in this map default to 500.
@@ -91,16 +93,73 @@ declare class SentriError extends Error {
91
93
  constructor(code: SentriErrorCode | (string & {}), message: string, statusCode?: number);
92
94
  }
93
95
 
96
+ /**
97
+ * Minimal structured logger interface accepted by Sentri.
98
+ *
99
+ * Compatible out-of-the-box with **pino**, **winston**, and **console** — all
100
+ * three expose `info`, `warn`, and `error` methods that accept an object argument.
101
+ *
102
+ * Pass an instance via `logger` in `ServerAuthConfig` or `ClientAuthConfig`.
103
+ * When omitted, Sentri produces no log output (no-op default).
104
+ *
105
+ * Each call receives a plain `Record<string, unknown>` containing structured
106
+ * fields — never a pre-formatted string — so your logger controls serialisation,
107
+ * output destination, and timestamp format.
108
+ *
109
+ * @example
110
+ * // pino
111
+ * import pino from 'pino';
112
+ * const auth = createAuth({ ..., logger: pino() });
113
+ *
114
+ * @example
115
+ * // winston
116
+ * import winston from 'winston';
117
+ * const auth = createAuth({ ..., logger: winston.createLogger({ ... }) });
118
+ *
119
+ * @example
120
+ * // console (zero setup, useful for development)
121
+ * const auth = createAuth({ ..., logger: console });
122
+ */
123
+ interface SentriLogger {
124
+ info(data: Record<string, unknown>): void;
125
+ warn(data: Record<string, unknown>): void;
126
+ error(data: Record<string, unknown>): void;
127
+ }
128
+
94
129
  interface ApiResponse<T = null> {
95
130
  error: boolean;
96
131
  statusCode: number;
97
132
  message: string;
98
133
  data: T | null;
99
134
  }
135
+ /** A single identifier entry belonging to a user. */
136
+ interface IdentifierRecord {
137
+ id: string;
138
+ type: string;
139
+ value: string;
140
+ isPrimary: boolean;
141
+ }
142
+ /**
143
+ * The authenticated user shape. `identifier` and `identifierType` always
144
+ * reflect the primary identifier. `identifiers` is populated only when the
145
+ * full user object is fetched (e.g. GET /me) and is absent from JWT payloads.
146
+ */
100
147
  interface AuthUser<TRole extends string = string> {
101
148
  id: string;
149
+ /** Primary identifier value — embedded in the JWT payload. */
102
150
  identifier: string;
151
+ /** Type of the primary identifier (e.g. 'email', 'username'). */
152
+ identifierType: string;
103
153
  roles: TRole[];
154
+ /** All identifiers for this user. Only present in full user responses, not in JWT. */
155
+ identifiers?: IdentifierRecord[];
156
+ }
157
+ /** Input for a single identifier entry. */
158
+ interface IdentifierInput {
159
+ /** Arbitrary label such as 'email', 'username', or 'phone'. */
160
+ type: string;
161
+ /** The globally unique identifier value. */
162
+ value: string;
104
163
  }
105
164
  type RegisterResult<TRole extends string = string> = {
106
165
  success: true;
@@ -132,9 +191,16 @@ type GetUserResult<TRole extends string = string> = {
132
191
  success: false;
133
192
  error: SentriError;
134
193
  };
135
- type ChangeIdentifierResult<TRole extends string = string> = {
194
+ type BulkIdentifiersResult = {
136
195
  success: true;
137
- user: AuthUser<TRole>;
196
+ identifiers: IdentifierRecord[];
197
+ } | {
198
+ success: false;
199
+ error: SentriError;
200
+ };
201
+ type ChangePrimaryResult = {
202
+ success: true;
203
+ identifiers: IdentifierRecord[];
138
204
  } | {
139
205
  success: false;
140
206
  error: SentriError;
@@ -155,11 +221,17 @@ type RefreshResult<TRole extends string = string> = {
155
221
  error: SentriError;
156
222
  };
157
223
  interface RegisterInput<TRole extends string = string> {
158
- identifier: string;
224
+ /**
225
+ * One or more identifiers for the new user. The first entry becomes the
226
+ * primary identifier (embedded in the JWT payload). All values must be
227
+ * globally unique. At least one identifier is required.
228
+ */
229
+ identifiers: IdentifierInput[];
159
230
  password: string;
160
231
  roles?: TRole[];
161
232
  }
162
233
  interface LoginInput {
234
+ /** Any of the user's identifier values — Sentri searches all types. */
163
235
  identifier: string;
164
236
  password: string;
165
237
  }
@@ -188,7 +260,14 @@ interface RouterHandlers {
188
260
  logout?: (refreshToken: string | undefined) => Promise<void>;
189
261
  logoutAll?: (userId: string) => Promise<void>;
190
262
  assignRoles?: (userId: string, roles: string[]) => Promise<AssignRolesResult>;
191
- changeIdentifier?: (userId: string, newIdentifier: string) => Promise<ChangeIdentifierResult>;
263
+ bulkCreateIdentifiers?: (userId: string, identifiers: IdentifierInput[]) => Promise<BulkIdentifiersResult>;
264
+ bulkUpdateIdentifiers?: (userId: string, updates: Array<{
265
+ id: string;
266
+ type: string;
267
+ value: string;
268
+ }>) => Promise<BulkIdentifiersResult>;
269
+ bulkDeleteIdentifiers?: (userId: string, ids: string[]) => Promise<BulkIdentifiersResult>;
270
+ changePrimaryIdentifier?: (userId: string, identifierId: string) => Promise<ChangePrimaryResult>;
192
271
  changePassword?: (userId: string, currentPassword: string, newPassword: string) => Promise<ChangePasswordResult>;
193
272
  }
194
273
  interface ServerAuthConfig<TRole extends string = string> {
@@ -226,6 +305,16 @@ interface ServerAuthConfig<TRole extends string = string> {
226
305
  * instead of an in-memory Map — required for multi-process deployments.
227
306
  */
228
307
  redisUrl?: string;
308
+ /**
309
+ * Structured logger instance. Compatible with pino, winston, or console.
310
+ * When omitted, Sentri produces no log output.
311
+ */
312
+ logger?: SentriLogger;
313
+ /**
314
+ * Service name embedded in every log entry.
315
+ * @default 'sentri'
316
+ */
317
+ loggerService?: string;
229
318
  }
230
319
  interface ClientAuthConfig<TRole extends string = string> {
231
320
  mode: 'client';
@@ -233,6 +322,16 @@ interface ClientAuthConfig<TRole extends string = string> {
233
322
  keyUri: string;
234
323
  /** Optional — only needed for TypeScript type safety on authorize(). */
235
324
  validRoles?: readonly TRole[];
325
+ /**
326
+ * Structured logger instance. Compatible with pino, winston, or console.
327
+ * When omitted, Sentri produces no log output.
328
+ */
329
+ logger?: SentriLogger;
330
+ /**
331
+ * Service name embedded in every log entry.
332
+ * @default 'sentri'
333
+ */
334
+ loggerService?: string;
236
335
  }
237
336
  type AuthConfig<TRole extends string = string> = ServerAuthConfig<TRole> | ClientAuthConfig<TRole>;
238
337
 
@@ -406,15 +505,13 @@ interface ServerAuthClient<TRole extends string = string> extends AuthClient<TRo
406
505
  /** Extract the raw access token from an Express request. */
407
506
  getCurrentAccessToken(request: Request): string | undefined;
408
507
  /**
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)
508
+ * Pre-built Express Router with auth endpoints.
509
+ * See README for the full endpoint list.
413
510
  */
414
511
  router(): Router;
415
512
  /**
416
- * Run database migrations to create sentri_users and sentri_sessions tables.
417
- * Safe to call on every startup — uses IF NOT EXISTS.
513
+ * Run database migrations to create sentri_users, sentri_sessions, and
514
+ * sentri_identifiers tables. Safe to call on every startup — uses IF NOT EXISTS.
418
515
  */
419
516
  migrate(): Promise<void>;
420
517
  /**
@@ -423,9 +520,9 @@ interface ServerAuthClient<TRole extends string = string> extends AuthClient<TRo
423
520
  * Uses Redis backend when `redisUrl` is set in server config; otherwise in-memory.
424
521
  */
425
522
  idempotencyMiddleware(options?: IdempotencyOptions): RequestHandler;
426
- /** Register a new user. */
523
+ /** Register a new user with one or more identifiers. */
427
524
  register(input: RegisterInput<TRole>): Promise<RegisterResult<TRole>>;
428
- /** Authenticate a user, returns access + refresh tokens. */
525
+ /** Authenticate a user by any of their identifier values, returns access + refresh tokens. */
429
526
  login(input: LoginInput): Promise<AuthResult<TRole>>;
430
527
  /** Rotate a refresh token, returns new token pair. */
431
528
  refresh(refreshToken: string): Promise<RefreshResult<TRole>>;
@@ -433,14 +530,24 @@ interface ServerAuthClient<TRole extends string = string> extends AuthClient<TRo
433
530
  logout(refreshToken: string): Promise<void>;
434
531
  /** Invalidate all sessions for a user. */
435
532
  logoutAll(userId: string): Promise<void>;
436
- /** Fetch a user by ID. Returns `success: false` if not found. */
533
+ /** Fetch a user by ID, including all their identifiers. Returns `success: false` if not found. */
437
534
  getUser(userId: string): Promise<GetUserResult<TRole>>;
438
- /** Change a user's identifier (username/email). */
439
- changeIdentifier(userId: string, newIdentifier: string): Promise<ChangeIdentifierResult<TRole>>;
440
535
  /** Change a user's password and revoke all their sessions. */
441
536
  changePassword(userId: string, currentPassword: string, newPassword: string): Promise<ChangePasswordResult>;
442
537
  /** Add roles to a user (existing roles are preserved). */
443
538
  assignRoles(userId: string, roles: TRole[]): Promise<AssignRolesResult<TRole>>;
539
+ /** Add new identifiers to a user in bulk. Returns the full updated identifier list. */
540
+ bulkCreateIdentifiers(userId: string, identifiers: IdentifierInput[]): Promise<BulkIdentifiersResult>;
541
+ /** Update type and value for multiple identifiers in bulk. Returns the full updated identifier list. */
542
+ bulkUpdateIdentifiers(userId: string, updates: Array<{
543
+ id: string;
544
+ type: string;
545
+ value: string;
546
+ }>): Promise<BulkIdentifiersResult>;
547
+ /** Delete multiple identifiers by ID. At least one identifier must remain. Returns the remaining list. */
548
+ bulkDeleteIdentifiers(userId: string, ids: string[]): Promise<BulkIdentifiersResult>;
549
+ /** Change which identifier is marked as primary (embedded in JWT on next login/refresh). */
550
+ changePrimaryIdentifier(userId: string, identifierId: string): Promise<ChangePrimaryResult>;
444
551
  }
445
552
  interface ClientAuthClient<TRole extends string = string> extends AuthClient<TRole> {
446
553
  }
@@ -502,6 +609,17 @@ interface CreateServerOptions<TRole extends string = string> {
502
609
  * When set, `auth.idempotencyMiddleware()` uses Redis as the cache backend.
503
610
  */
504
611
  redisUrl?: string;
612
+ /**
613
+ * Structured logger for all auth events.
614
+ * Accepts any object with `info`, `warn`, `error` methods (pino, winston, console).
615
+ * When omitted, sentri produces no log output.
616
+ */
617
+ logger?: SentriLogger;
618
+ /**
619
+ * Service name added to every log entry as `service`.
620
+ * Defaults to `'sentri'`.
621
+ */
622
+ loggerService?: string;
505
623
  }
506
624
  /**
507
625
  * Create a Sentri auth server for PostgreSQL.
@@ -549,4 +667,4 @@ declare global {
549
667
  }
550
668
  }
551
669
 
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 };
670
+ export { type AccessCookieConfig, type ApiResponse, type AssignRolesResult, type AuthClient, type AuthConfig, type AuthHooks, type AuthResult, type AuthUser, type BulkIdentifiersResult, type ChangePasswordResult, type ChangePrimaryResult, type ClientAuthClient, type ClientAuthConfig, type CookieConfig, type CreateServerOptions, type ErrorHandlerOptions, type GetUserResult, type IdempotencyOptions, type IdentifierInput, type IdentifierRecord, type LoginInput, type PermitCheck, type PermitOptions, type PostgresConfig, type RefreshResult, type RegisterInput, type RegisterResult, type RouterHandlers, SENTRI_ERROR_STATUS, SentriError, type SentriErrorCode, type SentriLogger, type ServerAuthClient, type ServerAuthConfig, createAuth, createAuthServer, createErrorHandler, createIdempotencyMiddleware, getCurrentAccessToken, register };
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import Re from'bcrypt';import H from'jsonwebtoken';import {generateKeyPairSync,randomUUID,createPublicKey,createPrivateKey}from'crypto';import {Kysely,sql,PostgresDialect}from'kysely';import {Router}from'express';import {Redis}from'ioredis';import {Pool}from'pg';var pe=Object.assign(Object.create(null),{UNAUTHORIZED:401,TOKEN_EXPIRED:401,TOKEN_INVALID:401,INVALID_CREDENTIALS:401,FORBIDDEN:403,USER_NOT_FOUND:404,USER_ALREADY_EXISTS:409,INVALID_ROLE:400,VALIDATION_ERROR:400,CONFIGURATION_ERROR:500}),i=class extends Error{code;statusCode;constructor(r,s,t){super(s),this.name="SentriError",this.code=r,this.statusCode=t??pe[r]??500;}};async function D(e,r=12){return Re.hash(e,r)}async function K(e,r){return Re.compare(e,r)}var ye=new Map,we=new Map,nr=3600*1e3;function Ce(e){let r=ye.get(e);if(!r){let s=createPrivateKey(e),t=createPublicKey(s),n=t.export({format:"jwk"}),o=Buffer.from(e).slice(0,8).toString("base64url"),c={...n,use:"sig",kid:o};r={kid:o,publicKey:t,jwk:c},ye.set(e,r);}return r}function ke(e){let{jwk:r}=Ce(e);return {keys:[r]}}function Ie(e){return Ce(e).publicKey}async function Se(e){let r=Date.now(),s=we.get(e);if(s&&r-s.fetchedAt<nr)return s.publicKey;let t=await fetch(e);if(!t.ok)throw new i("CONFIGURATION_ERROR",`Failed to fetch public key from ${e}: HTTP ${t.status}`);let n=await t.json();if(!n.keys||n.keys.length===0)throw new i("CONFIGURATION_ERROR",`No keys found in JWKS response from ${e}`);let o=n.keys[0],c=createPublicKey({key:o,format:"jwk"});return we.set(e,{publicKey:c,fetchedAt:r}),c}var Te=new WeakMap,ve=32,Ee=10,xe=31;function _e(e){if(e.mode==="client"){if(!e.keyUri||e.keyUri.trim().length===0)throw new i("CONFIGURATION_ERROR","keyUri must not be empty");return}if(!e.secret||e.secret.trim().length===0)throw new i("CONFIGURATION_ERROR","secret must not be empty");if((e.algorithm??"HS256").startsWith("HS")&&e.secret.length<ve)throw new i("CONFIGURATION_ERROR",`secret must be at least ${ve} characters for HMAC algorithms`);let t=e.saltRounds??12;if(!Number.isInteger(t)||t<Ee||t>xe)throw new i("CONFIGURATION_ERROR",`saltRounds must be an integer between ${Ee} and ${xe}`);if(!e.validRoles||e.validRoles.length===0)throw new i("CONFIGURATION_ERROR","validRoles must contain at least one role");if(!e.dialect)throw new i("CONFIGURATION_ERROR","dialect is required in server mode")}function p(e){let r=Te.get(e);if(r)return r;let s={algorithm:e.algorithm??"HS256",accessExpiresIn:e.accessExpiresIn??"15m",refreshExpiresIn:e.refreshExpiresIn??"7d",saltRounds:e.saltRounds??12,validRoles:e.validRoles,validRolesSet:new Set(e.validRoles),cookieName:e.cookie?.name??"refresh_token",accessCookieName:e.accessCookie?.name??"access_token"};return Te.set(e,s),s}var or=/^(\d+)([smhdw])$/,ir={s:1e3,m:6e4,h:36e5,d:864e5,w:6048e5},be=new Map;function x(e){if(typeof e=="number")return e*1e3;let r=be.get(e);if(r!==void 0)return r;let s=or.exec(e);if(!s?.[1]||!s?.[2])throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let t=ir[s[2]];if(t===void 0)throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let n=parseInt(s[1],10)*t;return be.set(e,n),n}var Pe=new Map,Oe=new Map,Ne=new Map;function Ue(e){return e.startsWith("RS")||e.startsWith("PS")}function De(e){let r=Pe.get(e);return r||(r={access:`${e}:access`,refresh:`${e}:refresh`},Pe.set(e,r)),r}function cr(e){let r=Oe.get(e);return r||(r=createPrivateKey(e),Oe.set(e,r)),r}function Ke(e){let r=p(e);if(Ue(r.algorithm)){let n=cr(e.secret);return {accessKey:n,refreshKey:n}}let{access:s,refresh:t}=De(e.secret);return {accessKey:s,refreshKey:t}}function He(e,r){let s=p(e);if(Ue(s.algorithm))return Ie(e.secret);let{access:t,refresh:n}=De(e.secret);return r==="access"?t:n}function je(e,r,s,t){let n=`${s}:${t}`,o=Ne.get(n);return o||(o={expiresIn:s,algorithm:t},Ne.set(n,o)),H.sign(e,r,o)}function Le(e,r,s){try{let t=H.verify(e,r,{algorithms:[s]});if(typeof t=="string"||t===null)throw new i("TOKEN_INVALID","Token payload is not an object");return t}catch(t){throw t instanceof i?t:t instanceof H.TokenExpiredError?new i("TOKEN_EXPIRED","Token has expired"):new i("TOKEN_INVALID","Token is invalid or malformed")}}function j(e,r){let s=p(r),{accessKey:t}=Ke(r);return je(e,t,s.accessExpiresIn,s.algorithm)}function L(e,r){let s=p(r),{refreshKey:t}=Ke(r);return je({sessionId:e},t,s.refreshExpiresIn,s.algorithm)}function z(e,r){let s=p(r),t=He(r,"access");return Le(e,t,s.algorithm)}function F(e,r){let s=p(r),t=He(r,"refresh");return Le(e,t,s.algorithm)}function Fe(e,r){try{let s=H.verify(e,r,{algorithms:["RS256","RS384","RS512","PS256","PS384","PS512"]});if(typeof s=="string"||s===null)throw new i("TOKEN_INVALID","Token payload is not an object");return s}catch(s){throw s instanceof i?s:s instanceof H.TokenExpiredError?new i("TOKEN_EXPIRED","Token has expired"):new i("TOKEN_INVALID","Token is invalid or malformed")}}function E(e){return p(e).cookieName}function b(e,r){if(!e)return;let s=`${r}=`,t=0;for(;t<e.length;){for(;t<e.length&&e[t]===" ";)t++;let n=e.indexOf(";",t),o=n===-1?e.length:n;if(e.startsWith(s,t))return e.slice(t+s.length,o);t=o+1;}}function q(e,r,s){let t=s.cookie??{},n=p(s),o=x(n.refreshExpiresIn);e.cookie(E(s),r,{httpOnly:t.httpOnly??true,secure:t.secure??false,sameSite:t.sameSite??"strict",path:t.path??"/",maxAge:o});}function W(e,r){let s=r.cookie??{};e.clearCookie(E(r),{path:s.path??"/"});}function ce(e){return p(e).accessCookieName}function M(e,r,s){if(!s.accessCookie)return;let t=s.accessCookie,n=p(s),o=x(n.accessExpiresIn);e.cookie(ce(s),r,{httpOnly:false,secure:t.secure??false,sameSite:t.sameSite??"strict",path:t.path??"/",maxAge:o});}function ue(e,r){if(!r.accessCookie)return;let s=r.accessCookie;e.clearCookie(ce(r),{path:s.path??"/"});}function $(e,r){let s=e.headers.authorization;return s?.startsWith("Bearer ")?s.slice(7):b(e.headers.cookie,ce(r))}var qe=new Map;function k(e){let r=qe.get(e);return r||(r=new Kysely({dialect:e}),qe.set(e,r)),r}function de(e){try{return JSON.parse(e)}catch{return []}}function $e(e){return JSON.stringify(e)}async function Z(e,r){let s=await e.selectFrom("sentri_users").selectAll().where("identifier","=",r).executeTakeFirst();return s?{id:s.id,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}:null}async function J(e,r){let s=await e.selectFrom("sentri_users").selectAll().where("id","=",r).executeTakeFirst();return s?{id:s.id,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}:null}async function Je(e,r){let s=randomUUID();return await e.insertInto("sentri_users").values({id:s,identifier:r.identifier,password_hash:r.passwordHash,roles:$e(r.roles)}).execute(),{id:s}}async function Ge(e,r,s){await e.updateTable("sentri_users").set({identifier:s}).where("id","=",r).execute();}async function Xe(e,r,s){await e.updateTable("sentri_users").set({password_hash:s}).where("id","=",r).execute();}async function Ve(e,r,s){await e.updateTable("sentri_users").set({roles:$e(s)}).where("id","=",r).execute();}async function le(e,r){let s=randomUUID();return await e.insertInto("sentri_sessions").values({id:s,user_id:r.userId,expires_at:r.expiresAt}).execute(),{id:s}}async function Be(e,r){let s=await e.selectFrom("sentri_sessions as s").innerJoin("sentri_users as u","u.id","s.user_id").select(["s.id as session_id","s.user_id","s.expires_at","s.created_at as session_created_at","u.id as user_id_col","u.identifier","u.password_hash","u.roles"]).where("s.id","=",r).executeTakeFirst();return s?{id:s.session_id,userId:s.user_id,expiresAt:new Date(s.expires_at),createdAt:new Date(s.session_created_at),user:{id:s.user_id_col,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}}:null}async function Y(e,r){await e.deleteFrom("sentri_sessions").where("id","=",r).execute();}async function fe(e,r){await e.deleteFrom("sentri_sessions").where("user_id","=",r).execute();}async function G(e,r){let s=p(r),t=k(r.dialect),n=e.roles??[],o=n.filter(w=>!s.validRolesSet.has(w));if(o.length>0)return {success:false,error:new i("INVALID_ROLE",`Invalid roles: ${o.join(", ")}`)};let c=e.identifier.trim();if(await Z(t,c))return {success:false,error:new i("USER_ALREADY_EXISTS","User already exists")};let g=await D(e.password,s.saltRounds);return {success:true,user:{id:(await Je(t,{identifier:c,passwordHash:g,roles:n})).id,identifier:c,roles:n}}}async function Q(e,r){let s=p(r),t=k(r.dialect),n=await Z(t,e.identifier.trim());if(!n)return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};if(!await K(e.password,n.passwordHash))return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};let c=new Date(Date.now()+x(s.refreshExpiresIn)),d=await le(t,{userId:n.id,expiresAt:c}),g={id:n.id,identifier:n.identifier,roles:n.roles},m=j({id:n.id,identifier:n.identifier,roles:n.roles,sessionId:d.id},r),w=L(d.id,r);return {success:true,accessToken:m,refreshToken:w,user:g}}async function _(e,r){let s=p(r),t=k(r.dialect),n;try{({sessionId:n}=F(e,r));}catch(v){return v instanceof i?{success:false,error:v}:{success:false,error:new i("TOKEN_INVALID","Invalid refresh token")}}let o=await Be(t,n);if(!o)return {success:false,error:new i("UNAUTHORIZED","Session not found or revoked")};if(o.expiresAt.getTime()<Date.now())return await Y(t,n),{success:false,error:new i("TOKEN_EXPIRED","Session has expired")};await Y(t,n);let c=new Date(Date.now()+x(s.refreshExpiresIn)),d=await le(t,{userId:o.userId,expiresAt:c}),g={id:o.user.id,identifier:o.user.identifier,roles:o.user.roles},m=j({id:g.id,identifier:g.identifier,roles:g.roles,sessionId:d.id},r),w=L(d.id,r);return {success:true,accessToken:m,refreshToken:w,user:g}}async function ee(e,r){let s=k(r.dialect),t;try{({sessionId:t}=F(e,r));}catch{return}await Y(s,t);}async function re(e,r){let s=k(r.dialect);await fe(s,e);}async function ze(e,r){let s=k(r.dialect),t=await J(s,e);return t?{success:true,user:{id:t.id,identifier:t.identifier,roles:t.roles}}:{success:false,error:new i("USER_NOT_FOUND","User not found")}}async function te(e,r,s){let t=k(s.dialect),n=await J(t,e);if(!n)return {success:false,error:new i("USER_NOT_FOUND","User not found")};let o=r.trim();return await Z(t,o)?{success:false,error:new i("USER_ALREADY_EXISTS","Identifier already taken")}:(await Ge(t,e,o),{success:true,user:{id:n.id,identifier:o,roles:n.roles}})}async function se(e,r,s,t){let n=p(t),o=k(t.dialect),c=await J(o,e);if(!c)return {success:false,error:new i("USER_NOT_FOUND","User not found")};if(!await K(r,c.passwordHash))return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};let g=await D(s,n.saltRounds);return await Xe(o,e,g),await fe(o,e),{success:true}}async function ne(e,r,s){let t=p(s),n=k(s.dialect),o=r.filter(m=>!t.validRolesSet.has(m));if(o.length>0)return {success:false,error:new i("INVALID_ROLE",`Invalid roles: ${o.join(", ")}`)};let c=await J(n,e);if(!c)return {success:false,error:new i("USER_NOT_FOUND","User not found")};let d=new Set(c.roles);for(let m of r)d.add(m);let g=Array.from(d);return await Ve(n,e,g),{success:true,user:{id:c.id,identifier:c.identifier,roles:g}}}function S(e){return e.mode==="client"?dr(e.keyUri):lr(e)}function dr(e){return async(r,s,t)=>{let n=r.headers.authorization,o=n?.startsWith("Bearer ")?n.slice(7):void 0;if(!o)return t(new i("UNAUTHORIZED","Missing or malformed Authorization header"));try{let c=await Se(e),d=Fe(o,c);r.user={id:d.id,identifier:d.identifier,roles:d.roles},t();}catch(c){t(c);}}}function lr(e){return async(r,s,t)=>{let n=$(r,e);if(!n)return t(new i("UNAUTHORIZED","Missing or malformed Authorization header"));try{let o=z(n,e);if(e.isTokenRevoked&&await e.isTokenRevoked(o.sessionId))return t(new i("UNAUTHORIZED","Token has been revoked"));r.user={id:o.id,identifier:o.identifier,roles:o.roles},t();}catch(o){if(o instanceof i&&o.code==="TOKEN_EXPIRED"){let c=b(r.headers.cookie,E(e));if(!c)return t(new i("UNAUTHORIZED","Token expired. Please login again."));try{let d=await _(c,e);if(!d.success)return t(new i("UNAUTHORIZED","Session expired. Please login again."));q(s,d.refreshToken,e),M(s,d.accessToken,e),s.setHeader("X-New-Access-Token",d.accessToken),r.user=d.user,t();}catch{t(new i("UNAUTHORIZED","Session expired. Please login again."));}}else t(o);}}}function X(...e){let r=`Requires one of roles: ${e.join(", ")}`;return (s,t,n)=>{if(!s.user)return n(new i("UNAUTHORIZED","Not authenticated"));let o=s.user.roles;if(!e.some(c=>o.includes(c)))return n(new i("FORBIDDEN",r));n();}}var fr=new i("FORBIDDEN","You do not have permission to perform this action");function V(e){let r=typeof e=="function"?{check:e}:e;return async(s,t,n)=>{if(!s.user)return n(new i("UNAUTHORIZED","Not authenticated"));if(r.roles&&r.roles.length>0){let o=s.user.roles;if(r.roles.some(c=>o.includes(c)))return n()}try{let o=r.check(s);(o instanceof Promise?await o:o)?n():n(fr);}catch(o){n(o);}}}function P(e){return (r,s,t,n)=>{if(r instanceof i){t.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});return}e?.onUnhandled?.(r),t.status(500).json({error:true,statusCode:500,code:"INTERNAL_SERVER_ERROR",message:"Internal server error",data:null});}}var oe=8,O=72,N=255;function y(e){return new i("VALIDATION_ERROR",e)}function T(e,r,s,t){e.status(r).json({error:false,statusCode:r,message:s,data:t});}function U(e,r){e.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});}function B(e){if(e==null||typeof e!="object"||Array.isArray(e))throw new i("VALIDATION_ERROR","Request body is missing or not a JSON object. Did you apply express.json()?");return e}function hr(e,r){if(!r.apiKey)return;let s=e.headers["x-api-key"];if(typeof s!="string"||s!==r.apiKey)throw new i("UNAUTHORIZED","Invalid or missing API key")}function ge(e){if(e)try{let r=e();r instanceof Promise&&r.catch(()=>{});}catch{}}function mr(e){return e.startsWith("RS")||e.startsWith("PS")}function We(e){let r=Router(),s=e,t=p(s),n=e.router?.register??(a=>G(a,s)),o=e.router?.login??(a=>Q(a,s)),c=e.router?.refresh??(a=>_(a,s)),d=e.router?.logout??(a=>a!==void 0?ee(a,s):Promise.resolve()),g=e.router?.logoutAll??(a=>re(a,s)),m=e.router?.assignRoles??((a,u)=>ne(a,u,s)),w=e.router?.changeIdentifier??((a,u)=>te(a,u,s)),v=e.router?.changePassword??((a,u,R)=>se(a,u,R,s));mr(t.algorithm)&&r.get("/keys",(a,u)=>{u.setHeader("Cache-Control","public, max-age=3600"),u.json(ke(e.secret));}),r.post("/register",async(a,u,R)=>{try{hr(a,e);let l=B(a.body),{identifier:f,password:h,roles:A}=l;if(typeof f!="string"||f.trim().length===0)throw y("identifier is required and must be a non-empty string");if(f.length>N)throw y(`identifier must not exceed ${N} characters`);if(typeof h!="string"||h.length<oe)throw y(`password is required and must be at least ${oe} characters`);if(h.length>O)throw y(`password must not exceed ${O} characters`);if(A!==void 0&&!Array.isArray(A))throw y("roles must be an array of strings when provided");if(Array.isArray(A)&&!A.every(tr=>typeof tr=="string"))throw y("each role must be a string");let C=Array.isArray(A)?A:void 0,ie=C!==void 0?{identifier:f.trim(),password:h,roles:C}:{identifier:f.trim(),password:h},ae=await n(ie);if(!ae.success){U(u,ae.error);return}T(u,201,"User registered successfully",{user:ae.user});}catch(l){R(l);}}),r.post("/login",async(a,u,R)=>{try{let l=B(a.body),{identifier:f,password:h}=l;if(typeof f!="string"||f.trim().length===0)throw y("identifier is required and must be a non-empty string");if(f.length>N)throw y(`identifier must not exceed ${N} characters`);if(typeof h!="string"||h.length===0)throw y("password is required");if(h.length>O)throw y(`password must not exceed ${O} characters`);let A=f.trim(),C=await o({identifier:A,password:h});if(!C.success){ge(()=>e.hooks?.onFailedLogin?.(A,C.error)),U(u,C.error);return}ge(()=>e.hooks?.onLogin?.(C.user)),q(u,C.refreshToken,e),M(u,C.accessToken,e),T(u,200,"Login successful",{accessToken:C.accessToken,user:C.user});}catch(l){R(l);}}),r.post("/refresh",async(a,u,R)=>{try{let l=b(a.headers.cookie,E(e));if(!l)throw new i("UNAUTHORIZED","Refresh token cookie is missing");let f=await c(l);if(!f.success){W(u,e),U(u,f.error);return}q(u,f.refreshToken,e),M(u,f.accessToken,e),T(u,200,"Token refreshed",{accessToken:f.accessToken});}catch(l){R(l);}}),r.post("/logout",async(a,u,R)=>{try{let l=b(a.headers.cookie,E(e));await d(l),W(u,e),ue(u,e),T(u,200,"Logged out",null);}catch(l){R(l);}}),r.post("/logout-all",S(e),async(a,u,R)=>{try{let l=a.user.id;await g(l),ge(()=>e.hooks?.onLogout?.(l)),W(u,e),ue(u,e),T(u,200,"All sessions revoked",null);}catch(l){R(l);}}),r.get("/me",S(e),(a,u)=>{T(u,200,"OK",a.user);});let I=V(a=>!!a.user);return r.patch("/me/identifier",S(e),I,async(a,u,R)=>{try{let l=B(a.body),{newIdentifier:f}=l;if(typeof f!="string"||f.trim().length===0)throw y("newIdentifier is required and must be a non-empty string");if(f.length>N)throw y(`newIdentifier must not exceed ${N} characters`);let h=await w(a.user.id,f);if(!h.success){U(u,h.error);return}T(u,200,"Identifier updated successfully",{user:h.user});}catch(l){R(l);}}),r.patch("/me/password",S(e),I,async(a,u,R)=>{try{let l=B(a.body),{currentPassword:f,newPassword:h}=l;if(typeof f!="string"||f.length===0)throw y("currentPassword is required");if(typeof h!="string"||h.length<oe)throw y(`newPassword must be at least ${oe} characters`);if(h.length>O)throw y(`newPassword must not exceed ${O} characters`);if(f===h)throw y("newPassword must be different from currentPassword");let A=await v(a.user.id,f,h);if(!A.success){U(u,A.error);return}T(u,200,"Password updated successfully. All sessions have been revoked.",null);}catch(l){R(l);}}),r.post("/users/:userId/roles",S(e),X("admin"),async(a,u,R)=>{try{let l=B(a.body),{roles:f}=l,h=a.params.userId,A=typeof h=="string"?h:void 0;if(!A)throw y("userId is required");if(!Array.isArray(f)||f.length===0)throw y("roles must be a non-empty array of strings");if(!f.every(ie=>typeof ie=="string"))throw y("each role must be a string");let C=await m(A,f);if(!C.success){U(u,C.error);return}T(u,200,"Roles assigned successfully",{user:C.user});}catch(l){R(l);}}),r.use(P()),r}var Ze=new Map;function Ye(e){let r=Ze.get(e);return r||(r=new Redis(e,{lazyConnect:false,enableOfflineQueue:false}),Ze.set(e,r)),r}function he(e){let r=e?.ttl??3e5,s=(e?.header??"X-Idempotency-Key").toLowerCase(),t=new Set((e?.methods??["POST","PUT","PATCH"]).map(o=>o.toUpperCase())),n=e?.redisUrl;return n?Rr(n,r,s,t):yr(r,s,t,e?.maxSize??1e4)}function Rr(e,r,s,t){let n=Ye(e),o="sentri:idempotency:";return async(c,d,g)=>{let m=c.headers[s];if(!m||typeof m!="string"||!t.has(c.method))return g();c.requestId=m,d.setHeader("X-Request-Id",m);let w=await n.get(`${o}${m}`);if(w){let I=JSON.parse(w);return d.setHeader("X-Idempotent-Replayed","true"),d.status(I.statusCode).json(I.body)}let v=d.json.bind(d);d.json=function(a){if(d.statusCode>=200&&d.statusCode<300){let u={statusCode:d.statusCode,body:a,expiresAt:Date.now()+r};n.set(`${o}${m}`,JSON.stringify(u),"PX",r).catch(()=>{});}return v(a)},g();}}function yr(e,r,s,t){let n=Math.max(e,5e3),o=new Map,c=setInterval(()=>{let d=Date.now();for(let[g,m]of o)m.expiresAt<=d&&o.delete(g);},n);return typeof c=="object"&&c!==null&&"unref"in c&&c.unref(),(d,g,m)=>{let w=d.headers[r];if(!w||typeof w!="string"||!s.has(d.method))return m();d.requestId=w,g.setHeader("X-Request-Id",w);let v=Date.now(),I=o.get(w);if(I&&I.expiresAt>v)return g.setHeader("X-Idempotent-Replayed","true"),g.status(I.statusCode).json(I.body);let a=g.json.bind(g);g.json=function(R){if(g.statusCode>=200&&g.statusCode<300){if(o.size>=t){let l=o.keys().next().value;l!==void 0&&o.delete(l);}o.set(w,{statusCode:g.statusCode,body:R,expiresAt:Date.now()+e});}return a(R)},m();}}async function er(e){await e.schema.createTable("sentri_users").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("identifier","varchar(255)",r=>r.notNull().unique()).addColumn("password_hash","varchar(255)",r=>r.notNull()).addColumn("roles","text",r=>r.notNull().defaultTo("[]")).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute(),await e.schema.createTable("sentri_sessions").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("user_id","varchar(36)",r=>r.notNull().references("sentri_users.id").onDelete("cascade")).addColumn("expires_at","timestamp",r=>r.notNull()).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute();}function me(e){if(_e(e),e.mode==="client")return {protect:()=>S(e),authorize:(...t)=>X(...t),permit:t=>V(t),errorHandler:t=>P(t)};let r=e,s=p(r);return {protect:()=>S(r),authorize:(...t)=>X(...t),permit:t=>V(t),hashPassword:t=>D(t,s.saltRounds),verifyPassword:(t,n)=>K(t,n),signAccessToken:t=>j(t,r),signRefreshToken:t=>L(t,r),verifyAccessToken:t=>z(t,r),verifyRefreshToken:t=>F(t,r),getCurrentAccessToken:t=>$(t,r),router:()=>We(r),migrate:()=>er(k(r.dialect)),errorHandler:t=>P(t),idempotencyMiddleware:t=>he({...t,...r.redisUrl!==void 0&&{redisUrl:r.redisUrl}}),register:t=>G(t,r),login:t=>Q(t,r),refresh:t=>_(t,r),logout:t=>ee(t,r),logoutAll:t=>re(t,r),getUser:t=>ze(t,r),changeIdentifier:(t,n)=>te(t,n,r),changePassword:(t,n,o)=>se(t,n,o,r),assignRoles:(t,n)=>ne(t,n,r)}}function rr(e){return new PostgresDialect({pool:new Pool(e)})}function kr(e){let{privateKey:r}=generateKeyPairSync("rsa",{modulusLength:2048,privateKeyEncoding:{type:"pkcs8",format:"pem"},publicKeyEncoding:{type:"spki",format:"pem"}}),s={mode:"server",dialect:rr(e.db),secret:r,algorithm:"RS256",validRoles:e.validRoles,...e.accessExpiresIn!==void 0&&{accessExpiresIn:e.accessExpiresIn},...e.refreshExpiresIn!==void 0&&{refreshExpiresIn:e.refreshExpiresIn},...e.saltRounds!==void 0&&{saltRounds:e.saltRounds},...e.apiKey!==void 0&&{apiKey:e.apiKey},...e.cookie!==void 0&&{cookie:e.cookie},...e.accessCookie!==void 0&&{accessCookie:e.accessCookie},...e.hooks!==void 0&&{hooks:e.hooks},...e.router!==void 0&&{router:e.router},...e.isTokenRevoked!==void 0&&{isTokenRevoked:e.isTokenRevoked},...e.redisUrl!==void 0&&{redisUrl:e.redisUrl}};return me(s)}export{pe as SENTRI_ERROR_STATUS,i as SentriError,me as createAuth,kr as createAuthServer,P as createErrorHandler,he as createIdempotencyMiddleware,$ as getCurrentAccessToken,G as register};
1
+ import Fe from'bcrypt';import W from'jsonwebtoken';import {randomUUID,generateKeyPairSync,createPublicKey,createPrivateKey}from'crypto';import {Kysely,sql,PostgresDialect}from'kysely';import {Router}from'express';import {Redis}from'ioredis';import {Pool}from'pg';var He=Object.assign(Object.create(null),{UNAUTHORIZED:401,TOKEN_EXPIRED:401,TOKEN_INVALID:401,INVALID_CREDENTIALS:401,FORBIDDEN:403,USER_NOT_FOUND:404,IDENTIFIER_NOT_FOUND:404,USER_ALREADY_EXISTS:409,IDENTIFIER_ALREADY_EXISTS:409,INVALID_ROLE:400,VALIDATION_ERROR:400,CONFIGURATION_ERROR:500}),c=class extends Error{code;statusCode;constructor(r,t,s){super(t),this.name="SentriError",this.code=r,this.statusCode=s??He[r]??500;}};async function G(e,r=12){return Fe.hash(e,r)}async function Z(e,r){return Fe.compare(e,r)}var je=new Map,qe=new Map,Nr=3600*1e3;function Me(e){let r=je.get(e);if(!r){let t=createPrivateKey(e),s=createPublicKey(t),n=s.export({format:"jwk"}),i=Buffer.from(e).slice(0,8).toString("base64url"),u={...n,use:"sig",kid:i};r={kid:i,publicKey:s,jwk:u},je.set(e,r);}return r}function Ve(e){let{jwk:r}=Me(e);return {keys:[r]}}function Be(e){return Me(e).publicKey}async function Xe(e){let r=Date.now(),t=qe.get(e);if(t&&r-t.fetchedAt<Nr)return t.publicKey;let s=await fetch(e);if(!s.ok)throw new c("CONFIGURATION_ERROR",`Failed to fetch public key from ${e}: HTTP ${s.status}`);let n=await s.json();if(!n.keys||n.keys.length===0)throw new c("CONFIGURATION_ERROR",`No keys found in JWKS response from ${e}`);let i=n.keys[0],u=createPublicKey({key:i,format:"jwk"});return qe.set(e,{publicKey:u,fetchedAt:r}),u}var Je=new WeakMap,ze=32,Ge=10,Ze=31;function Ye(e){if(e.mode==="client"){if(!e.keyUri||e.keyUri.trim().length===0)throw new c("CONFIGURATION_ERROR","keyUri must not be empty");return}if(!e.secret||e.secret.trim().length===0)throw new c("CONFIGURATION_ERROR","secret must not be empty");if((e.algorithm??"HS256").startsWith("HS")&&e.secret.length<ze)throw new c("CONFIGURATION_ERROR",`secret must be at least ${ze} characters for HMAC algorithms`);let s=e.saltRounds??12;if(!Number.isInteger(s)||s<Ge||s>Ze)throw new c("CONFIGURATION_ERROR",`saltRounds must be an integer between ${Ge} and ${Ze}`);if(!e.validRoles||e.validRoles.length===0)throw new c("CONFIGURATION_ERROR","validRoles must contain at least one role");if(!e.dialect)throw new c("CONFIGURATION_ERROR","dialect is required in server mode")}function T(e){let r=Je.get(e);if(r)return r;let t={algorithm:e.algorithm??"HS256",accessExpiresIn:e.accessExpiresIn??"15m",refreshExpiresIn:e.refreshExpiresIn??"7d",saltRounds:e.saltRounds??12,validRoles:e.validRoles,validRolesSet:new Set(e.validRoles),cookieName:e.cookie?.name??"refresh_token",accessCookieName:e.accessCookie?.name??"access_token"};return Je.set(e,t),t}var Or=/^(\d+)([smhdw])$/,Ur={s:1e3,m:6e4,h:36e5,d:864e5,w:6048e5},We=new Map;function j(e){if(typeof e=="number")return e*1e3;let r=We.get(e);if(r!==void 0)return r;let t=Or.exec(e);if(!t?.[1]||!t?.[2])throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let s=Ur[t[2]];if(s===void 0)throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let n=parseInt(t[1],10)*s;return We.set(e,n),n}var Qe=new Map,er=new Map,rr=new Map;function tr(e){return e.startsWith("RS")||e.startsWith("PS")}function sr(e){let r=Qe.get(e);return r||(r={access:`${e}:access`,refresh:`${e}:refresh`},Qe.set(e,r)),r}function Lr(e){let r=er.get(e);return r||(r=createPrivateKey(e),er.set(e,r)),r}function ir(e){let r=T(e);if(tr(r.algorithm)){let n=Lr(e.secret);return {accessKey:n,refreshKey:n}}let{access:t,refresh:s}=sr(e.secret);return {accessKey:t,refreshKey:s}}function nr(e,r){let t=T(e);if(tr(t.algorithm))return Be(e.secret);let{access:s,refresh:n}=sr(e.secret);return r==="access"?s:n}function or(e,r,t,s){let n=`${t}:${s}`,i=rr.get(n);return i||(i={expiresIn:t,algorithm:s},rr.set(n,i)),W.sign(e,r,i)}function ar(e,r,t){try{let s=W.verify(e,r,{algorithms:[t]});if(typeof s=="string"||s===null)throw new c("TOKEN_INVALID","Token payload is not an object");return s}catch(s){throw s instanceof c?s:s instanceof W.TokenExpiredError?new c("TOKEN_EXPIRED","Token has expired"):new c("TOKEN_INVALID","Token is invalid or malformed")}}function Y(e,r){let t=T(r),{accessKey:s}=ir(r);return or(e,s,t.accessExpiresIn,t.algorithm)}function Q(e,r){let t=T(r),{refreshKey:s}=ir(r);return or({sessionId:e},s,t.refreshExpiresIn,t.algorithm)}function de(e,r){let t=T(r),s=nr(r,"access");return ar(e,s,t.algorithm)}function ee(e,r){let t=T(r),s=nr(r,"refresh");return ar(e,s,t.algorithm)}function ur(e,r){try{let t=W.verify(e,r,{algorithms:["RS256","RS384","RS512","PS256","PS384","PS512"]});if(typeof t=="string"||t===null)throw new c("TOKEN_INVALID","Token payload is not an object");return t}catch(t){throw t instanceof c?t:t instanceof W.TokenExpiredError?new c("TOKEN_EXPIRED","Token has expired"):new c("TOKEN_INVALID","Token is invalid or malformed")}}function F(e){return T(e).cookieName}function q(e,r){if(!e)return;let t=`${r}=`,s=0;for(;s<e.length;){for(;s<e.length&&e[s]===" ";)s++;let n=e.indexOf(";",s),i=n===-1?e.length:n;if(e.startsWith(t,s))return e.slice(s+t.length,i);s=i+1;}}function re(e,r,t){let s=t.cookie??{},n=T(t),i=j(n.refreshExpiresIn);e.cookie(F(t),r,{httpOnly:s.httpOnly??true,secure:s.secure??false,sameSite:s.sameSite??"strict",path:s.path??"/",maxAge:i});}function ce(e,r){let t=r.cookie??{};e.clearCookie(F(r),{path:t.path??"/"});}function ke(e){return T(e).accessCookieName}function te(e,r,t){if(!t.accessCookie)return;let s=t.accessCookie,n=T(t),i=j(n.accessExpiresIn);e.cookie(ke(t),r,{httpOnly:false,secure:s.secure??false,sameSite:s.sameSite??"strict",path:s.path??"/",maxAge:i});}function Se(e,r){if(!r.accessCookie)return;let t=r.accessCookie;e.clearCookie(ke(r),{path:t.path??"/"});}function se(e,r){let t=e.headers.authorization;return t?.startsWith("Bearer ")?t.slice(7):q(e.headers.cookie,ke(r))}var dr=new Map;function k(e){let r=dr.get(e);return r||(r=new Kysely({dialect:e}),dr.set(e,r)),r}function Ee(e){try{return JSON.parse(e)}catch{return []}}function Fr(e){return JSON.stringify(e)}function lr(e){return {id:e.id,userId:e.user_id,type:e.type,value:e.value,isPrimary:e.is_primary===1,createdAt:new Date(e.created_at)}}async function ie(e,r){let t=await e.selectFrom("sentri_identifiers as i_login").innerJoin("sentri_users as u","u.id","i_login.user_id").innerJoin("sentri_identifiers as i_primary",s=>s.onRef("i_primary.user_id","=","u.id").on("i_primary.is_primary","=",1)).select(["u.id","u.password_hash","u.roles","i_primary.value as primary_value","i_primary.type as primary_type"]).where("i_login.value","=",r).executeTakeFirst();return t?{id:t.id,identifier:t.primary_value,identifierType:t.primary_type,passwordHash:t.password_hash,roles:Ee(t.roles)}:null}async function le(e,r){let t=await e.selectFrom("sentri_users as u").innerJoin("sentri_identifiers as i",s=>s.onRef("i.user_id","=","u.id").on("i.is_primary","=",1)).select(["u.id","u.password_hash","u.roles","i.value as primary_value","i.type as primary_type"]).where("u.id","=",r).executeTakeFirst();return t?{id:t.id,identifier:t.primary_value,identifierType:t.primary_type,passwordHash:t.password_hash,roles:Ee(t.roles)}:null}async function fr(e,r,t){let s=t.map(n=>({id:randomUUID(),user_id:r,type:n.type,value:n.value,is_primary:n.isPrimary?1:0}));return await e.insertInto("sentri_identifiers").values(s).execute(),s.map(n=>({id:n.id,userId:n.user_id,type:n.type,value:n.value,isPrimary:n.is_primary===1,createdAt:new Date}))}async function $(e,r){return (await e.selectFrom("sentri_identifiers").selectAll().where("user_id","=",r).orderBy("created_at","asc").execute()).map(lr)}async function fe(e,r,t){let s=await e.selectFrom("sentri_identifiers").selectAll().where("id","=",r).where("user_id","=",t).executeTakeFirst();return s?lr(s):null}async function pr(e,r){let t=await e.selectFrom("sentri_identifiers").select(s=>s.fn.countAll().as("count")).where("user_id","=",r).executeTakeFirst();return Number(t?.count??0)}async function gr(e,r,t,s){await e.updateTable("sentri_identifiers").set({type:s.type,value:s.value}).where("id","=",r).where("user_id","=",t).execute();}async function mr(e,r,t){await e.deleteFrom("sentri_identifiers").where("user_id","=",r).where("id","in",t).execute();}async function hr(e,r,t){await e.transaction().execute(async s=>{await s.updateTable("sentri_identifiers").set({is_primary:0}).where("user_id","=",r).execute(),await s.updateTable("sentri_identifiers").set({is_primary:1}).where("id","=",t).where("user_id","=",r).execute();});}async function yr(e,r,t){await e.updateTable("sentri_users").set({password_hash:t}).where("id","=",r).execute();}async function Rr(e,r,t){await e.updateTable("sentri_users").set({roles:Fr(t)}).where("id","=",r).execute();}async function be(e,r){let t=randomUUID();return await e.insertInto("sentri_sessions").values({id:t,user_id:r.userId,expires_at:r.expiresAt}).execute(),{id:t}}async function wr(e,r){let t=await e.selectFrom("sentri_sessions as s").innerJoin("sentri_users as u","u.id","s.user_id").innerJoin("sentri_identifiers as i",s=>s.onRef("i.user_id","=","u.id").on("i.is_primary","=",1)).select(["s.id as session_id","s.user_id","s.expires_at","s.created_at as session_created_at","u.id as user_id_col","u.password_hash","u.roles","i.value as primary_identifier","i.type as primary_identifier_type"]).where("s.id","=",r).executeTakeFirst();return t?{id:t.session_id,userId:t.user_id,expiresAt:new Date(t.expires_at),createdAt:new Date(t.session_created_at),user:{id:t.user_id_col,identifier:t.primary_identifier,identifierType:t.primary_identifier_type,passwordHash:t.password_hash,roles:Ee(t.roles)}}:null}async function pe(e,r){await e.deleteFrom("sentri_sessions").where("id","=",r).execute();}async function xe(e,r){await e.deleteFrom("sentri_sessions").where("user_id","=",r).execute();}async function ne(e,r){let t=T(r),s=k(r.dialect),n=e.roles??[],i=n.filter(v=>!t.validRolesSet.has(v));if(i.length>0)return {success:false,error:new c("INVALID_ROLE",`Invalid roles: ${i.join(", ")}`)};if(!e.identifiers||e.identifiers.length===0)return {success:false,error:new c("VALIDATION_ERROR","At least one identifier is required")};let u=e.identifiers.map(v=>({type:v.type.trim(),value:v.value.trim()}));if(new Set(u.map(v=>v.value)).size!==u.length)return {success:false,error:new c("VALIDATION_ERROR","Duplicate identifier values in request")};for(let v of u)if(await ie(s,v.value))return {success:false,error:new c("IDENTIFIER_ALREADY_EXISTS",`Identifier already taken: ${v.value}`)};let d=await G(e.password,t.saltRounds),{userId:g,identifierRows:h}=await s.transaction().execute(async v=>{let P=randomUUID();await v.insertInto("sentri_users").values({id:P,password_hash:d,roles:JSON.stringify(n)}).execute();let K=u.map((L,Ce)=>({id:randomUUID(),user_id:P,type:L.type,value:L.value,is_primary:Ce===0?1:0}));return await v.insertInto("sentri_identifiers").values(K).execute(),{userId:P,identifierRows:K}}),x=h.map(v=>({id:v.id,type:v.type,value:v.value,isPrimary:v.is_primary===1})),S=x[0];return {success:true,user:{id:g,identifier:S.value,identifierType:S.type,roles:n,identifiers:x}}}async function ge(e,r){let t=T(r),s=k(r.dialect),n=await ie(s,e.identifier.trim());if(!n)return {success:false,error:new c("INVALID_CREDENTIALS","Invalid credentials")};if(!await Z(e.password,n.passwordHash))return {success:false,error:new c("INVALID_CREDENTIALS","Invalid credentials")};let u=new Date(Date.now()+j(t.refreshExpiresIn)),o=await be(s,{userId:n.id,expiresAt:u}),d={id:n.id,identifier:n.identifier,identifierType:n.identifierType,roles:n.roles},g=Y({id:n.id,identifier:n.identifier,identifierType:n.identifierType,roles:n.roles,sessionId:o.id},r),h=Q(o.id,r);return {success:true,accessToken:g,refreshToken:h,user:d}}async function M(e,r){let t=T(r),s=k(r.dialect),n;try{({sessionId:n}=ee(e,r));}catch(x){return x instanceof c?{success:false,error:x}:{success:false,error:new c("TOKEN_INVALID","Invalid refresh token")}}let i=await wr(s,n);if(!i)return {success:false,error:new c("UNAUTHORIZED","Session not found or revoked")};if(i.expiresAt.getTime()<Date.now())return await pe(s,n),{success:false,error:new c("TOKEN_EXPIRED","Session has expired")};await pe(s,n);let u=new Date(Date.now()+j(t.refreshExpiresIn)),o=await be(s,{userId:i.userId,expiresAt:u}),d={id:i.user.id,identifier:i.user.identifier,identifierType:i.user.identifierType,roles:i.user.roles},g=Y({...d,sessionId:o.id},r),h=Q(o.id,r);return {success:true,accessToken:g,refreshToken:h,user:d}}async function me(e,r){let t=k(r.dialect),s;try{({sessionId:s}=ee(e,r));}catch{return}await pe(t,s);}async function he(e,r){let t=k(r.dialect);await xe(t,e);}async function Ar(e,r){let t=k(r.dialect),s=await le(t,e);if(!s)return {success:false,error:new c("USER_NOT_FOUND","User not found")};let n=await $(t,e);return {success:true,user:{id:s.id,identifier:s.identifier,identifierType:s.identifierType,roles:s.roles,identifiers:n.map(i=>({id:i.id,type:i.type,value:i.value,isPrimary:i.isPrimary}))}}}async function ye(e,r,t,s){let n=T(s),i=k(s.dialect),u=await le(i,e);if(!u)return {success:false,error:new c("USER_NOT_FOUND","User not found")};if(!await Z(r,u.passwordHash))return {success:false,error:new c("INVALID_CREDENTIALS","Invalid credentials")};let d=await G(t,n.saltRounds);return await yr(i,e,d),await xe(i,e),{success:true}}async function Re(e,r,t){let s=T(t),n=k(t.dialect),i=r.filter(g=>!s.validRolesSet.has(g));if(i.length>0)return {success:false,error:new c("INVALID_ROLE",`Invalid roles: ${i.join(", ")}`)};let u=await le(n,e);if(!u)return {success:false,error:new c("USER_NOT_FOUND","User not found")};let o=new Set(u.roles);for(let g of r)o.add(g);let d=Array.from(o);return await Rr(n,e,d),{success:true,user:{id:u.id,identifier:u.identifier,identifierType:u.identifierType,roles:d}}}async function we(e,r,t){let s=k(t.dialect);if(r.length===0)return {success:false,error:new c("VALIDATION_ERROR","At least one identifier is required")};let n=r.map(d=>({type:d.type.trim(),value:d.value.trim()}));if(new Set(n.map(d=>d.value)).size!==n.length)return {success:false,error:new c("VALIDATION_ERROR","Duplicate identifier values in request")};for(let d of n)if(await ie(s,d.value))return {success:false,error:new c("IDENTIFIER_ALREADY_EXISTS",`Identifier already taken: ${d.value}`)};await fr(s,e,n.map(d=>({...d,isPrimary:false})));return {success:true,identifiers:(await $(s,e)).map(d=>({id:d.id,type:d.type,value:d.value,isPrimary:d.isPrimary}))}}async function Ie(e,r,t){let s=k(t.dialect);if(r.length===0)return {success:false,error:new c("VALIDATION_ERROR","At least one update is required")};let n=r.map(o=>({id:o.id,type:o.type.trim(),value:o.value.trim()}));if(new Set(n.map(o=>o.value)).size!==n.length)return {success:false,error:new c("VALIDATION_ERROR","Duplicate identifier values in request")};for(let o of n){let d=await fe(s,o.id,e);if(!d)return {success:false,error:new c("IDENTIFIER_NOT_FOUND",`Identifier not found: ${o.id}`)};if(d.value!==o.value&&await ie(s,o.value))return {success:false,error:new c("IDENTIFIER_ALREADY_EXISTS",`Identifier already taken: ${o.value}`)}}for(let o of n)await gr(s,o.id,e,{type:o.type,value:o.value});return {success:true,identifiers:(await $(s,e)).map(o=>({id:o.id,type:o.type,value:o.value,isPrimary:o.isPrimary}))}}async function Ae(e,r,t){let s=k(t.dialect);if(r.length===0)return {success:false,error:new c("VALIDATION_ERROR","At least one ID is required")};for(let u of r)if(!await fe(s,u,e))return {success:false,error:new c("IDENTIFIER_NOT_FOUND",`Identifier not found: ${u}`)};return await pr(s,e)-r.length<1?{success:false,error:new c("VALIDATION_ERROR","Cannot delete all identifiers \u2014 at least one must remain")}:(await mr(s,e,r),{success:true,identifiers:(await $(s,e)).map(u=>({id:u.id,type:u.type,value:u.value,isPrimary:u.isPrimary}))})}async function ve(e,r,t){let s=k(t.dialect);return await fe(s,r,e)?(await hr(s,e,r),{success:true,identifiers:(await $(s,e)).map(u=>({id:u.id,type:u.type,value:u.value,isPrimary:u.isPrimary}))}):{success:false,error:new c("IDENTIFIER_NOT_FOUND",`Identifier not found: ${r}`)}}var O={info:()=>{},warn:()=>{},error:()=>{}};function m(e,r,t){return {service:e,event:r,...t}}function E(e){let r=e.logger??O,t=e.loggerService??"sentri";return e.mode==="client"?jr(e.keyUri,r,t):qr(e,r,t)}function jr(e,r,t){return async(s,n,i)=>{let u=s.headers.authorization,o=u?.startsWith("Bearer ")?u.slice(7):void 0,d=s.requestId;if(!o)return r.warn(m(t,"auth.protect.failure",{mode:"client",errorCode:"UNAUTHORIZED",...d!==void 0&&{requestId:d}})),i(new c("UNAUTHORIZED","Missing or malformed Authorization header"));try{let g=await Xe(e),h=ur(o,g);s.user={id:h.id,identifier:h.identifier,identifierType:h.identifierType,roles:h.roles},r.info(m(t,"auth.protect.success",{mode:"client",userId:h.id,...d!==void 0&&{requestId:d}})),i();}catch(g){let h=g instanceof c?g.code:"TOKEN_INVALID";r.warn(m(t,"auth.protect.failure",{mode:"client",errorCode:h,...d!==void 0&&{requestId:d}})),i(g);}}}function qr(e,r,t){return async(s,n,i)=>{let u=se(s,e),o=s.requestId;if(!u)return r.warn(m(t,"auth.protect.failure",{mode:"server",errorCode:"UNAUTHORIZED",...o!==void 0&&{requestId:o}})),i(new c("UNAUTHORIZED","Missing or malformed Authorization header"));try{let d=de(u,e);if(e.isTokenRevoked&&await e.isTokenRevoked(d.sessionId))return r.warn(m(t,"auth.protect.token_revoked",{mode:"server",userId:d.id,...o!==void 0&&{requestId:o}})),i(new c("UNAUTHORIZED","Token has been revoked"));s.user={id:d.id,identifier:d.identifier,identifierType:d.identifierType,roles:d.roles},r.info(m(t,"auth.protect.success",{mode:"server",userId:d.id,...o!==void 0&&{requestId:o}})),i();}catch(d){if(d instanceof c&&d.code==="TOKEN_EXPIRED"){let g=q(s.headers.cookie,F(e));if(!g)return r.warn(m(t,"auth.protect.failure",{mode:"server",errorCode:"TOKEN_EXPIRED",...o!==void 0&&{requestId:o}})),i(new c("UNAUTHORIZED","Token expired. Please login again."));try{let h=await M(g,e);if(!h.success)return r.warn(m(t,"auth.protect.failure",{mode:"server",errorCode:h.error.code,...o!==void 0&&{requestId:o}})),i(new c("UNAUTHORIZED","Session expired. Please login again."));re(n,h.refreshToken,e),te(n,h.accessToken,e),n.setHeader("X-New-Access-Token",h.accessToken),s.user=h.user,r.info(m(t,"auth.protect.auto_refresh",{mode:"server",userId:h.user.id,...o!==void 0&&{requestId:o}})),i();}catch{r.warn(m(t,"auth.protect.failure",{mode:"server",errorCode:"TOKEN_EXPIRED",...o!==void 0&&{requestId:o}})),i(new c("UNAUTHORIZED","Session expired. Please login again."));}}else {let g=d instanceof c?d.code:"TOKEN_INVALID";r.warn(m(t,"auth.protect.failure",{mode:"server",errorCode:g,...o!==void 0&&{requestId:o}})),i(d);}}}}function vr(e,r,t){let s=`Requires one of roles: ${e.join(", ")}`;return (n,i,u)=>{let o=n.requestId;if(!n.user)return r.warn(m(t,"auth.authorize.unauthenticated",{requiredRoles:e,...o!==void 0&&{requestId:o}})),u(new c("UNAUTHORIZED","Not authenticated"));let d=n.user.roles;if(!e.some(g=>d.includes(g)))return r.warn(m(t,"auth.authorize.denied",{userId:n.user.id,userRoles:[...d],requiredRoles:e,...o!==void 0&&{requestId:o}})),u(new c("FORBIDDEN",s));r.info(m(t,"auth.authorize.passed",{userId:n.user.id,userRoles:[...d],requiredRoles:e,...o!==void 0&&{requestId:o}})),u();}}function oe(e,r){return function(...s){return vr(s,e,r)}}function De(...e){return vr(e,O,"sentri")}var $r=new c("FORBIDDEN","You do not have permission to perform this action");function _r(e,r,t){return async(s,n,i)=>{let u=s.requestId;if(!s.user)return r.warn(m(t,"auth.permit.unauthenticated",{...u!==void 0&&{requestId:u}})),i(new c("UNAUTHORIZED","Not authenticated"));let o=s.user.id;if(e.roles&&e.roles.length>0){let d=s.user.roles;if(e.roles.some(g=>d.includes(g)))return r.info(m(t,"auth.permit.role_bypass",{userId:o,bypassedByRole:true,...u!==void 0&&{requestId:u}})),i()}try{let d=e.check(s);(d instanceof Promise?await d:d)?(r.info(m(t,"auth.permit.passed",{userId:o,...u!==void 0&&{requestId:u}})),i()):(r.warn(m(t,"auth.permit.denied",{userId:o,...u!==void 0&&{requestId:u}})),i($r));}catch(d){i(d);}}}function ae(e,r){return function(s){return _r(typeof s=="function"?{check:s}:s,e,r)}}function Pe(e){return _r(typeof e=="function"?{check:e}:e,O,"sentri")}function V(e){return (r,t,s,n)=>{if(r instanceof c){s.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});return}e?.onUnhandled?.(r),s.status(500).json({error:true,statusCode:500,code:"INTERNAL_SERVER_ERROR",message:"Internal server error",data:null});}}var _e=8,B=72,Te=255,Tr=100,X=50;function w(e){return new c("VALIDATION_ERROR",e)}function b(e,r,t,s){e.status(r).json({error:false,statusCode:r,message:t,data:s});}function N(e,r){e.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});}function U(e){if(e==null||typeof e!="object"||Array.isArray(e))throw new c("VALIDATION_ERROR","Request body is missing or not a JSON object. Did you apply express.json()?");return e}function Vr(e,r){if(!r.apiKey)return;let t=e.headers["x-api-key"];if(typeof t!="string"||t!==r.apiKey)throw new c("UNAUTHORIZED","Invalid or missing API key")}function Ne(e){if(e)try{let r=e();r instanceof Promise&&r.catch(()=>{});}catch{}}function Br(e){return e.startsWith("RS")||e.startsWith("PS")}function Oe(e,r){if(typeof e!="object"||e===null||Array.isArray(e))throw w(`identifiers[${r}] must be an object`);let t=e;if(typeof t.type!="string"||t.type.trim().length===0)throw w(`identifiers[${r}].type is required and must be a non-empty string`);if(t.type.length>Tr)throw w(`identifiers[${r}].type must not exceed ${Tr} characters`);if(typeof t.value!="string"||t.value.trim().length===0)throw w(`identifiers[${r}].value is required and must be a non-empty string`);if(t.value.length>Te)throw w(`identifiers[${r}].value must not exceed ${Te} characters`);return {type:t.type,value:t.value}}function C(e){return e.requestId!==void 0?{requestId:e.requestId}:{}}function Cr(e){let r=Router(),t=e,s=T(t),n=e.logger??O,i=e.loggerService??"sentri",u=oe(n,i),o=ae(n,i),d=e.router?.register??(a=>ne(a,t)),g=e.router?.login??(a=>ge(a,t)),h=e.router?.refresh??(a=>M(a,t)),x=e.router?.logout??(a=>a!==void 0?me(a,t):Promise.resolve()),S=e.router?.logoutAll??(a=>he(a,t)),v=e.router?.assignRoles??((a,l)=>Re(a,l,t)),P=e.router?.changePassword??((a,l,_)=>ye(a,l,_,t)),K=e.router?.bulkCreateIdentifiers??((a,l)=>we(a,l,t)),L=e.router?.bulkUpdateIdentifiers??((a,l)=>Ie(a,l,t)),Ce=e.router?.bulkDeleteIdentifiers??((a,l)=>Ae(a,l,t)),xr=e.router?.changePrimaryIdentifier??((a,l)=>ve(a,l,t));Br(s.algorithm)&&r.get("/keys",(a,l)=>{l.setHeader("Cache-Control","public, max-age=3600"),l.json(Ve(e.secret));}),r.post("/register",async(a,l,_)=>{let I=Date.now();try{Vr(a,e);let p=U(a.body),{identifiers:f,password:y,roles:R}=p;if(!Array.isArray(f)||f.length===0)throw w("identifiers is required and must be a non-empty array");if(f.length>X)throw w(`identifiers must not exceed ${X} entries`);let A=f.map((z,Dr)=>Oe(z,Dr));if(typeof y!="string"||y.length<_e)throw w(`password is required and must be at least ${_e} characters`);if(y.length>B)throw w(`password must not exceed ${B} characters`);if(R!==void 0&&!Array.isArray(R))throw w("roles must be an array of strings when provided");if(Array.isArray(R)&&!R.every(z=>typeof z=="string"))throw w("each role must be a string");let D=Array.isArray(R)?R:void 0,H=await d(D!==void 0?{identifiers:A,password:y,roles:D}:{identifiers:A,password:y});if(!H.success){n.warn(m(i,"auth.register.failure",{errorCode:H.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,H.error);return}n.info(m(i,"auth.register.success",{userId:H.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,201,"User registered successfully",{user:H.user});}catch(p){_(p);}}),r.post("/login",async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{identifier:f,password:y}=p;if(typeof f!="string"||f.trim().length===0)throw w("identifier is required and must be a non-empty string");if(f.length>Te)throw w(`identifier must not exceed ${Te} characters`);if(typeof y!="string"||y.length===0)throw w("password is required");if(y.length>B)throw w(`password must not exceed ${B} characters`);let R=f.trim(),A=await g({identifier:R,password:y});if(!A.success){Ne(()=>e.hooks?.onFailedLogin?.(R,A.error)),n.warn(m(i,"auth.login.failure",{errorCode:A.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,A.error);return}Ne(()=>e.hooks?.onLogin?.(A.user)),re(l,A.refreshToken,e),te(l,A.accessToken,e),n.info(m(i,"auth.login.success",{userId:A.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Login successful",{accessToken:A.accessToken,user:A.user});}catch(p){_(p);}}),r.post("/refresh",async(a,l,_)=>{let I=Date.now();try{let p=q(a.headers.cookie,F(e));if(!p)throw new c("UNAUTHORIZED","Refresh token cookie is missing");let f=await h(p);if(!f.success){ce(l,e),n.warn(m(i,"auth.refresh.failure",{errorCode:f.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,f.error);return}re(l,f.refreshToken,e),te(l,f.accessToken,e),n.info(m(i,"auth.refresh.success",{userId:f.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Token refreshed",{accessToken:f.accessToken});}catch(p){_(p);}}),r.post("/logout",async(a,l,_)=>{let I=Date.now();try{let p=q(a.headers.cookie,F(e));await x(p),ce(l,e),Se(l,e),n.info(m(i,"auth.logout",{duration_ms:Date.now()-I,...C(a)})),b(l,200,"Logged out",null);}catch(p){_(p);}}),r.post("/logout-all",E(e),async(a,l,_)=>{let I=Date.now();try{let p=a.user.id;await S(p),Ne(()=>e.hooks?.onLogout?.(p)),ce(l,e),Se(l,e),n.info(m(i,"auth.logout_all",{userId:p,duration_ms:Date.now()-I,...C(a)})),b(l,200,"All sessions revoked",null);}catch(p){_(p);}}),r.get("/me",E(e),(a,l)=>{b(l,200,"OK",a.user);});let J=o(a=>!!a.user);return r.post("/me/identifiers",E(e),J,async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{identifiers:f}=p;if(!Array.isArray(f)||f.length===0)throw w("identifiers is required and must be a non-empty array");if(f.length>X)throw w(`identifiers must not exceed ${X} entries`);let y=f.map((A,D)=>Oe(A,D)),R=await K(a.user.id,y);if(!R.success){n.warn(m(i,"auth.identifiers.create_failure",{userId:a.user.id,errorCode:R.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,R.error);return}n.info(m(i,"auth.identifiers.created",{userId:a.user.id,count:R.identifiers.length,duration_ms:Date.now()-I,...C(a)})),b(l,201,"Identifiers added successfully",{identifiers:R.identifiers});}catch(p){_(p);}}),r.put("/me/identifiers",E(e),J,async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{identifiers:f}=p;if(!Array.isArray(f)||f.length===0)throw w("identifiers is required and must be a non-empty array");if(f.length>X)throw w(`identifiers must not exceed ${X} entries`);let y=f.map((A,D)=>{if(typeof A!="object"||A===null||Array.isArray(A))throw w(`identifiers[${D}] must be an object`);let ue=A;if(typeof ue.id!="string"||ue.id.trim().length===0)throw w(`identifiers[${D}].id is required and must be a non-empty string`);let{type:H,value:z}=Oe(A,D);return {id:ue.id,type:H,value:z}}),R=await L(a.user.id,y);if(!R.success){n.warn(m(i,"auth.identifiers.update_failure",{userId:a.user.id,errorCode:R.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,R.error);return}n.info(m(i,"auth.identifiers.updated",{userId:a.user.id,count:R.identifiers.length,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Identifiers updated successfully",{identifiers:R.identifiers});}catch(p){_(p);}}),r.delete("/me/identifiers",E(e),J,async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{ids:f}=p;if(!Array.isArray(f)||f.length===0)throw w("ids is required and must be a non-empty array of strings");if(!f.every(R=>typeof R=="string"))throw w("each id must be a string");let y=await Ce(a.user.id,f);if(!y.success){n.warn(m(i,"auth.identifiers.delete_failure",{userId:a.user.id,errorCode:y.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,y.error);return}n.info(m(i,"auth.identifiers.deleted",{userId:a.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Identifiers deleted successfully",{identifiers:y.identifiers});}catch(p){_(p);}}),r.patch("/me/identifiers/primary",E(e),J,async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{id:f}=p;if(typeof f!="string"||f.trim().length===0)throw w("id is required and must be a non-empty string");let y=await xr(a.user.id,f.trim());if(!y.success){n.warn(m(i,"auth.identifiers.primary_change_failure",{userId:a.user.id,errorCode:y.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,y.error);return}n.info(m(i,"auth.identifiers.primary_changed",{userId:a.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Primary identifier updated successfully",{identifiers:y.identifiers});}catch(p){_(p);}}),r.patch("/me/password",E(e),J,async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{currentPassword:f,newPassword:y}=p;if(typeof f!="string"||f.length===0)throw w("currentPassword is required");if(typeof y!="string"||y.length<_e)throw w(`newPassword must be at least ${_e} characters`);if(y.length>B)throw w(`newPassword must not exceed ${B} characters`);if(f===y)throw w("newPassword must be different from currentPassword");let R=await P(a.user.id,f,y);if(!R.success){n.warn(m(i,"auth.password.change_failure",{userId:a.user.id,errorCode:R.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,R.error);return}n.info(m(i,"auth.password.changed",{userId:a.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Password updated successfully. All sessions have been revoked.",null);}catch(p){_(p);}}),r.post("/users/:userId/roles",E(e),u("admin"),async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{roles:f}=p,y=a.params.userId,R=typeof y=="string"?y:void 0;if(!R)throw w("userId is required");if(!Array.isArray(f)||f.length===0)throw w("roles must be a non-empty array of strings");if(!f.every(D=>typeof D=="string"))throw w("each role must be a string");let A=await v(R,f);if(!A.success){n.warn(m(i,"auth.roles.assign_failure",{targetUserId:R,errorCode:A.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,A.error);return}n.info(m(i,"auth.roles.assigned",{targetUserId:R,roles:f,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Roles assigned successfully",{user:A.user});}catch(p){_(p);}}),r.use(V()),r}var kr=new Map;function Sr(e){let r=kr.get(e);return r||(r=new Redis(e,{lazyConnect:false,enableOfflineQueue:false}),kr.set(e,r)),r}function Ue(e){let r=e?.ttl??3e5,t=(e?.header??"X-Idempotency-Key").toLowerCase(),s=new Set((e?.methods??["POST","PUT","PATCH"]).map(i=>i.toUpperCase())),n=e?.redisUrl;return n?Jr(n,r,t,s):zr(r,t,s,e?.maxSize??1e4)}function Jr(e,r,t,s){let n=Sr(e),i="sentri:idempotency:";return async(u,o,d)=>{let g=u.headers[t];if(!g||typeof g!="string"||!s.has(u.method))return d();u.requestId=g,o.setHeader("X-Request-Id",g);let h=await n.get(`${i}${g}`);if(h){let S=JSON.parse(h);return o.setHeader("X-Idempotent-Replayed","true"),o.status(S.statusCode).json(S.body)}let x=o.json.bind(o);o.json=function(v){if(o.statusCode>=200&&o.statusCode<300){let P={statusCode:o.statusCode,body:v,expiresAt:Date.now()+r};n.set(`${i}${g}`,JSON.stringify(P),"PX",r).catch(()=>{});}return x(v)},d();}}function zr(e,r,t,s){let n=Math.max(e,5e3),i=new Map,u=setInterval(()=>{let o=Date.now();for(let[d,g]of i)g.expiresAt<=o&&i.delete(d);},n);return typeof u=="object"&&u!==null&&"unref"in u&&u.unref(),(o,d,g)=>{let h=o.headers[r];if(!h||typeof h!="string"||!t.has(o.method))return g();o.requestId=h,d.setHeader("X-Request-Id",h);let x=Date.now(),S=i.get(h);if(S&&S.expiresAt>x)return d.setHeader("X-Idempotent-Replayed","true"),d.status(S.statusCode).json(S.body);let v=d.json.bind(d);d.json=function(K){if(d.statusCode>=200&&d.statusCode<300){if(i.size>=s){let L=i.keys().next().value;L!==void 0&&i.delete(L);}i.set(h,{statusCode:d.statusCode,body:K,expiresAt:Date.now()+e});}return v(K)},g();}}async function Er(e){await e.schema.createTable("sentri_users").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("password_hash","varchar(255)",r=>r.notNull()).addColumn("roles","text",r=>r.notNull().defaultTo("[]")).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute(),await e.schema.createTable("sentri_sessions").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("user_id","varchar(36)",r=>r.notNull().references("sentri_users.id").onDelete("cascade")).addColumn("expires_at","timestamp",r=>r.notNull()).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute(),await e.schema.createTable("sentri_identifiers").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("user_id","varchar(36)",r=>r.notNull().references("sentri_users.id").onDelete("cascade")).addColumn("type","varchar(100)",r=>r.notNull()).addColumn("value","varchar(255)",r=>r.notNull().unique()).addColumn("is_primary","integer",r=>r.notNull().defaultTo(0)).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute();}function Le(e){if(Ye(e),e.mode==="client"){let i=e.logger?oe(e.logger,e.loggerService??"sentri"):De,u=e.logger?ae(e.logger,e.loggerService??"sentri"):Pe;return {protect:()=>E(e),authorize:(...o)=>i(...o),permit:o=>u(o),errorHandler:o=>V(o)}}let r=e,t=T(r),s=r.logger?oe(r.logger,r.loggerService??"sentri"):De,n=r.logger?ae(r.logger,r.loggerService??"sentri"):Pe;return {protect:()=>E(r),authorize:(...i)=>s(...i),permit:i=>n(i),hashPassword:i=>G(i,t.saltRounds),verifyPassword:(i,u)=>Z(i,u),signAccessToken:i=>Y(i,r),signRefreshToken:i=>Q(i,r),verifyAccessToken:i=>de(i,r),verifyRefreshToken:i=>ee(i,r),getCurrentAccessToken:i=>se(i,r),router:()=>Cr(r),migrate:()=>Er(k(r.dialect)),errorHandler:i=>V(i),idempotencyMiddleware:i=>Ue({...i,...r.redisUrl!==void 0&&{redisUrl:r.redisUrl}}),register:i=>ne(i,r),login:i=>ge(i,r),refresh:i=>M(i,r),logout:i=>me(i,r),logoutAll:i=>he(i,r),getUser:i=>Ar(i,r),changePassword:(i,u,o)=>ye(i,u,o,r),assignRoles:(i,u)=>Re(i,u,r),bulkCreateIdentifiers:(i,u)=>we(i,u,r),bulkUpdateIdentifiers:(i,u)=>Ie(i,u,r),bulkDeleteIdentifiers:(i,u)=>Ae(i,u,r),changePrimaryIdentifier:(i,u)=>ve(i,u,r)}}function br(e){return new PostgresDialect({pool:new Pool(e)})}function Yr(e){let{privateKey:r}=generateKeyPairSync("rsa",{modulusLength:2048,privateKeyEncoding:{type:"pkcs8",format:"pem"},publicKeyEncoding:{type:"spki",format:"pem"}}),t={mode:"server",dialect:br(e.db),secret:r,algorithm:"RS256",validRoles:e.validRoles,...e.accessExpiresIn!==void 0&&{accessExpiresIn:e.accessExpiresIn},...e.refreshExpiresIn!==void 0&&{refreshExpiresIn:e.refreshExpiresIn},...e.saltRounds!==void 0&&{saltRounds:e.saltRounds},...e.apiKey!==void 0&&{apiKey:e.apiKey},...e.cookie!==void 0&&{cookie:e.cookie},...e.accessCookie!==void 0&&{accessCookie:e.accessCookie},...e.hooks!==void 0&&{hooks:e.hooks},...e.router!==void 0&&{router:e.router},...e.isTokenRevoked!==void 0&&{isTokenRevoked:e.isTokenRevoked},...e.redisUrl!==void 0&&{redisUrl:e.redisUrl},...e.logger!==void 0&&{logger:e.logger},...e.loggerService!==void 0&&{loggerService:e.loggerService}};return Le(t)}export{He as SENTRI_ERROR_STATUS,c as SentriError,Le as createAuth,Yr as createAuthServer,V as createErrorHandler,Ue as createIdempotencyMiddleware,se as getCurrentAccessToken,ne as register};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentri",
3
- "version": "2.1.0",
3
+ "version": "4.1.0",
4
4
  "description": "Personal auth/authorization library for Express + Postgres",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -54,10 +54,13 @@
54
54
  "bcrypt": "^6.0.0",
55
55
  "ioredis": "^5.11.1",
56
56
  "jsonwebtoken": "^9.0.3",
57
- "kysely": "^0.27.4",
57
+ "kysely": "^0.29.2",
58
58
  "pg": "^8.13.1"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "express": ">=4.0.0"
62
+ },
63
+ "overrides": {
64
+ "esbuild": "0.28.1"
62
65
  }
63
66
  }