@chidchanun/bcp 0.2.16 → 0.2.18

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.
@@ -0,0 +1,1391 @@
1
+ // packages/server/src/auth.ts
2
+ import {
3
+ randomUUID
4
+ } from "node:crypto";
5
+
6
+ // packages/server/src/session.ts
7
+ import {
8
+ createHmac,
9
+ timingSafeEqual
10
+ } from "node:crypto";
11
+
12
+ // packages/server/src/request-context.ts
13
+ import {
14
+ AsyncLocalStorage
15
+ } from "node:async_hooks";
16
+ var requestStorage = new AsyncLocalStorage();
17
+ async function requestUrl() {
18
+ const context = getRequestContext();
19
+ return new URL(
20
+ context.url.href
21
+ );
22
+ }
23
+ async function cookies() {
24
+ const context = getRequestContext();
25
+ return context.cookies;
26
+ }
27
+ function getRequestContext() {
28
+ const context = requestStorage.getStore();
29
+ if (!context) {
30
+ throw new Error(
31
+ "BCP Framework: server request APIs can only be used while handling a request."
32
+ );
33
+ }
34
+ return context;
35
+ }
36
+
37
+ // packages/server/src/session.ts
38
+ var DEFAULT_COOKIE_NAME = "bcp_session";
39
+ var DEFAULT_EXPIRES_IN = 60 * 60 * 12;
40
+ var MAX_TOKEN_LENGTH = 16 * 1024;
41
+ var MINIMUM_SECRET_BYTES = 32;
42
+ async function createSessionToken(payload, options = {}) {
43
+ assertPayload(
44
+ payload
45
+ );
46
+ const secret = resolveSessionSecret(
47
+ options.secret
48
+ );
49
+ const expiresIn = resolveExpiresIn(
50
+ options.expiresIn
51
+ );
52
+ const issuer = resolveOptionalIssuer(
53
+ options.issuer
54
+ );
55
+ const audience = options.audience === void 0 ? void 0 : normalizeAudience(
56
+ options.audience
57
+ );
58
+ const now = Math.floor(
59
+ Date.now() / 1e3
60
+ );
61
+ const header = {
62
+ alg: "HS256",
63
+ typ: "JWT"
64
+ };
65
+ const claims = {
66
+ ...payload,
67
+ iat: now,
68
+ exp: now + expiresIn
69
+ };
70
+ if (issuer !== void 0) {
71
+ claims.iss = issuer;
72
+ }
73
+ if (audience !== void 0) {
74
+ claims.aud = audience;
75
+ }
76
+ const encodedHeader = encodeJson(
77
+ header
78
+ );
79
+ const encodedPayload = encodeJson(
80
+ claims
81
+ );
82
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
83
+ const signature = signHs256(
84
+ signingInput,
85
+ secret
86
+ );
87
+ return `${signingInput}.${signature}`;
88
+ }
89
+ async function verifySessionToken(token, options = {}) {
90
+ const secret = resolveSessionSecret(
91
+ options.secret
92
+ );
93
+ const issuer = resolveOptionalIssuer(
94
+ options.issuer
95
+ );
96
+ const audience = options.audience === void 0 ? void 0 : normalizeAudience(
97
+ options.audience
98
+ );
99
+ try {
100
+ if (typeof token !== "string" || token.length === 0 || token.length > MAX_TOKEN_LENGTH) {
101
+ return null;
102
+ }
103
+ const parts = token.split(".");
104
+ if (parts.length !== 3) {
105
+ return null;
106
+ }
107
+ const [
108
+ encodedHeader,
109
+ encodedPayload,
110
+ signature
111
+ ] = parts;
112
+ const header = decodeJson(
113
+ encodedHeader
114
+ );
115
+ if (header.alg !== "HS256" || header.typ !== void 0 && header.typ !== "JWT") {
116
+ return null;
117
+ }
118
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
119
+ const expectedSignature = signHs256(
120
+ signingInput,
121
+ secret
122
+ );
123
+ if (!safeEqual(
124
+ signature,
125
+ expectedSignature
126
+ )) {
127
+ return null;
128
+ }
129
+ const claims = decodeJson(
130
+ encodedPayload
131
+ );
132
+ const now = Math.floor(
133
+ Date.now() / 1e3
134
+ );
135
+ if (typeof claims.iat !== "number" || !Number.isFinite(
136
+ claims.iat
137
+ ) || typeof claims.exp !== "number" || !Number.isFinite(
138
+ claims.exp
139
+ ) || claims.exp <= now) {
140
+ return null;
141
+ }
142
+ if (claims.nbf !== void 0) {
143
+ if (typeof claims.nbf !== "number" || !Number.isFinite(
144
+ claims.nbf
145
+ ) || claims.nbf > now) {
146
+ return null;
147
+ }
148
+ }
149
+ if (issuer !== void 0 && claims.iss !== issuer) {
150
+ return null;
151
+ }
152
+ if (audience !== void 0 && !audienceMatches(
153
+ claims.aud,
154
+ audience
155
+ )) {
156
+ return null;
157
+ }
158
+ return claims;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+ async function createSession(payload, options = {}) {
164
+ const expiresIn = resolveExpiresIn(
165
+ options.expiresIn
166
+ );
167
+ const token = await createSessionToken(
168
+ payload,
169
+ {
170
+ secret: options.secret,
171
+ expiresIn,
172
+ issuer: options.issuer,
173
+ audience: options.audience
174
+ }
175
+ );
176
+ const cookieStore = await cookies();
177
+ cookieStore.set(
178
+ resolveCookieName(
179
+ options.cookieName
180
+ ),
181
+ token,
182
+ {
183
+ httpOnly: options.httpOnly ?? true,
184
+ secure: options.secure ?? process.env.NODE_ENV === "production",
185
+ sameSite: options.sameSite ?? "lax",
186
+ path: options.path ?? "/",
187
+ domain: options.domain,
188
+ maxAge: expiresIn
189
+ }
190
+ );
191
+ return token;
192
+ }
193
+ async function getSession(options = {}) {
194
+ const cookieStore = await cookies();
195
+ const token = cookieStore.get(
196
+ resolveCookieName(
197
+ options.cookieName
198
+ )
199
+ )?.value;
200
+ if (!token) {
201
+ return null;
202
+ }
203
+ return verifySessionToken(
204
+ token,
205
+ {
206
+ secret: options.secret,
207
+ issuer: options.issuer,
208
+ audience: options.audience
209
+ }
210
+ );
211
+ }
212
+ async function destroySession(options = {}) {
213
+ const cookieStore = await cookies();
214
+ cookieStore.delete(
215
+ resolveCookieName(
216
+ options.cookieName
217
+ ),
218
+ {
219
+ path: options.path ?? "/",
220
+ domain: options.domain
221
+ }
222
+ );
223
+ }
224
+ function resolveSessionSecret(explicitSecret) {
225
+ const secret = explicitSecret ?? process.env.BCP_SESSION_SECRET;
226
+ if (!secret) {
227
+ throw new Error(
228
+ "BCP Framework: BCP_SESSION_SECRET is required for session tokens."
229
+ );
230
+ }
231
+ if (Buffer.byteLength(
232
+ secret,
233
+ "utf8"
234
+ ) < MINIMUM_SECRET_BYTES) {
235
+ throw new Error(
236
+ `BCP Framework: session secret must be at least ${MINIMUM_SECRET_BYTES} bytes.`
237
+ );
238
+ }
239
+ return secret;
240
+ }
241
+ function resolveExpiresIn(value) {
242
+ const expiresIn = value ?? DEFAULT_EXPIRES_IN;
243
+ if (!Number.isFinite(
244
+ expiresIn
245
+ ) || expiresIn <= 0) {
246
+ throw new Error(
247
+ "BCP Framework: session expiresIn must be a positive finite number of seconds."
248
+ );
249
+ }
250
+ return Math.floor(
251
+ expiresIn
252
+ );
253
+ }
254
+ function resolveCookieName(value) {
255
+ const cookieName = value ?? DEFAULT_COOKIE_NAME;
256
+ assertNonEmptyString(
257
+ cookieName,
258
+ "cookieName"
259
+ );
260
+ return cookieName;
261
+ }
262
+ function resolveOptionalIssuer(value) {
263
+ if (value === void 0) {
264
+ return void 0;
265
+ }
266
+ assertNonEmptyString(
267
+ value,
268
+ "issuer"
269
+ );
270
+ return value;
271
+ }
272
+ function assertPayload(payload) {
273
+ if (payload === null || typeof payload !== "object" || Array.isArray(
274
+ payload
275
+ )) {
276
+ throw new Error(
277
+ "BCP Framework: session payload must be a plain object."
278
+ );
279
+ }
280
+ }
281
+ function assertNonEmptyString(value, label) {
282
+ if (typeof value !== "string" || value.trim().length === 0) {
283
+ throw new Error(
284
+ `BCP Framework: session ${label} must be a non-empty string.`
285
+ );
286
+ }
287
+ }
288
+ function normalizeAudience(value) {
289
+ if (typeof value === "string") {
290
+ assertNonEmptyString(
291
+ value,
292
+ "audience"
293
+ );
294
+ return value;
295
+ }
296
+ if (!Array.isArray(
297
+ value
298
+ ) || value.length === 0) {
299
+ throw new Error(
300
+ "BCP Framework: session audience must contain at least one value."
301
+ );
302
+ }
303
+ for (const audience of value) {
304
+ assertNonEmptyString(
305
+ audience,
306
+ "audience"
307
+ );
308
+ }
309
+ return [
310
+ ...value
311
+ ];
312
+ }
313
+ function audienceMatches(claim, expected) {
314
+ const expectedValues = typeof expected === "string" ? [
315
+ expected
316
+ ] : expected;
317
+ const claimValues = typeof claim === "string" ? [
318
+ claim
319
+ ] : Array.isArray(
320
+ claim
321
+ ) ? claim.filter(
322
+ (value) => typeof value === "string"
323
+ ) : [];
324
+ return expectedValues.some(
325
+ (value) => claimValues.includes(
326
+ value
327
+ )
328
+ );
329
+ }
330
+ function encodeJson(value) {
331
+ return Buffer.from(
332
+ JSON.stringify(
333
+ value
334
+ ),
335
+ "utf8"
336
+ ).toString(
337
+ "base64url"
338
+ );
339
+ }
340
+ function decodeJson(value) {
341
+ const decoded = Buffer.from(
342
+ value,
343
+ "base64url"
344
+ ).toString(
345
+ "utf8"
346
+ );
347
+ return JSON.parse(
348
+ decoded
349
+ );
350
+ }
351
+ function signHs256(signingInput, secret) {
352
+ return createHmac(
353
+ "sha256",
354
+ secret
355
+ ).update(
356
+ signingInput,
357
+ "utf8"
358
+ ).digest(
359
+ "base64url"
360
+ );
361
+ }
362
+ function safeEqual(actual, expected) {
363
+ const actualBuffer = Buffer.from(
364
+ actual,
365
+ "base64url"
366
+ );
367
+ const expectedBuffer = Buffer.from(
368
+ expected,
369
+ "base64url"
370
+ );
371
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(
372
+ actualBuffer,
373
+ expectedBuffer
374
+ );
375
+ }
376
+
377
+ // packages/server/src/auth.ts
378
+ async function auth(options = {}) {
379
+ const session = await readSignedAuthSession(
380
+ toSessionCookieOptions(
381
+ options
382
+ )
383
+ );
384
+ if (!session) {
385
+ return null;
386
+ }
387
+ const idleTimeout = resolveIdleTimeout(
388
+ options.idleTimeout
389
+ );
390
+ const store = options.store;
391
+ if (idleTimeout !== void 0 && !store) {
392
+ throw new Error(
393
+ "BCP Auth: idleTimeout requires a server-side auth session store."
394
+ );
395
+ }
396
+ if (!store) {
397
+ return session;
398
+ }
399
+ const record = await store.get(
400
+ session.sid
401
+ );
402
+ if (!record) {
403
+ return null;
404
+ }
405
+ const now = currentUnixTime();
406
+ const userId = serializeAuthUserId(
407
+ session.user.id
408
+ );
409
+ if (record.userId !== userId || record.revokedAt !== void 0 && record.revokedAt !== null || record.expiresAt <= now) {
410
+ return null;
411
+ }
412
+ if (idleTimeout !== void 0 && record.lastSeenAt + idleTimeout <= now) {
413
+ await store.revoke(
414
+ session.sid,
415
+ now
416
+ );
417
+ return null;
418
+ }
419
+ await store.touch(
420
+ session.sid,
421
+ now
422
+ );
423
+ return session;
424
+ }
425
+ async function getSession2(options = {}) {
426
+ return auth(
427
+ options
428
+ );
429
+ }
430
+ async function login(user, options = {}) {
431
+ assertAuthUser(
432
+ user
433
+ );
434
+ if (options.data !== void 0 && !isPlainObject(
435
+ options.data
436
+ )) {
437
+ throw new TypeError(
438
+ "BCP Auth: session data must be a plain object."
439
+ );
440
+ }
441
+ const idleTimeout = resolveIdleTimeout(
442
+ options.idleTimeout
443
+ );
444
+ if (idleTimeout !== void 0 && !options.store) {
445
+ throw new Error(
446
+ "BCP Auth: idleTimeout requires a server-side auth session store."
447
+ );
448
+ }
449
+ const {
450
+ data
451
+ } = options;
452
+ const sessionOptions = toSessionCookieOptions(
453
+ options
454
+ );
455
+ const payload = {
456
+ sid: randomUUID(),
457
+ user,
458
+ ...data === void 0 ? {} : {
459
+ data
460
+ }
461
+ };
462
+ await createSession(
463
+ payload,
464
+ sessionOptions
465
+ );
466
+ const session = await readSignedAuthSession(
467
+ sessionOptions
468
+ );
469
+ if (!session) {
470
+ throw new Error(
471
+ "BCP Auth: login created a session but it could not be read back."
472
+ );
473
+ }
474
+ if (options.store) {
475
+ await options.store.set(
476
+ createStoreRecord(
477
+ session
478
+ )
479
+ );
480
+ }
481
+ return session;
482
+ }
483
+ async function logout(options = {}) {
484
+ if (options.store) {
485
+ const current = await auth(
486
+ options
487
+ );
488
+ if (current) {
489
+ await options.store.revoke(
490
+ current.sid,
491
+ currentUnixTime()
492
+ );
493
+ }
494
+ }
495
+ await destroySession(
496
+ toSessionCookieOptions(
497
+ options
498
+ )
499
+ );
500
+ }
501
+ async function logoutAll(options = {}) {
502
+ const store = requireAuthSessionStore(
503
+ options.store,
504
+ "logoutAll"
505
+ );
506
+ const current = await auth(
507
+ options
508
+ );
509
+ let revoked = 0;
510
+ if (current) {
511
+ revoked = await store.revokeUser(
512
+ serializeAuthUserId(
513
+ current.user.id
514
+ ),
515
+ currentUnixTime()
516
+ );
517
+ }
518
+ await destroySession(
519
+ toSessionCookieOptions(
520
+ options
521
+ )
522
+ );
523
+ return revoked;
524
+ }
525
+ async function rotateSession(options = {}) {
526
+ const current = await auth(
527
+ options
528
+ );
529
+ if (!current) {
530
+ return null;
531
+ }
532
+ const rotated = await login(
533
+ current.user,
534
+ {
535
+ ...options,
536
+ issuer: options.issuer ?? current.iss,
537
+ audience: options.audience ?? current.aud,
538
+ ...current.data === void 0 ? {} : {
539
+ data: current.data
540
+ }
541
+ }
542
+ );
543
+ if (options.store) {
544
+ await options.store.revoke(
545
+ current.sid,
546
+ currentUnixTime()
547
+ );
548
+ }
549
+ return rotated;
550
+ }
551
+ async function revokeSession(sid, store) {
552
+ assertSessionId(
553
+ sid
554
+ );
555
+ assertAuthSessionStore(
556
+ store
557
+ );
558
+ return Boolean(
559
+ await store.revoke(
560
+ sid,
561
+ currentUnixTime()
562
+ )
563
+ );
564
+ }
565
+ async function revokeUserSessions(userId, store) {
566
+ assertAuthSessionStore(
567
+ store
568
+ );
569
+ return store.revokeUser(
570
+ serializeAuthUserId(
571
+ userId
572
+ ),
573
+ currentUnixTime()
574
+ );
575
+ }
576
+ function createAuth(defaults = {}) {
577
+ return {
578
+ auth: (options = {}) => auth({
579
+ ...defaults,
580
+ ...options
581
+ }),
582
+ getSession: (options = {}) => auth({
583
+ ...defaults,
584
+ ...options
585
+ }),
586
+ login: (user, options = {}) => {
587
+ const {
588
+ data,
589
+ ...sessionOptions
590
+ } = options;
591
+ return login(
592
+ user,
593
+ {
594
+ ...defaults,
595
+ ...sessionOptions,
596
+ ...data === void 0 ? {} : {
597
+ data
598
+ }
599
+ }
600
+ );
601
+ },
602
+ logout: (options = {}) => logout({
603
+ ...defaults,
604
+ ...options
605
+ }),
606
+ logoutAll: (options = {}) => logoutAll({
607
+ ...defaults,
608
+ ...options
609
+ }),
610
+ rotateSession: (options = {}) => rotateSession({
611
+ ...defaults,
612
+ ...options
613
+ }),
614
+ revokeSession: (sid) => revokeSession(
615
+ sid,
616
+ requireAuthSessionStore(
617
+ defaults.store,
618
+ "revokeSession"
619
+ )
620
+ ),
621
+ revokeUserSessions: (userId) => revokeUserSessions(
622
+ userId,
623
+ requireAuthSessionStore(
624
+ defaults.store,
625
+ "revokeUserSessions"
626
+ )
627
+ )
628
+ };
629
+ }
630
+ async function readSignedAuthSession(options) {
631
+ const session = await getSession(
632
+ options
633
+ );
634
+ if (!session) {
635
+ return null;
636
+ }
637
+ if (typeof session.sid !== "string" || session.sid.trim().length === 0 || !isAuthUser(
638
+ session.user
639
+ )) {
640
+ return null;
641
+ }
642
+ if (session.data !== void 0 && !isPlainObject(
643
+ session.data
644
+ )) {
645
+ return null;
646
+ }
647
+ return session;
648
+ }
649
+ function createStoreRecord(session) {
650
+ return {
651
+ sid: session.sid,
652
+ userId: serializeAuthUserId(
653
+ session.user.id
654
+ ),
655
+ createdAt: session.iat,
656
+ expiresAt: session.exp,
657
+ lastSeenAt: session.iat,
658
+ revokedAt: null
659
+ };
660
+ }
661
+ function toSessionCookieOptions(options) {
662
+ const {
663
+ store: _store,
664
+ idleTimeout: _idleTimeout,
665
+ ...sessionOptions
666
+ } = options;
667
+ return sessionOptions;
668
+ }
669
+ function serializeAuthUserId(value) {
670
+ if (typeof value === "string") {
671
+ const normalized = value.trim();
672
+ if (!normalized) {
673
+ throw new TypeError(
674
+ "BCP Auth: user id must not be empty."
675
+ );
676
+ }
677
+ return `string:${normalized}`;
678
+ }
679
+ if (!Number.isFinite(
680
+ value
681
+ )) {
682
+ throw new TypeError(
683
+ "BCP Auth: numeric user id must be finite."
684
+ );
685
+ }
686
+ return `number:${value}`;
687
+ }
688
+ function resolveIdleTimeout(value) {
689
+ if (value === void 0) {
690
+ return void 0;
691
+ }
692
+ if (!Number.isFinite(
693
+ value
694
+ ) || value <= 0) {
695
+ throw new TypeError(
696
+ "BCP Auth: idleTimeout must be a positive finite number of seconds."
697
+ );
698
+ }
699
+ return Math.floor(
700
+ value
701
+ );
702
+ }
703
+ function requireAuthSessionStore(store, operation) {
704
+ if (!store) {
705
+ throw new Error(
706
+ `BCP Auth: ${operation} requires a server-side auth session store.`
707
+ );
708
+ }
709
+ assertAuthSessionStore(
710
+ store
711
+ );
712
+ return store;
713
+ }
714
+ function assertAuthSessionStore(store) {
715
+ if (!store || typeof store !== "object" || typeof store.set !== "function" || typeof store.get !== "function" || typeof store.touch !== "function" || typeof store.revoke !== "function" || typeof store.revokeUser !== "function") {
716
+ throw new TypeError(
717
+ "BCP Auth: session store must implement set, get, touch, revoke and revokeUser."
718
+ );
719
+ }
720
+ }
721
+ function assertSessionId(sid) {
722
+ if (typeof sid !== "string" || sid.trim() === "") {
723
+ throw new TypeError(
724
+ "BCP Auth: session id must be a non-empty string."
725
+ );
726
+ }
727
+ }
728
+ function assertAuthUser(value) {
729
+ if (!isAuthUser(
730
+ value
731
+ )) {
732
+ throw new TypeError(
733
+ "BCP Auth: user must be a plain object with a non-empty string or finite numeric id."
734
+ );
735
+ }
736
+ }
737
+ function isAuthUser(value) {
738
+ if (!isPlainObject(
739
+ value
740
+ )) {
741
+ return false;
742
+ }
743
+ const id = value.id;
744
+ return typeof id === "string" && id.trim().length > 0 || typeof id === "number" && Number.isFinite(
745
+ id
746
+ );
747
+ }
748
+ function isPlainObject(value) {
749
+ if (value === null || typeof value !== "object" || Array.isArray(
750
+ value
751
+ )) {
752
+ return false;
753
+ }
754
+ const prototype = Object.getPrototypeOf(
755
+ value
756
+ );
757
+ return prototype === Object.prototype || prototype === null;
758
+ }
759
+ function currentUnixTime() {
760
+ return Math.floor(
761
+ Date.now() / 1e3
762
+ );
763
+ }
764
+
765
+ // packages/server/src/auth-session-store.ts
766
+ function createMemoryAuthSessionStore() {
767
+ const sessions = /* @__PURE__ */ new Map();
768
+ const clearExpired = (now = currentUnixTime2()) => {
769
+ let removed = 0;
770
+ for (const [
771
+ sid,
772
+ record
773
+ ] of sessions) {
774
+ if (record.expiresAt <= now) {
775
+ sessions.delete(
776
+ sid
777
+ );
778
+ removed += 1;
779
+ }
780
+ }
781
+ return removed;
782
+ };
783
+ return {
784
+ set(record) {
785
+ assertRecord(
786
+ record
787
+ );
788
+ clearExpired();
789
+ sessions.set(
790
+ record.sid,
791
+ cloneRecord(
792
+ record
793
+ )
794
+ );
795
+ },
796
+ get(sid) {
797
+ assertSid(
798
+ sid
799
+ );
800
+ clearExpired();
801
+ const record = sessions.get(
802
+ sid
803
+ );
804
+ return record ? cloneRecord(
805
+ record
806
+ ) : null;
807
+ },
808
+ touch(sid, lastSeenAt) {
809
+ assertSid(
810
+ sid
811
+ );
812
+ assertTimestamp(
813
+ lastSeenAt,
814
+ "lastSeenAt"
815
+ );
816
+ clearExpired(
817
+ lastSeenAt
818
+ );
819
+ const record = sessions.get(
820
+ sid
821
+ );
822
+ if (!record || record.revokedAt !== void 0 && record.revokedAt !== null) {
823
+ return;
824
+ }
825
+ record.lastSeenAt = Math.max(
826
+ record.lastSeenAt,
827
+ lastSeenAt
828
+ );
829
+ },
830
+ revoke(sid, revokedAt = currentUnixTime2()) {
831
+ assertSid(
832
+ sid
833
+ );
834
+ assertTimestamp(
835
+ revokedAt,
836
+ "revokedAt"
837
+ );
838
+ const record = sessions.get(
839
+ sid
840
+ );
841
+ if (!record) {
842
+ return false;
843
+ }
844
+ record.revokedAt = revokedAt;
845
+ return true;
846
+ },
847
+ revokeUser(userId, revokedAt = currentUnixTime2()) {
848
+ assertUserId(
849
+ userId
850
+ );
851
+ assertTimestamp(
852
+ revokedAt,
853
+ "revokedAt"
854
+ );
855
+ clearExpired(
856
+ revokedAt
857
+ );
858
+ let revoked = 0;
859
+ for (const record of sessions.values()) {
860
+ if (record.userId !== userId || record.revokedAt !== void 0 && record.revokedAt !== null) {
861
+ continue;
862
+ }
863
+ record.revokedAt = revokedAt;
864
+ revoked += 1;
865
+ }
866
+ return revoked;
867
+ },
868
+ clearExpired,
869
+ size() {
870
+ clearExpired();
871
+ return sessions.size;
872
+ }
873
+ };
874
+ }
875
+ function cloneRecord(record) {
876
+ return {
877
+ ...record
878
+ };
879
+ }
880
+ function assertRecord(record) {
881
+ if (!record || typeof record !== "object") {
882
+ throw new TypeError(
883
+ "BCP Auth Store: session record must be an object."
884
+ );
885
+ }
886
+ assertSid(
887
+ record.sid
888
+ );
889
+ assertUserId(
890
+ record.userId
891
+ );
892
+ assertTimestamp(
893
+ record.createdAt,
894
+ "createdAt"
895
+ );
896
+ assertTimestamp(
897
+ record.expiresAt,
898
+ "expiresAt"
899
+ );
900
+ assertTimestamp(
901
+ record.lastSeenAt,
902
+ "lastSeenAt"
903
+ );
904
+ if (record.expiresAt <= record.createdAt) {
905
+ throw new TypeError(
906
+ "BCP Auth Store: expiresAt must be later than createdAt."
907
+ );
908
+ }
909
+ if (record.revokedAt !== void 0 && record.revokedAt !== null) {
910
+ assertTimestamp(
911
+ record.revokedAt,
912
+ "revokedAt"
913
+ );
914
+ }
915
+ }
916
+ function assertSid(value) {
917
+ if (typeof value !== "string" || value.trim() === "") {
918
+ throw new TypeError(
919
+ "BCP Auth Store: sid must be a non-empty string."
920
+ );
921
+ }
922
+ }
923
+ function assertUserId(value) {
924
+ if (typeof value !== "string" || value.trim() === "") {
925
+ throw new TypeError(
926
+ "BCP Auth Store: userId must be a non-empty string."
927
+ );
928
+ }
929
+ }
930
+ function assertTimestamp(value, label) {
931
+ if (!Number.isFinite(
932
+ value
933
+ ) || value < 0) {
934
+ throw new TypeError(
935
+ `BCP Auth Store: ${label} must be a non-negative finite Unix timestamp.`
936
+ );
937
+ }
938
+ }
939
+ function currentUnixTime2() {
940
+ return Math.floor(
941
+ Date.now() / 1e3
942
+ );
943
+ }
944
+
945
+ // packages/server/src/authorization.ts
946
+ var AuthorizationError = class extends Error {
947
+ status = 403;
948
+ code = "FORBIDDEN";
949
+ constructor(message = "Forbidden") {
950
+ super(message);
951
+ this.name = "AuthorizationError";
952
+ }
953
+ };
954
+ function getUserPermissions(user, field = "permissions") {
955
+ const normalizedField = normalizeField(
956
+ field
957
+ );
958
+ const value = user[normalizedField];
959
+ if (typeof value === "string") {
960
+ const permission = value.trim();
961
+ return permission ? [permission] : [];
962
+ }
963
+ if (!Array.isArray(
964
+ value
965
+ )) {
966
+ return [];
967
+ }
968
+ return Array.from(
969
+ new Set(
970
+ value.filter(
971
+ (permission) => typeof permission === "string"
972
+ ).map(
973
+ (permission) => permission.trim()
974
+ ).filter(Boolean)
975
+ )
976
+ );
977
+ }
978
+ function hasPermission(user, required, options = {}) {
979
+ const requiredPermissions = normalizePermissions(
980
+ required
981
+ );
982
+ const assignedPermissions = getUserPermissions(
983
+ user,
984
+ options.field
985
+ );
986
+ const match = options.match ?? "any";
987
+ assertAuthorizationMatch(
988
+ match
989
+ );
990
+ return match === "all" ? requiredPermissions.every(
991
+ (permission) => assignedPermissions.includes(
992
+ permission
993
+ )
994
+ ) : requiredPermissions.some(
995
+ (permission) => assignedPermissions.includes(
996
+ permission
997
+ )
998
+ );
999
+ }
1000
+ function assertPermission(user, required, options = {}) {
1001
+ if (!hasPermission(
1002
+ user,
1003
+ required,
1004
+ options
1005
+ )) {
1006
+ throw new AuthorizationError();
1007
+ }
1008
+ }
1009
+ function defineAuthorizationPolicy(policy) {
1010
+ if (typeof policy !== "function") {
1011
+ throw new TypeError(
1012
+ "BCP Authorization: policy must be a function."
1013
+ );
1014
+ }
1015
+ return policy;
1016
+ }
1017
+ async function can(policy, context) {
1018
+ assertAuthorizationContext(
1019
+ context
1020
+ );
1021
+ return Boolean(
1022
+ await policy(
1023
+ context
1024
+ )
1025
+ );
1026
+ }
1027
+ async function cannot(policy, context) {
1028
+ return !await can(
1029
+ policy,
1030
+ context
1031
+ );
1032
+ }
1033
+ async function authorize(policy, context, message = "Forbidden") {
1034
+ if (!await can(
1035
+ policy,
1036
+ context
1037
+ )) {
1038
+ throw new AuthorizationError(
1039
+ message
1040
+ );
1041
+ }
1042
+ }
1043
+ function normalizePermissions(value) {
1044
+ const permissions = typeof value === "string" ? [value] : [
1045
+ ...value
1046
+ ];
1047
+ const normalized = permissions.map(
1048
+ (permission) => permission.trim()
1049
+ );
1050
+ if (normalized.length === 0 || normalized.some(
1051
+ (permission) => permission.length === 0
1052
+ )) {
1053
+ throw new TypeError(
1054
+ "BCP Authorization: required permissions must contain non-empty strings."
1055
+ );
1056
+ }
1057
+ return Array.from(
1058
+ new Set(
1059
+ normalized
1060
+ )
1061
+ );
1062
+ }
1063
+ function normalizeField(value) {
1064
+ const field = value.trim();
1065
+ if (!field) {
1066
+ throw new TypeError(
1067
+ "BCP Authorization: permission field must be a non-empty string."
1068
+ );
1069
+ }
1070
+ return field;
1071
+ }
1072
+ function assertAuthorizationMatch(value) {
1073
+ if (value !== "any" && value !== "all") {
1074
+ throw new TypeError(
1075
+ 'BCP Authorization: match must be either "any" or "all".'
1076
+ );
1077
+ }
1078
+ }
1079
+ function assertAuthorizationContext(value) {
1080
+ if (!value || typeof value !== "object" || Array.isArray(
1081
+ value
1082
+ ) || !("user" in value) || !value.user || typeof value.user !== "object" || Array.isArray(
1083
+ value.user
1084
+ )) {
1085
+ throw new TypeError(
1086
+ "BCP Authorization: context must contain a user object."
1087
+ );
1088
+ }
1089
+ }
1090
+
1091
+ // packages/server/src/server-response.ts
1092
+ async function redirect(destination, status = 307) {
1093
+ assertRedirectStatus(
1094
+ status
1095
+ );
1096
+ const base = await requestUrl();
1097
+ const target = destination instanceof URL ? new URL(
1098
+ destination.href
1099
+ ) : new URL(
1100
+ destination,
1101
+ base
1102
+ );
1103
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
1104
+ throw new Error(
1105
+ `BCP Framework: redirect only supports http and https URLs, received "${target.protocol}".`
1106
+ );
1107
+ }
1108
+ return new Response(
1109
+ null,
1110
+ {
1111
+ status,
1112
+ headers: {
1113
+ Location: target.href
1114
+ }
1115
+ }
1116
+ );
1117
+ }
1118
+ function assertRedirectStatus(status) {
1119
+ if (status !== 301 && status !== 302 && status !== 303 && status !== 307 && status !== 308) {
1120
+ throw new Error(
1121
+ `BCP Framework: invalid redirect status ${status}. Use 301, 302, 303, 307 or 308.`
1122
+ );
1123
+ }
1124
+ }
1125
+
1126
+ // packages/server/src/auth-guard.ts
1127
+ async function requireAuth(options = {}) {
1128
+ const {
1129
+ redirectTo = "/login",
1130
+ redirectStatus = 303,
1131
+ ...authOptions
1132
+ } = options;
1133
+ const session = await auth(
1134
+ authOptions
1135
+ );
1136
+ if (!session) {
1137
+ if (redirectTo === null) {
1138
+ return new Response(
1139
+ "Unauthorized",
1140
+ {
1141
+ status: 401
1142
+ }
1143
+ );
1144
+ }
1145
+ return redirect(
1146
+ redirectTo,
1147
+ redirectStatus
1148
+ );
1149
+ }
1150
+ return {
1151
+ auth: session
1152
+ };
1153
+ }
1154
+ async function requireGuest(options = {}) {
1155
+ const {
1156
+ redirectTo = "/",
1157
+ redirectStatus = 303,
1158
+ ...authOptions
1159
+ } = options;
1160
+ const session = await auth(
1161
+ authOptions
1162
+ );
1163
+ if (!session) {
1164
+ return {};
1165
+ }
1166
+ if (redirectTo === null) {
1167
+ return new Response(
1168
+ "Already authenticated",
1169
+ {
1170
+ status: 409
1171
+ }
1172
+ );
1173
+ }
1174
+ return redirect(
1175
+ redirectTo,
1176
+ redirectStatus
1177
+ );
1178
+ }
1179
+ async function requireRole(requiredRole, options = {}) {
1180
+ const roles = normalizeRequiredRoles(
1181
+ requiredRole
1182
+ );
1183
+ const {
1184
+ roleField = "role",
1185
+ match = "any",
1186
+ forbiddenRedirectTo = null,
1187
+ forbiddenRedirectStatus = 303,
1188
+ ...authOptions
1189
+ } = options;
1190
+ const authenticated = await requireAuth(
1191
+ authOptions
1192
+ );
1193
+ if (authenticated instanceof Response) {
1194
+ return authenticated;
1195
+ }
1196
+ const assignedRoles = readAssignedRoles(
1197
+ authenticated.auth.user,
1198
+ roleField
1199
+ );
1200
+ const allowed = match === "all" ? roles.every(
1201
+ (role) => assignedRoles.includes(
1202
+ role
1203
+ )
1204
+ ) : roles.some(
1205
+ (role) => assignedRoles.includes(
1206
+ role
1207
+ )
1208
+ );
1209
+ return finalizeAuthorization(
1210
+ allowed,
1211
+ authenticated,
1212
+ forbiddenRedirectTo,
1213
+ forbiddenRedirectStatus
1214
+ );
1215
+ }
1216
+ async function requirePermission(requiredPermission, options = {}) {
1217
+ const {
1218
+ permissionField = "permissions",
1219
+ match = "any",
1220
+ forbiddenRedirectTo = null,
1221
+ forbiddenRedirectStatus = 303,
1222
+ ...authOptions
1223
+ } = options;
1224
+ const authenticated = await requireAuth(
1225
+ authOptions
1226
+ );
1227
+ if (authenticated instanceof Response) {
1228
+ return authenticated;
1229
+ }
1230
+ const allowed = hasPermission(
1231
+ authenticated.auth.user,
1232
+ requiredPermission,
1233
+ {
1234
+ field: permissionField,
1235
+ match
1236
+ }
1237
+ );
1238
+ return finalizeAuthorization(
1239
+ allowed,
1240
+ authenticated,
1241
+ forbiddenRedirectTo,
1242
+ forbiddenRedirectStatus
1243
+ );
1244
+ }
1245
+ function createAuthGuard(options = {}) {
1246
+ return () => requireAuth(
1247
+ options
1248
+ );
1249
+ }
1250
+ function createGuestGuard(options = {}) {
1251
+ return () => requireGuest(
1252
+ options
1253
+ );
1254
+ }
1255
+ function createRoleGuard(requiredRole, options = {}) {
1256
+ return () => requireRole(
1257
+ requiredRole,
1258
+ options
1259
+ );
1260
+ }
1261
+ function createPermissionGuard(requiredPermission, options = {}) {
1262
+ return () => requirePermission(
1263
+ requiredPermission,
1264
+ options
1265
+ );
1266
+ }
1267
+ function getGuardAuth(guardData) {
1268
+ const value = guardData.auth;
1269
+ if (!value || typeof value !== "object" || Array.isArray(
1270
+ value
1271
+ )) {
1272
+ return null;
1273
+ }
1274
+ const session = value;
1275
+ if (typeof session.sid !== "string" || session.sid.trim().length === 0 || typeof session.iat !== "number" || !Number.isFinite(
1276
+ session.iat
1277
+ ) || typeof session.exp !== "number" || !Number.isFinite(
1278
+ session.exp
1279
+ ) || !isGuardAuthUser(
1280
+ session.user
1281
+ )) {
1282
+ return null;
1283
+ }
1284
+ return value;
1285
+ }
1286
+ function finalizeAuthorization(allowed, authenticated, forbiddenRedirectTo, forbiddenRedirectStatus) {
1287
+ if (allowed) {
1288
+ return authenticated;
1289
+ }
1290
+ if (forbiddenRedirectTo !== null) {
1291
+ return redirect(
1292
+ forbiddenRedirectTo,
1293
+ forbiddenRedirectStatus
1294
+ );
1295
+ }
1296
+ return new Response(
1297
+ "Forbidden",
1298
+ {
1299
+ status: 403
1300
+ }
1301
+ );
1302
+ }
1303
+ function normalizeRequiredRoles(value) {
1304
+ const roles = typeof value === "string" ? [
1305
+ value
1306
+ ] : [
1307
+ ...value
1308
+ ];
1309
+ const normalized = roles.map(
1310
+ (role) => role.trim()
1311
+ );
1312
+ if (normalized.length === 0 || normalized.some(
1313
+ (role) => role.length === 0
1314
+ )) {
1315
+ throw new TypeError(
1316
+ "BCP Auth Guard: required roles must contain non-empty strings."
1317
+ );
1318
+ }
1319
+ return Array.from(
1320
+ new Set(
1321
+ normalized
1322
+ )
1323
+ );
1324
+ }
1325
+ function readAssignedRoles(user, roleField) {
1326
+ const field = roleField.trim();
1327
+ if (!field) {
1328
+ throw new TypeError(
1329
+ "BCP Auth Guard: roleField must be a non-empty string."
1330
+ );
1331
+ }
1332
+ const value = user[field];
1333
+ if (typeof value === "string") {
1334
+ const role = value.trim();
1335
+ return role ? [
1336
+ role
1337
+ ] : [];
1338
+ }
1339
+ if (Array.isArray(
1340
+ value
1341
+ )) {
1342
+ return value.filter(
1343
+ (role) => typeof role === "string"
1344
+ ).map(
1345
+ (role) => role.trim()
1346
+ ).filter(
1347
+ Boolean
1348
+ );
1349
+ }
1350
+ return [];
1351
+ }
1352
+ function isGuardAuthUser(value) {
1353
+ if (!value || typeof value !== "object" || Array.isArray(
1354
+ value
1355
+ )) {
1356
+ return false;
1357
+ }
1358
+ const id = value.id;
1359
+ return typeof id === "string" && id.trim().length > 0 || typeof id === "number" && Number.isFinite(
1360
+ id
1361
+ );
1362
+ }
1363
+ export {
1364
+ AuthorizationError,
1365
+ assertPermission,
1366
+ auth,
1367
+ authorize,
1368
+ can,
1369
+ cannot,
1370
+ createAuth,
1371
+ createAuthGuard,
1372
+ createGuestGuard,
1373
+ createMemoryAuthSessionStore,
1374
+ createPermissionGuard,
1375
+ createRoleGuard,
1376
+ defineAuthorizationPolicy,
1377
+ getGuardAuth,
1378
+ getSession2 as getSession,
1379
+ getUserPermissions,
1380
+ hasPermission,
1381
+ login,
1382
+ logout,
1383
+ logoutAll,
1384
+ requireAuth,
1385
+ requireGuest,
1386
+ requirePermission,
1387
+ requireRole,
1388
+ revokeSession,
1389
+ revokeUserSessions,
1390
+ rotateSession
1391
+ };