astro-sessionkit 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/core/config.d.ts +3 -0
  2. package/dist/core/config.d.ts.map +1 -1
  3. package/dist/core/config.js +40 -0
  4. package/dist/core/config.js.map +1 -1
  5. package/dist/core/context.js.map +1 -1
  6. package/dist/core/guardMiddleware.d.ts.map +1 -1
  7. package/dist/core/guardMiddleware.js +15 -3
  8. package/dist/core/guardMiddleware.js.map +1 -1
  9. package/dist/core/logger.d.ts +4 -0
  10. package/dist/core/logger.d.ts.map +1 -0
  11. package/dist/core/logger.js +11 -0
  12. package/dist/core/logger.js.map +1 -0
  13. package/dist/core/matcher.js.map +1 -1
  14. package/dist/core/sessionMiddleware.d.ts.map +1 -1
  15. package/dist/core/sessionMiddleware.js +4 -4
  16. package/dist/core/sessionMiddleware.js.map +1 -1
  17. package/dist/core/types.d.ts +3 -0
  18. package/dist/core/types.d.ts.map +1 -1
  19. package/dist/core/types.js.map +1 -1
  20. package/dist/core/validation.js.map +1 -1
  21. package/dist/guard.js.map +1 -1
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.js +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/integration.js +1 -1
  26. package/dist/integration.js.map +1 -1
  27. package/dist/server.js.map +1 -1
  28. package/package.json +2 -1
  29. package/readme.md +40 -1
  30. package/src/core/config.ts +153 -0
  31. package/src/core/context.ts +30 -0
  32. package/src/core/guardMiddleware.ts +131 -0
  33. package/src/core/logger.ts +31 -0
  34. package/src/core/matcher.ts +61 -0
  35. package/src/core/sessionMiddleware.ts +65 -0
  36. package/src/core/types.ts +147 -0
  37. package/src/core/validation.ts +137 -0
  38. package/src/guard.ts +7 -0
  39. package/src/index.ts +78 -0
  40. package/src/integration.ts +58 -0
  41. package/src/middleware.ts +5 -0
  42. package/src/server.ts +245 -0
@@ -0,0 +1,153 @@
1
+ // ============================================================================
2
+ // Configuration Store
3
+ // ============================================================================
4
+
5
+ import type {SessionKitConfig, AccessHooks, ProtectionRule, Session, SessionContext} from "./types";
6
+ import {isValidPattern, isValidRedirectPath} from "./validation";
7
+
8
+ /**
9
+ * Internal config with defaults applied
10
+ */
11
+ export interface ResolvedConfig {
12
+ loginPath: string;
13
+ protect: ProtectionRule[];
14
+ access: Required<Omit<AccessHooks, "check">> & {
15
+ check?: AccessHooks["check"];
16
+ };
17
+ runWithContext?: <T>(context: SessionContext, fn: () => T) => T | Promise<T>;
18
+ getContextStore?: () => SessionContext | undefined;
19
+ setContextStore?: (context: SessionContext) => void;
20
+ globalProtect: boolean;
21
+ exclude: string[];
22
+ debug: boolean;
23
+ }
24
+
25
+ const DEFAULT_CONFIG: ResolvedConfig = {
26
+ loginPath: "/login",
27
+ protect: [],
28
+ access: {
29
+ getRole: (session: Session | null) => session?.role ?? null,
30
+ getPermissions: (session: Session | null) => session?.permissions ?? [],
31
+ check: undefined,
32
+ },
33
+ globalProtect: false,
34
+ exclude: [],
35
+ debug: false,
36
+ };
37
+
38
+ let config: ResolvedConfig = { ...DEFAULT_CONFIG };
39
+
40
+ /**
41
+ * Set configuration (called by integration)
42
+ */
43
+ export function setConfig(userConfig: SessionKitConfig): void {
44
+ // Start with default config
45
+ const newConfig: ResolvedConfig = { ...DEFAULT_CONFIG };
46
+
47
+ // Validate and set loginPath
48
+ if (userConfig.loginPath !== undefined) {
49
+ if (!isValidRedirectPath(userConfig.loginPath)) {
50
+ throw new Error(
51
+ `[SessionKit] Invalid loginPath: "${userConfig.loginPath}". Must start with / and be less than 500 characters.`
52
+ );
53
+ }
54
+ newConfig.loginPath = userConfig.loginPath;
55
+ }
56
+
57
+ // Validate protection rules
58
+ if (userConfig.protect) {
59
+ for (const rule of userConfig.protect) {
60
+ // Validate pattern
61
+ if (!isValidPattern(rule.pattern)) {
62
+ throw new Error(
63
+ `[SessionKit] Invalid pattern: "${rule.pattern}". ` +
64
+ `Patterns must start with / and be less than 1000 characters.`
65
+ );
66
+ }
67
+
68
+ // Validate redirectTo if present
69
+ if (rule.redirectTo && !isValidRedirectPath(rule.redirectTo)) {
70
+ throw new Error(
71
+ `[SessionKit] Invalid redirectTo: "${rule.redirectTo}". ` +
72
+ `Must start with / and be less than 500 characters.`
73
+ );
74
+ }
75
+ }
76
+ newConfig.protect = [...userConfig.protect];
77
+ }
78
+
79
+ // Validate context store getter/setter pair
80
+ if ((userConfig.getContextStore && !userConfig.setContextStore) || (!userConfig.getContextStore && userConfig.setContextStore)) {
81
+ throw new Error(
82
+ '[SessionKit] Both getContextStore and setContextStore must be provided together if using custom context storage.'
83
+ );
84
+ }
85
+
86
+ // Set access hooks
87
+ if (userConfig.access) {
88
+ newConfig.access = {
89
+ getRole: userConfig.access.getRole ?? DEFAULT_CONFIG.access.getRole,
90
+ getPermissions: userConfig.access.getPermissions ?? DEFAULT_CONFIG.access.getPermissions,
91
+ check: userConfig.access.check,
92
+ };
93
+
94
+ // Migration/Safety: If user misplaced globalProtect/exclude/debug in access object,
95
+ // we honor them but warn about it.
96
+ const anyAccess = userConfig.access as any;
97
+ if (anyAccess.globalProtect !== undefined && userConfig.globalProtect === undefined) {
98
+ newConfig.globalProtect = anyAccess.globalProtect;
99
+ if (process.env.NODE_ENV !== 'production') {
100
+ console.warn('[SessionKit] Deprecation: globalProtect should be at the top level of configuration, not inside "access".');
101
+ }
102
+ }
103
+ if (anyAccess.exclude !== undefined && userConfig.exclude === undefined) {
104
+ newConfig.exclude = anyAccess.exclude;
105
+ if (process.env.NODE_ENV !== 'production') {
106
+ console.warn('[SessionKit] Deprecation: exclude should be at the top level of configuration, not inside "access".');
107
+ }
108
+ }
109
+ if (anyAccess.debug !== undefined && userConfig.debug === undefined) {
110
+ newConfig.debug = anyAccess.debug;
111
+ if (process.env.NODE_ENV !== 'production') {
112
+ console.warn('[SessionKit] Deprecation: debug should be at the top level of configuration, not inside "access".');
113
+ }
114
+ }
115
+ }
116
+
117
+ // Set context hooks
118
+ newConfig.runWithContext = userConfig.runWithContext;
119
+ newConfig.getContextStore = userConfig.getContextStore;
120
+ newConfig.setContextStore = userConfig.setContextStore;
121
+
122
+ if (userConfig.globalProtect !== undefined) {
123
+ newConfig.globalProtect = userConfig.globalProtect;
124
+ }
125
+
126
+ if (userConfig.exclude !== undefined) {
127
+ for (const pattern of userConfig.exclude) {
128
+ if (!isValidPattern(pattern)) {
129
+ throw new Error(
130
+ `[SessionKit] Invalid exclude pattern: "${pattern}". ` +
131
+ `Patterns must start with / and be less than 1000 characters.`
132
+ );
133
+ }
134
+ }
135
+ newConfig.exclude = [...userConfig.exclude];
136
+ } else if (newConfig.exclude.length === 0) {
137
+ newConfig.exclude = [...DEFAULT_CONFIG.exclude];
138
+ }
139
+
140
+ if (userConfig.debug !== undefined) {
141
+ newConfig.debug = userConfig.debug;
142
+ }
143
+
144
+ // Atomic update
145
+ config = Object.freeze(newConfig);
146
+ }
147
+
148
+ /**
149
+ * Get current configuration
150
+ */
151
+ export function getConfig(): ResolvedConfig {
152
+ return config;
153
+ }
@@ -0,0 +1,30 @@
1
+ // ============================================================================
2
+ // Session Context (AsyncLocalStorage)
3
+ // ============================================================================
4
+
5
+ import { AsyncLocalStorage } from "node:async_hooks";
6
+ import type { SessionContext } from "./types";
7
+ import { getConfig } from "./config";
8
+
9
+ const als = new AsyncLocalStorage<SessionContext>();
10
+
11
+ /**
12
+ * Run a function with session context available
13
+ */
14
+ export function runWithContext<T>(
15
+ context: SessionContext,
16
+ fn: () => T
17
+ ): T {
18
+ return als.run(context, fn);
19
+ }
20
+
21
+ /**
22
+ * Get the current session context (only available inside middleware chain)
23
+ */
24
+ export function getContextStore(): SessionContext | undefined {
25
+ const customGetter = getConfig().getContextStore;
26
+ if (customGetter) {
27
+ return customGetter();
28
+ }
29
+ return als.getStore();
30
+ }
@@ -0,0 +1,131 @@
1
+ // ============================================================================
2
+ // Route Guard Middleware - Enforces protection rules
3
+ // ============================================================================
4
+
5
+ import type {APIContext, MiddlewareHandler} from "astro";
6
+ import { getContextStore } from "./context";
7
+ import { getConfig } from "./config";
8
+ import { matchesPattern } from "./matcher";
9
+ import type { ProtectionRule, Session } from "./types";
10
+ import { isValidSessionStructure } from "./validation";
11
+
12
+ /**
13
+ * Check if session satisfies a protection rule
14
+ */
15
+ async function checkRule(rule: ProtectionRule, session: Session | null): Promise<boolean> {
16
+ const { access } = getConfig();
17
+
18
+ // Custom check overrides everything
19
+ if (access.check) {
20
+ try {
21
+ return await access.check(rule, session);
22
+ } catch (error) {
23
+ if (process.env.NODE_ENV !== 'production') {
24
+ console.error('[SessionKit] Error in custom access check hook:', error);
25
+ }
26
+ return false;
27
+ }
28
+ }
29
+
30
+ // Custom allow function
31
+ if ("allow" in rule) {
32
+ try {
33
+ return await rule.allow(session);
34
+ } catch (error) {
35
+ if (process.env.NODE_ENV !== 'production') {
36
+ console.error('[SessionKit] Error in custom rule allow function:', error);
37
+ }
38
+ return false;
39
+ }
40
+ }
41
+
42
+ // Must be authenticated and have a valid session structure for all other checks
43
+ if (!session || !isValidSessionStructure(session)) {
44
+ return false;
45
+ }
46
+
47
+ // Single role check
48
+ if ("role" in rule) {
49
+ const userRole = access.getRole(session);
50
+ return userRole === rule.role;
51
+ }
52
+
53
+ // Multiple roles check (user must have ONE of these)
54
+ if ("roles" in rule) {
55
+ const userRole = access.getRole(session);
56
+ return userRole !== null && rule.roles.includes(userRole);
57
+ }
58
+
59
+ // Single permission check
60
+ if ("permission" in rule) {
61
+ const userPermissions = access.getPermissions(session);
62
+ return userPermissions.includes(rule.permission);
63
+ }
64
+
65
+ // Multiple permissions check (user must have ALL of these)
66
+ if ("permissions" in rule) {
67
+ const userPermissions = access.getPermissions(session);
68
+ return rule.permissions.every((p) => userPermissions.includes(p));
69
+ }
70
+
71
+ // No specific rule matched - allow by default
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * Create route guard middleware
77
+ */
78
+ export function createGuardMiddleware(): MiddlewareHandler {
79
+ return async (context : APIContext, next) => {
80
+ const { protect, loginPath, globalProtect, exclude } = getConfig();
81
+
82
+ // No rules configured and no global protect - skip
83
+ if (protect.length === 0 && !globalProtect) {
84
+ return next();
85
+ }
86
+
87
+ let pathname: string;
88
+ try {
89
+ pathname = new URL(context.request.url).pathname;
90
+ } catch {
91
+ // Fallback if URL is invalid (unlikely in Astro)
92
+ pathname = "/";
93
+ }
94
+ const sessionContext = getContextStore();
95
+ const session = sessionContext?.session ?? null;
96
+
97
+ // Find matching rule
98
+ const rule = protect.find((r) => matchesPattern(r.pattern, pathname));
99
+
100
+ // No matching rule - check global protection
101
+ if (!rule) {
102
+ if (globalProtect) {
103
+ // Skip if path is in exclude list
104
+ if (exclude.some((pattern) => matchesPattern(pattern, pathname))) {
105
+ return next();
106
+ }
107
+
108
+ // Skip if it's the login page itself (to avoid redirect loops)
109
+ if (pathname === loginPath) {
110
+ return next();
111
+ }
112
+
113
+ // Require valid session
114
+ if (!session || !isValidSessionStructure(session)) {
115
+ return context.redirect(loginPath);
116
+ }
117
+ }
118
+ return next();
119
+ }
120
+
121
+ // Check if access is allowed
122
+ const allowed = await checkRule(rule, session);
123
+
124
+ if (!allowed) {
125
+ const redirectTo = rule.redirectTo ?? loginPath;
126
+ return context.redirect(redirectTo);
127
+ }
128
+
129
+ return next();
130
+ };
131
+ }
@@ -0,0 +1,31 @@
1
+ import { getConfig } from "./config";
2
+
3
+ /**
4
+ * Log message if debug mode is enabled
5
+ */
6
+ export function debug(message: string, ...args: any[]): void {
7
+ const { debug } = getConfig();
8
+ if (debug) {
9
+ console.debug(`[SessionKit] ${message}`, ...args);
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Log error message. Always logs unless in production, but can be forced via debug flag.
15
+ */
16
+ export function error(message: string, ...args: any[]): void {
17
+ const { debug } = getConfig();
18
+ if (debug || process.env.NODE_ENV !== 'production') {
19
+ console.error(`[SessionKit] ${message}`, ...args);
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Log warning message. Always logs unless in production, but can be forced via debug flag.
25
+ */
26
+ export function warn(message: string, ...args: any[]): void {
27
+ const { debug } = getConfig();
28
+ if (debug || process.env.NODE_ENV !== 'production') {
29
+ console.warn(`[SessionKit] ${message}`, ...args);
30
+ }
31
+ }
@@ -0,0 +1,61 @@
1
+ // ============================================================================
2
+ // Route Pattern Matching
3
+ // ============================================================================
4
+
5
+ function escapeRegex(str: string): string {
6
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ }
8
+
9
+ function globToRegex(pattern: string): RegExp {
10
+ let regex = "";
11
+ let i = 0;
12
+
13
+ while (i < pattern.length) {
14
+ const char = pattern[i];
15
+ const next = pattern[i + 1];
16
+
17
+ // Handle **
18
+ if (char === "*" && next === "*") {
19
+ const isAtEnd = i + 2 === pattern.length;
20
+ const prevIsSlash = i > 0 && pattern[i - 1] === "/";
21
+
22
+ if (prevIsSlash) {
23
+ // Handle "/**"
24
+ if (isAtEnd) {
25
+ // "/**" at end matches everything from that point
26
+ if (regex.endsWith("/")) regex = regex.slice(0, -1);
27
+ regex += "(?:/.*)?";
28
+ } else if (pattern[i + 2] === "/") {
29
+ // "/**/" matches zero or more segments
30
+ if (regex.endsWith("/")) regex = regex.slice(0, -1);
31
+ regex += "(?:/.*)?";
32
+ i += 1; // skip one extra for the trailing slash
33
+ } else {
34
+ regex += ".*";
35
+ }
36
+ } else {
37
+ regex += ".*";
38
+ }
39
+
40
+ i += 2;
41
+ continue;
42
+ }
43
+
44
+ // Handle *
45
+ if (char === "*") {
46
+ // one or more segments (to maintain backward compatibility with previous tests)
47
+ regex += "[^/]+(?:/[^/]+)*";
48
+ i += 1;
49
+ continue;
50
+ }
51
+
52
+ regex += escapeRegex(char as string);
53
+ i += 1;
54
+ }
55
+
56
+ return new RegExp(`^${regex}$`);
57
+ }
58
+
59
+ export function matchesPattern(pattern: string, path: string): boolean {
60
+ return globToRegex(pattern).test(path);
61
+ }
@@ -0,0 +1,65 @@
1
+ // ============================================================================
2
+ // Session Middleware - Loads session into AsyncLocalStorage
3
+ // ============================================================================
4
+
5
+ import type {MiddlewareHandler} from "astro";
6
+ import {runWithContext as defaultRunWithContext} from "./context";
7
+ import {isValidSessionStructure} from "./validation";
8
+ import type {Session} from "./types";
9
+ import {getConfig} from "./config";
10
+ import {warn} from "./logger";
11
+
12
+ /**
13
+ * Session key used to store session in context.session
14
+ */
15
+ const SESSION_KEY = "__session__";
16
+
17
+ /**
18
+ * Main session middleware
19
+ *
20
+ * Reads session from context.session.get('__session__') and makes it available
21
+ * throughout the request via AsyncLocalStorage
22
+ */
23
+ export const sessionMiddleware: MiddlewareHandler = async (context, next) => {
24
+ // Get session from context.session store
25
+ const rawSession = context.session?.get<Session>(SESSION_KEY) ?? null;
26
+
27
+ // Validate session structure if present
28
+ let session: Session | null = null;
29
+
30
+ if (rawSession) {
31
+ if (isValidSessionStructure(rawSession)) {
32
+ session = rawSession;
33
+ } else {
34
+ // Invalid session structure - log warning and treat as unauthenticated
35
+ warn(
36
+ 'Invalid session structure detected. Session will be ignored. ' +
37
+ 'Ensure context.session.set("__session__", ...) has the correct structure. ' +
38
+ 'Received: ' + JSON.stringify(rawSession)
39
+ );
40
+ session = null;
41
+ }
42
+ }
43
+
44
+ // Run the rest of the request chain with session context
45
+ const config = getConfig();
46
+
47
+ // If getContextStore is provided, but runWithContext is NOT,
48
+ // we assume the user is managing the context at a superior level
49
+ // and we should NOT wrap the call in our default runner.
50
+ if (config.getContextStore && !config.runWithContext) {
51
+ // Initialize context store if setter is provided
52
+ const store = config.getContextStore();
53
+ if (store) {
54
+ store.session = session;
55
+ } else if (config.setContextStore) {
56
+ config.setContextStore({session});
57
+ } else if (process.env.NODE_ENV !== 'production') {
58
+ console.error('[SessionKit] getContextStore returned undefined, cannot set session');
59
+ }
60
+ return next();
61
+ }
62
+
63
+ const runner = config.runWithContext ?? defaultRunWithContext;
64
+ return runner({session}, () => next());
65
+ };
@@ -0,0 +1,147 @@
1
+ // ============================================================================
2
+ // Core Session Types
3
+ // ============================================================================
4
+
5
+ /**
6
+ * The session object stored in context.locals.session
7
+ * This is what your Astro app provides - we just read it.
8
+ */
9
+ export interface Session {
10
+ /** Unique user identifier */
11
+ userId: string;
12
+
13
+ /** User's email (optional) */
14
+ email?: string;
15
+
16
+ /** Primary role */
17
+ role?: string;
18
+
19
+ /** Additional roles for multi-role scenarios */
20
+ roles?: string[];
21
+
22
+ /** Fine-grained permissions */
23
+ permissions?: string[];
24
+
25
+ /** Any additional custom data */
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ /**
30
+ * What we store in AsyncLocalStorage
31
+ */
32
+ export interface SessionContext {
33
+ session: Session | null;
34
+ }
35
+
36
+ // ============================================================================
37
+ // Route Protection Rules
38
+ // ============================================================================
39
+
40
+ interface BaseProtectionRule {
41
+ /** Glob pattern for route matching: "/admin/**", "/dashboard/*", "/settings" */
42
+ pattern: string;
43
+
44
+ /** Where to redirect if access denied (defaults to global loginPath) */
45
+ redirectTo?: string;
46
+ }
47
+
48
+ /** Protect by single role */
49
+ export interface RoleProtectionRule extends BaseProtectionRule {
50
+ role: string;
51
+ }
52
+
53
+ /** Protect by multiple roles (user must have ONE of these) */
54
+ export interface RolesProtectionRule extends BaseProtectionRule {
55
+ roles: string[];
56
+ }
57
+
58
+ /** Protect by single permission */
59
+ export interface PermissionProtectionRule extends BaseProtectionRule {
60
+ permission: string;
61
+ }
62
+
63
+ /** Protect by multiple permissions (user must have ALL of these) */
64
+ export interface PermissionsProtectionRule extends BaseProtectionRule {
65
+ permissions: string[];
66
+ }
67
+
68
+ /** Protect with custom function */
69
+ export interface CustomProtectionRule extends BaseProtectionRule {
70
+ allow: (session: Session | null) => boolean | Promise<boolean>;
71
+ }
72
+
73
+ /** Union of all protection rule types */
74
+ export type ProtectionRule =
75
+ | RoleProtectionRule
76
+ | RolesProtectionRule
77
+ | PermissionProtectionRule
78
+ | PermissionsProtectionRule
79
+ | CustomProtectionRule;
80
+
81
+ // ============================================================================
82
+ // Configuration
83
+ // ============================================================================
84
+
85
+ /**
86
+ * Optional hooks for custom role/permission extraction
87
+ */
88
+ export interface AccessHooks {
89
+ /** Extract role from session (default: session.role) */
90
+ getRole?: (session: Session | null) => string | null;
91
+
92
+ /** Extract permissions from session (default: session.permissions ?? []) */
93
+ getPermissions?: (session: Session | null) => string[];
94
+
95
+ /** Custom access check that overrides all built-in logic */
96
+ check?: (rule: ProtectionRule, session: Session | null) => boolean | Promise<boolean>;
97
+ }
98
+
99
+ /**
100
+ * SessionKit configuration
101
+ */
102
+ export interface SessionKitConfig {
103
+ /** Default redirect path for protected routes (default: "/login") */
104
+ loginPath?: string;
105
+
106
+ /** Route protection rules */
107
+ protect?: ProtectionRule[];
108
+
109
+ /** Custom access hooks */
110
+ access?: AccessHooks;
111
+
112
+ /**
113
+ * Custom session context runner (optional)
114
+ * Use this to provide your own AsyncLocalStorage implementation or wrap the request
115
+ */
116
+ runWithContext?: <T>(context: SessionContext, fn: () => T) => T;
117
+
118
+ /**
119
+ * Custom session context getter (optional)
120
+ * Use this to provide your own way to retrieve the session context
121
+ */
122
+ getContextStore?: () => SessionContext | undefined;
123
+
124
+ /**
125
+ * Custom session context setter (optional)
126
+ * Use this to provide your own way to initialize the session context
127
+ */
128
+ setContextStore?: (context: SessionContext) => void;
129
+
130
+ /**
131
+ * Enable global session verification for all routes.
132
+ * If true, any route not explicitly ignored will require an active session.
133
+ * @default false
134
+ */
135
+ globalProtect?: boolean;
136
+
137
+ /**
138
+ * Routes to exclude from global protection (only if globalProtect is true)
139
+ */
140
+ exclude?: string[];
141
+
142
+ /**
143
+ * Enable debug logging
144
+ * @default false
145
+ */
146
+ debug?: boolean;
147
+ }