@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.
- package/README.md +44 -0
- package/package.json +19 -0
- package/src/adapter.ts +24 -0
- package/src/core/activity.ts +294 -0
- package/src/core/auth.ts +424 -0
- package/src/core/credentials.ts +665 -0
- package/src/core/guards.ts +152 -0
- package/src/core/oauth.ts +348 -0
- package/src/core/organization.ts +171 -0
- package/src/core/plugin.ts +178 -0
- package/src/core/rate-limit.ts +235 -0
- package/src/core/roles.ts +219 -0
- package/src/core/turnstile.ts +275 -0
- package/src/index.ts +69 -0
- package/src/utils/activity-actions.ts +50 -0
- package/src/utils/crypto.ts +207 -0
|
@@ -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
|
+
|