@kuratchi/auth 0.0.1

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,178 @@
1
+ /**
2
+ * @kuratchi/auth — Core plugin system
3
+ * Enables modular, composable authentication middleware
4
+ */
5
+
6
+ export type MaybePromise<T> = T | Promise<T>;
7
+
8
+ /**
9
+ * Base context available to all plugins
10
+ * Maps to KuratchiJS RouteContext — direct env access, no SvelteKit indirection
11
+ */
12
+ export interface PluginContext {
13
+ /** The incoming Request (standard Web API) */
14
+ request: Request;
15
+ /** Cloudflare Worker env — D1, KV, R2, DO, etc. */
16
+ env: Record<string, any>;
17
+ /** Parsed URL */
18
+ url: URL;
19
+ /** Request-scoped state (shared across middleware/plugins) */
20
+ locals: Record<string, any>;
21
+ /** Auth-specific env values resolved from env bindings */
22
+ authEnv: AuthEnv;
23
+ }
24
+
25
+ /**
26
+ * Context available after session is resolved
27
+ */
28
+ export interface SessionContext extends PluginContext {
29
+ session: any;
30
+ user: any;
31
+ }
32
+
33
+ /**
34
+ * Context available when handling response
35
+ */
36
+ export interface ResponseContext extends SessionContext {
37
+ response: Response;
38
+ }
39
+
40
+ /**
41
+ * Auth environment — resolved from Cloudflare Worker env bindings
42
+ */
43
+ export interface AuthEnv {
44
+ AUTH_SECRET: string;
45
+ ORIGIN?: string;
46
+ RESEND_API_KEY?: string;
47
+ EMAIL_FROM?: string;
48
+ GOOGLE_CLIENT_ID?: string;
49
+ GOOGLE_CLIENT_SECRET?: string;
50
+ GITHUB_CLIENT_ID?: string;
51
+ GITHUB_CLIENT_SECRET?: string;
52
+ TURNSTILE_SECRET?: string;
53
+ TURNSTILE_SITE_KEY?: string;
54
+ [key: string]: string | undefined;
55
+ }
56
+
57
+ /**
58
+ * Auth plugin interface
59
+ * Plugins can hook into different lifecycle stages
60
+ */
61
+ export interface AuthPlugin {
62
+ /** Unique plugin name */
63
+ name: string;
64
+
65
+ /**
66
+ * Priority for execution order (lower runs first)
67
+ * Default: 100
68
+ * Recommended ranges:
69
+ * - 0-20: Infrastructure (rate limiting)
70
+ * - 20-40: Session management
71
+ * - 40-60: Storage bindings
72
+ * - 60-80: Auth flows (OAuth, credentials, magic links)
73
+ * - 80-100: Route guards
74
+ * - 100+: Custom/analytics
75
+ */
76
+ priority?: number;
77
+
78
+ /**
79
+ * Called early in request lifecycle
80
+ * Can short-circuit by returning a Response
81
+ * Use for: setup, route handling, redirects
82
+ */
83
+ onRequest?: (context: PluginContext) => MaybePromise<Response | void>;
84
+
85
+ /**
86
+ * Called after session is resolved
87
+ * Use for: session enrichment, user data loading
88
+ */
89
+ onSession?: (context: SessionContext) => MaybePromise<void>;
90
+
91
+ /**
92
+ * Called before response is sent
93
+ * Can modify or replace the response
94
+ * Use for: response headers, logging, analytics
95
+ */
96
+ onResponse?: (context: ResponseContext) => MaybePromise<Response | void>;
97
+ }
98
+
99
+ /**
100
+ * Plugin registry and execution engine
101
+ */
102
+ export class PluginRegistry {
103
+ private plugins: AuthPlugin[] = [];
104
+
105
+ register(plugin: AuthPlugin): void {
106
+ if (this.plugins.some(p => p.name === plugin.name)) {
107
+ console.warn(`[kuratchi/auth] Plugin "${plugin.name}" already registered, skipping`);
108
+ return;
109
+ }
110
+
111
+ this.plugins.push(plugin);
112
+ this.plugins.sort((a, b) => (a.priority || 100) - (b.priority || 100));
113
+ }
114
+
115
+ registerMany(plugins: AuthPlugin[]): void {
116
+ plugins.forEach(p => this.register(p));
117
+ }
118
+
119
+ getPlugins(): AuthPlugin[] {
120
+ return [...this.plugins];
121
+ }
122
+
123
+ getPlugin(name: string): AuthPlugin | undefined {
124
+ return this.plugins.find(p => p.name === name);
125
+ }
126
+
127
+ async executeOnRequest(context: PluginContext): Promise<Response | void> {
128
+ for (const plugin of this.plugins) {
129
+ if (plugin.onRequest) {
130
+ const result = await plugin.onRequest(context);
131
+ if (result instanceof Response) {
132
+ return result;
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ async executeOnSession(context: SessionContext): Promise<void> {
139
+ for (const plugin of this.plugins) {
140
+ if (plugin.onSession) {
141
+ await plugin.onSession(context);
142
+ }
143
+ }
144
+ }
145
+
146
+ async executeOnResponse(context: ResponseContext): Promise<Response> {
147
+ let response = context.response;
148
+
149
+ for (const plugin of this.plugins) {
150
+ if (plugin.onResponse) {
151
+ const result = await plugin.onResponse({ ...context, response });
152
+ if (result instanceof Response) {
153
+ response = result;
154
+ }
155
+ }
156
+ }
157
+
158
+ return response;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Helper to create a simple plugin
164
+ */
165
+ export function createPlugin(
166
+ name: string,
167
+ hooks: Partial<Pick<AuthPlugin, 'onRequest' | 'onSession' | 'onResponse'>>,
168
+ priority?: number
169
+ ): AuthPlugin {
170
+ return {
171
+ name,
172
+ priority,
173
+ ...hooks
174
+ };
175
+ }
176
+
177
+
178
+
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @kuratchi/auth — Rate Limiting API
3
+ *
4
+ * Config-driven request throttling. Runs in the worker entry before route handlers.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * // In kuratchi.config.ts:
9
+ * auth: {
10
+ * rateLimit: {
11
+ * routes: [
12
+ * { path: '/auth/login', methods: ['POST'], maxRequests: 10, windowMs: 60000 },
13
+ * { path: '/auth/signup', methods: ['POST'], maxRequests: 5, windowMs: 60000 },
14
+ * ]
15
+ * }
16
+ * }
17
+ * ```
18
+ */
19
+
20
+ // ============================================================================
21
+ // Types
22
+ // ============================================================================
23
+
24
+ export interface RateLimitRouteConfig {
25
+ /** Unique identifier for the route (used in storage key) */
26
+ id?: string;
27
+ /** Path matcher — literal path or glob with * */
28
+ path: string;
29
+ /** HTTP methods to match (defaults to all) */
30
+ methods?: string[];
31
+ /** Maximum requests within the window */
32
+ maxRequests?: number;
33
+ /** Window duration in milliseconds */
34
+ windowMs?: number;
35
+ /** Custom error message */
36
+ message?: string;
37
+ }
38
+
39
+ export interface RateLimitConfig {
40
+ /** Default window in ms (default: 60000) */
41
+ defaultWindowMs?: number;
42
+ /** Default max requests (default: 10) */
43
+ defaultMaxRequests?: number;
44
+ /** KV binding name for cross-instance rate limiting (optional) */
45
+ kvBinding?: string;
46
+ /** Key prefix (default: 'ratelimit') */
47
+ keyPrefix?: string;
48
+ /** Route-specific rate limit configs */
49
+ routes?: RateLimitRouteConfig[];
50
+ }
51
+
52
+ interface RateLimitRecord {
53
+ count: number;
54
+ expiresAt: number;
55
+ }
56
+
57
+ // ============================================================================
58
+ // Module state
59
+ // ============================================================================
60
+
61
+ let _config: RateLimitConfig | null = null;
62
+
63
+ // In-memory store (per-isolate, resets on cold start)
64
+ const _store = new Map<string, RateLimitRecord>();
65
+
66
+ /**
67
+ * Configure rate limiting. Called automatically by the compiler from kuratchi.config.ts.
68
+ */
69
+ export function configureRateLimit(config: RateLimitConfig): void {
70
+ _config = config;
71
+ }
72
+
73
+ // ============================================================================
74
+ // Framework context
75
+ // ============================================================================
76
+
77
+ function _getContext() {
78
+ const dezContext = (globalThis as any).__kuratchi_context__;
79
+ const env = (globalThis as any).__cloudflare_env__ ?? {};
80
+ return {
81
+ request: dezContext?.request as Request | undefined,
82
+ locals: dezContext?.locals as Record<string, any> ?? {},
83
+ env,
84
+ };
85
+ }
86
+
87
+ // ============================================================================
88
+ // Pattern matching
89
+ // ============================================================================
90
+
91
+ function _matchPath(pathname: string, pattern: string): boolean {
92
+ const trimmed = pattern !== '/' && pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
93
+ const escaped = trimmed
94
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
95
+ .replace(/\\\*/g, '.*');
96
+ return new RegExp(`^${escaped}/?$`, 'i').test(pathname);
97
+ }
98
+
99
+ // ============================================================================
100
+ // Store operations
101
+ // ============================================================================
102
+
103
+ async function _increment(key: string, windowMs: number, kvBinding?: any): Promise<RateLimitRecord> {
104
+ const now = Date.now();
105
+
106
+ // Try KV if available
107
+ if (kvBinding) {
108
+ try {
109
+ const prefix = _config?.keyPrefix || 'ratelimit';
110
+ const storageKey = `${prefix}:${key}`;
111
+ const existing = await kvBinding.get(storageKey, 'json') as RateLimitRecord | null;
112
+
113
+ if (!existing || existing.expiresAt <= now) {
114
+ const record = { count: 1, expiresAt: now + windowMs };
115
+ await kvBinding.put(storageKey, JSON.stringify(record), {
116
+ expirationTtl: Math.max(1, Math.ceil(windowMs / 1000)),
117
+ });
118
+ return record;
119
+ }
120
+
121
+ const updated = { count: existing.count + 1, expiresAt: existing.expiresAt };
122
+ const ttl = Math.max(1, Math.ceil((updated.expiresAt - now) / 1000));
123
+ await kvBinding.put(storageKey, JSON.stringify(updated), { expirationTtl: ttl });
124
+ return updated;
125
+ } catch {
126
+ // Fall through to memory store
127
+ }
128
+ }
129
+
130
+ // Memory store fallback
131
+ const existing = _store.get(key);
132
+ if (!existing || existing.expiresAt <= now) {
133
+ const record = { count: 1, expiresAt: now + windowMs };
134
+ _store.set(key, record);
135
+ return record;
136
+ }
137
+
138
+ const updated = { count: existing.count + 1, expiresAt: existing.expiresAt };
139
+ _store.set(key, updated);
140
+ return updated;
141
+ }
142
+
143
+ // ============================================================================
144
+ // Public API
145
+ // ============================================================================
146
+
147
+ /**
148
+ * Check rate limits for the current request.
149
+ * Called by the compiler-generated worker entry before route handlers.
150
+ * Returns a 429 Response if rate limited, or null to proceed.
151
+ */
152
+ export function checkRateLimit(): Promise<Response | null> {
153
+ return _checkRateLimitAsync();
154
+ }
155
+
156
+ async function _checkRateLimitAsync(): Promise<Response | null> {
157
+ if (!_config?.routes?.length) return null;
158
+
159
+ const { request, env } = _getContext();
160
+ if (!request) return null;
161
+
162
+ const url = new URL(request.url);
163
+ const method = request.method.toUpperCase();
164
+ const pathname = url.pathname;
165
+
166
+ // Find matching route config
167
+ const matched = _config.routes.find(route => {
168
+ if (!_matchPath(pathname, route.path)) return false;
169
+ if (route.methods?.length) {
170
+ return route.methods.map(m => m.toUpperCase()).includes(method);
171
+ }
172
+ return true;
173
+ });
174
+
175
+ if (!matched) return null;
176
+
177
+ // Resolve identifier (IP address)
178
+ const ip = request.headers.get('cf-connecting-ip')
179
+ || request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
180
+ || 'unknown';
181
+
182
+ const routeId = matched.id || matched.path;
183
+ const windowMs = matched.windowMs ?? _config.defaultWindowMs ?? 60_000;
184
+ const maxRequests = matched.maxRequests ?? _config.defaultMaxRequests ?? 10;
185
+
186
+ // Get KV binding if configured
187
+ const kvBinding = _config.kvBinding ? env[_config.kvBinding] : null;
188
+
189
+ const record = await _increment(`${routeId}:${ip}`, windowMs, kvBinding);
190
+
191
+ if (record.count > maxRequests) {
192
+ const retryAfter = Math.max(1, Math.ceil((record.expiresAt - Date.now()) / 1000));
193
+ return new Response(JSON.stringify({
194
+ error: 'too_many_requests',
195
+ message: matched.message || 'Too many requests. Please try again later.',
196
+ }), {
197
+ status: 429,
198
+ headers: {
199
+ 'Content-Type': 'application/json',
200
+ 'Retry-After': retryAfter.toString(),
201
+ 'X-RateLimit-Limit': String(maxRequests),
202
+ 'X-RateLimit-Remaining': '0',
203
+ 'X-RateLimit-Reset': Math.ceil(record.expiresAt / 1000).toString(),
204
+ },
205
+ });
206
+ }
207
+
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * Get rate limit info for a specific route (for use in server functions).
213
+ */
214
+ export function getRateLimitInfo(routeId: string): { limit: number; remaining: number; reset: number } | null {
215
+ if (!_config?.routes) return null;
216
+ const route = _config.routes.find(r => (r.id || r.path) === routeId);
217
+ if (!route) return null;
218
+
219
+ const maxRequests = route.maxRequests ?? _config.defaultMaxRequests ?? 10;
220
+ const key = `${routeId}:unknown`;
221
+ const record = _store.get(key);
222
+
223
+ if (!record || record.expiresAt <= Date.now()) {
224
+ return { limit: maxRequests, remaining: maxRequests, reset: 0 };
225
+ }
226
+
227
+ return {
228
+ limit: maxRequests,
229
+ remaining: Math.max(0, maxRequests - record.count),
230
+ reset: Math.ceil(record.expiresAt / 1000),
231
+ };
232
+ }
233
+
234
+
235
+
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @kuratchi/auth — Roles & Permissions API
3
+ *
4
+ * Ready-to-use server functions for RBAC.
5
+ * Define roles in config, then use hasRole/hasPermission anywhere.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { defineRoles, hasRole, hasPermission, assignRole, getRolesData } from '@kuratchi/auth';
10
+ *
11
+ * const Roles = defineRoles({
12
+ * admin: ['*'],
13
+ * editor: ['todos.*', 'users.read'],
14
+ * user: ['todos.read', 'todos.create'],
15
+ * });
16
+ *
17
+ * // Check permissions:
18
+ * const user = await getCurrentUser();
19
+ * if (hasPermission(user, 'todos.delete')) { ... }
20
+ *
21
+ * // Assign role:
22
+ * await assignRole(formData); // reads userId + role from FormData
23
+ * ```
24
+ */
25
+
26
+ // ============================================================================
27
+ // Types
28
+ // ============================================================================
29
+
30
+ export type RoleDefinitions = Record<string, string[]>;
31
+
32
+ export interface RolesConfig {
33
+ /** Default role for new users (default: 'user') */
34
+ defaultRole?: string;
35
+ }
36
+
37
+ // ============================================================================
38
+ // Module state
39
+ // ============================================================================
40
+
41
+ let _definitions: RoleDefinitions = {};
42
+ let _allRoles: string[] = [];
43
+ let _defaultRole: string = 'user';
44
+
45
+ /**
46
+ * Define role → permission mappings. Returns a typed constant object
47
+ * mapping role names to themselves (for type-safe usage).
48
+ */
49
+ export function defineRoles<T extends RoleDefinitions>(
50
+ definitions: T,
51
+ config?: RolesConfig,
52
+ ): { [K in keyof T]: K } {
53
+ _definitions = definitions;
54
+ _allRoles = Object.keys(definitions);
55
+ if (config?.defaultRole) _defaultRole = config.defaultRole;
56
+
57
+ const roles = {} as { [K in keyof T]: K };
58
+ for (const key of Object.keys(definitions) as (keyof T & string)[]) {
59
+ (roles as any)[key] = key;
60
+ }
61
+ return roles;
62
+ }
63
+
64
+ /**
65
+ * Get the registered role definitions.
66
+ */
67
+ export function getRoleDefinitions(): RoleDefinitions {
68
+ return _definitions;
69
+ }
70
+
71
+ /**
72
+ * Get all defined role names.
73
+ */
74
+ export function getAllRoles(): string[] {
75
+ return _allRoles;
76
+ }
77
+
78
+ /**
79
+ * Get the default role name.
80
+ */
81
+ export function getDefaultRole(): string {
82
+ return _defaultRole;
83
+ }
84
+
85
+ // ============================================================================
86
+ // Permission matching
87
+ // ============================================================================
88
+
89
+ function _matchesPermission(permission: string, pattern: string): boolean {
90
+ if (pattern === permission) return true;
91
+ if (pattern === '*') return true;
92
+ if (pattern.endsWith('.*')) {
93
+ const prefix = pattern.slice(0, -2);
94
+ return permission === prefix || permission.startsWith(prefix + '.');
95
+ }
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Get all concrete permissions for a given role.
101
+ * Expands wildcards against all known permissions from all role definitions.
102
+ */
103
+ export function getPermissionsForRole(role: string): string[] {
104
+ const patterns = _definitions[role] || [];
105
+ if (patterns.includes('*')) return _getAllPermissions();
106
+ return _getAllPermissions().filter(p =>
107
+ patterns.some(pattern => _matchesPermission(p, pattern))
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Check if a user object has a specific permission based on their role.
113
+ */
114
+ export function hasPermission(user: { role?: string } | null, permission: string): boolean {
115
+ if (!user) return false;
116
+ const role = user.role || _defaultRole;
117
+ const patterns = _definitions[role] || [];
118
+ return patterns.some(pattern => _matchesPermission(permission, pattern));
119
+ }
120
+
121
+ /**
122
+ * Check if a user object has a specific role.
123
+ */
124
+ export function hasRole(user: { role?: string } | null, role: string): boolean {
125
+ if (!user) return false;
126
+ return (user.role || _defaultRole) === role;
127
+ }
128
+
129
+ // ============================================================================
130
+ // Framework context + DB
131
+ // ============================================================================
132
+
133
+ function _getDb(): any {
134
+ const env = (globalThis as any).__cloudflare_env__ ?? {};
135
+ const binding = env.DB;
136
+ if (!binding) throw new Error('[kuratchi/auth] No DB binding found.');
137
+ return binding;
138
+ }
139
+
140
+ // ============================================================================
141
+ // Server functions
142
+ // ============================================================================
143
+
144
+ /**
145
+ * Assign a role to a user. Reads userId and role from FormData.
146
+ * Callable by users who can manage users via `users.*` permission.
147
+ */
148
+ export async function assignRole(formData: FormData): Promise<void> {
149
+ // Import getCurrentUser at call time to avoid circular deps
150
+ const { getCurrentUser } = await import('./credentials.js');
151
+ const currentUser = await getCurrentUser();
152
+ if (!currentUser || !hasPermission(currentUser, 'users.update')) {
153
+ throw new Error('Not authorized to assign roles');
154
+ }
155
+
156
+ const userId = formData.get('userId') as string;
157
+ const role = formData.get('role') as string;
158
+
159
+ if (!userId || !role) throw new Error('User ID and role are required');
160
+ if (!_allRoles.includes(role)) throw new Error(`Invalid role: ${role}`);
161
+
162
+ const db = _getDb();
163
+ await db.prepare('UPDATE users SET role = ? WHERE id = ?').bind(role, Number(userId)).run();
164
+ }
165
+
166
+ /**
167
+ * Get full roles data for the roles management page.
168
+ * Returns current user, their permissions, role definitions, and all users (if allowed).
169
+ */
170
+ export async function getRolesData() {
171
+ const { getCurrentUser } = await import('./credentials.js');
172
+ const currentUser = await getCurrentUser();
173
+ if (!currentUser) {
174
+ return { isAuthenticated: false, user: null, roles: [], userRole: null, permissions: [], roleDefinitions: {}, allPermissions: [], allUsers: [] };
175
+ }
176
+
177
+ const userRole = currentUser.role || _defaultRole;
178
+ const permissions = getPermissionsForRole(userRole);
179
+
180
+ let allUsers: any[] = [];
181
+ if (hasPermission(currentUser, 'users.read')) {
182
+ const db = _getDb();
183
+ const result = await db.prepare('SELECT id, email, name, role, createdAt FROM users').all();
184
+ allUsers = (result?.results ?? []).map((u: any) => ({
185
+ ...u,
186
+ role: u.role || _defaultRole,
187
+ }));
188
+ }
189
+
190
+ return {
191
+ isAuthenticated: true,
192
+ user: currentUser,
193
+ userRole,
194
+ roles: _allRoles,
195
+ roleDefinitions: _definitions,
196
+ permissions,
197
+ allPermissions: _getAllPermissions(),
198
+ allUsers,
199
+ };
200
+ }
201
+
202
+ // ============================================================================
203
+ // Internal: collect all concrete permissions from role definitions
204
+ // ============================================================================
205
+
206
+ function _getAllPermissions(): string[] {
207
+ const perms = new Set<string>();
208
+ for (const patterns of Object.values(_definitions)) {
209
+ for (const p of patterns) {
210
+ if (p !== '*' && !p.endsWith('.*')) {
211
+ perms.add(p);
212
+ }
213
+ }
214
+ }
215
+ return Array.from(perms);
216
+ }
217
+
218
+
219
+