UNPKG

@makolabs/ripple

Version:

Simple Svelte 5 powered component library ✨

646 lines (645 loc) 27.7 kB
import { query, command } from '$app/server'; import { getRequestEvent } from '$app/server'; import { env } from '$env/dynamic/private'; const CLIENT_ID = env.CLIENT_ID || 'sharkfin'; const PERMISSION_PREFIX = env.PERMISSION_PREFIX || 'sharkfin:'; const ORGANIZATION_ID = env.ALLOWED_ORG_ID; function handleClerkError(error, defaultMessage) { if (error && typeof error === 'object' && 'status' in error && 'details' in error) { const enrichedError = new Error('message' in error && typeof error.message === 'string' ? error.message : 'Unknown error'); enrichedError.status = error.status; enrichedError.details = error.details; enrichedError.clerkError = true; throw enrichedError; } throw new Error(`${defaultMessage}: ${error instanceof Error ? error.message : 'Unknown error'}`); } async function makeClerkRequest(endpoint, options = {}) { const CLERK_SECRET_KEY = env.CLERK_SECRET_KEY; if (!CLERK_SECRET_KEY) { throw new Error('CLERK_SECRET_KEY environment variable is required'); } const response = await fetch(`https://api.clerk.com/v1${endpoint}`, { ...options, headers: { Authorization: `Bearer ${CLERK_SECRET_KEY}`, 'Content-Type': 'application/json', ...options.headers } }); if (!response.ok) { const errorText = await response.text(); console.error(`[Clerk API] ${response.status} ${response.statusText} - ${errorText}`); let errorDetails; try { errorDetails = JSON.parse(errorText); } catch { errorDetails = { message: errorText || `${response.status} ${response.statusText}` }; } const error = new Error(errorDetails.message || `Clerk API request failed: ${response.status} ${response.statusText}`); error.status = response.status; error.details = errorDetails; throw error; } const data = await response.json(); // Ensure all data is serializable by converting to plain objects return JSON.parse(JSON.stringify(data)); } async function makeAdminRequest(endpoint, options = {}) { const ADMIN_API_KEY = env.ADMIN_API_KEY; const PRIVATE_BASE_AUTH_URL = env.PRIVATE_BASE_AUTH_URL; if (!ADMIN_API_KEY || !PRIVATE_BASE_AUTH_URL) { const missing = []; if (!ADMIN_API_KEY) missing.push('ADMIN_API_KEY'); if (!PRIVATE_BASE_AUTH_URL) missing.push('PRIVATE_BASE_AUTH_URL'); throw new Error(`Admin API configuration missing: ${missing.join(', ')}`); } const url = `${PRIVATE_BASE_AUTH_URL}${endpoint}`; const response = await fetch(url, { ...options, headers: { 'X-Admin-API-Key': ADMIN_API_KEY, 'Content-Type': 'application/json', ...options.headers } }); if (!response.ok) { const errorText = await response.text(); console.error(`[Admin API] ${response.status} ${response.statusText} - ${errorText}`); throw new Error(`Admin API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); // Ensure all data is serializable by converting to plain objects return JSON.parse(JSON.stringify(data)); } async function makeAuthRequest(endpoint, options = {}) { const PRIVATE_BASE_AUTH_URL = env.PRIVATE_BASE_AUTH_URL; if (!PRIVATE_BASE_AUTH_URL) { throw new Error('PRIVATE_BASE_AUTH_URL environment variable is required'); } const url = `${PRIVATE_BASE_AUTH_URL}${endpoint}`; const response = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...options.headers } }); const text = await response.text(); let data; try { data = JSON.parse(text); } catch { // Not JSON, treat as plain text error (e.g., "404 page not found") data = { error: text, message: text }; } return { ok: response.ok, status: response.status, data: data }; } async function verifyApiKeyToken(apiKey) { try { const result = await makeAuthRequest('/auth/token', { method: 'POST', headers: { 'X-API-Key': apiKey } }); if (result.ok && result.data?.data?.access_token) { const token = result.data.data.access_token; const verifyResult = await makeAuthRequest('/auth/verify', { method: 'GET', headers: { Authorization: `Bearer ${token}` } }); if (verifyResult.ok && verifyResult.data?.data) { // The API returns "scope" (singular) as a space-separated string, not "scopes" array const scopeString = verifyResult.data.data.scope; const scopes = scopeString ? scopeString.split(' ').filter(Boolean) : []; return { valid: true, scopes: scopes }; } } const errorMsg = result.data?.message || result.data?.error || `API key verification failed with status ${result.status}`; console.warn('[verifyApiKeyToken] Verification failed:', errorMsg); return { valid: false, error: errorMsg }; } catch (error) { console.error('[verifyApiKeyToken] Exception during verification:', error); return { valid: false, error: error instanceof Error ? error.message : 'Unknown error during verification' }; } } async function createUserPermissions(userId, permissions, clientId = CLIENT_ID) { const filteredPermissions = PERMISSION_PREFIX ? permissions.filter((scope) => scope.startsWith(PERMISSION_PREFIX)) : permissions; if (filteredPermissions.length === 0) { return null; } return await makeAdminRequest('/admin/keys', { method: 'POST', body: JSON.stringify({ client_id: clientId, sub: userId, scopes: filteredPermissions }) }); } export const getUsers = query('unchecked', async (options) => { try { const limit = options.pageSize; const offset = (options.page - 1) * options.pageSize; let orderBy = ''; if (options.sortBy) { const prefix = options.sortOrder === 'desc' ? '-' : ''; orderBy = `${prefix}${options.sortBy}`; } else { orderBy = '-created_at'; } const params = new URLSearchParams({ limit: limit.toString(), offset: offset.toString(), order_by: orderBy }); if (options.query) params.append('query', options.query); const [usersData, countData] = await Promise.all([ makeClerkRequest(`/users?${params}`), makeClerkRequest(`/users/count?${params}`) ]); // Extract users array - Clerk API returns { data: [...] } or array directly const users = Array.isArray(usersData) ? usersData : usersData?.data || []; // Ensure all data is serializable by converting to plain objects const serializedUsers = JSON.parse(JSON.stringify(users)); const serializedCountData = JSON.parse(JSON.stringify(countData)); // Return plain object to ensure serialization return JSON.parse(JSON.stringify({ users: serializedUsers, totalUsers: serializedCountData.total_count || 0 })); } catch (error) { console.error('[getUsers] Error:', error); throw new Error(`Failed to fetch users: ${error instanceof Error ? error.message : 'Unknown error'}`); } }); export const createUser = command('unchecked', async (userData) => { const emailAddress = userData.email_addresses?.[0]?.email_address || ''; if (!emailAddress) { throw new Error('Email address is required'); } try { const { permissions, ...userDataOnly } = userData; const clerkUserData = { first_name: userDataOnly.first_name || '', last_name: userDataOnly.last_name || '', email_address: [emailAddress], ...(userDataOnly.username && { username: userDataOnly.username }), ...(userDataOnly.private_metadata && { private_metadata: userDataOnly.private_metadata }) }; let result = await makeClerkRequest('/users', { method: 'POST', body: JSON.stringify(clerkUserData) }); // Ensure result is serializable result = JSON.parse(JSON.stringify(result)); if (ORGANIZATION_ID) { try { await makeClerkRequest(`/organizations/${ORGANIZATION_ID}/memberships`, { method: 'POST', body: JSON.stringify({ user_id: result.id, role: 'org:member' }) }); } catch (orgError) { console.error('[createUser] Failed to add user to organization:', orgError); try { await makeClerkRequest(`/users/${result.id}`, { method: 'DELETE' }); } catch (deleteError) { console.error('[createUser] Failed to delete user after org membership failure:', deleteError); } throw new Error(`Failed to add user to organization. User creation rolled back. Error: ${orgError instanceof Error ? orgError.message : String(orgError)}`); } } if (permissions && permissions.length > 0) { try { const adminKeyResult = await createUserPermissions(result.id, permissions); const apiKey = adminKeyResult?.data?.key; if (adminKeyResult && apiKey) { const updatedUser = await makeClerkRequest(`/users/${result.id}`, { method: 'PATCH', body: JSON.stringify({ private_metadata: { ...result.private_metadata, mako_api_key: apiKey } }) }); // Ensure updatedUser is serializable result = JSON.parse(JSON.stringify(updatedUser)); } result.adminKey = adminKeyResult; result.permissionsAssigned = permissions; } catch (permissionError) { console.error('[createUser] Failed to assign permissions:', permissionError); result.warning = 'User created but permissions assignment failed'; result.permissionError = permissionError instanceof Error ? permissionError.message : String(permissionError); } } // Final serialization check before returning return JSON.parse(JSON.stringify(result)); } catch (error) { console.error('[createUser] Error:', error); handleClerkError(error, 'Failed to create user'); } }); export const updateUser = command('unchecked', async (options) => { const { userId, userData } = options; try { const updateData = {}; if (userData.first_name !== undefined) updateData.first_name = userData.first_name; if (userData.last_name !== undefined) updateData.last_name = userData.last_name; if (userData.username !== undefined && userData.username !== '') { updateData.username = userData.username; } if (userData.private_metadata !== undefined) { updateData.private_metadata = userData.private_metadata; } let result = await makeClerkRequest(`/users/${userId}`, { method: 'PATCH', body: JSON.stringify(updateData) }); // Ensure result is serializable result = JSON.parse(JSON.stringify(result)); if (userData.permissions !== undefined) { try { await updateUserPermissions({ userId, permissions: userData.permissions }); } catch (permError) { console.error('[updateUser] Failed to update permissions:', permError); } } // Final serialization check before returning return JSON.parse(JSON.stringify(result)); } catch (error) { console.error('[updateUser] Error:', error); handleClerkError(error, 'Failed to update user'); } }); export const deleteUser = command('unchecked', async (userId) => { try { await makeClerkRequest(`/users/${userId}`, { method: 'DELETE' }); } catch (error) { console.error('[deleteUser] Error:', error); throw new Error(`Failed to delete user: ${error instanceof Error ? error.message : 'Unknown error'}`); } }); export const deleteUsers = command('unchecked', async (userIds) => { try { await Promise.all(userIds.map((userId) => makeClerkRequest(`/users/${userId}`, { method: 'DELETE' }))); } catch (error) { console.error('[deleteUsers] Error:', error); throw new Error(`Failed to delete users: ${error instanceof Error ? error.message : 'Unknown error'}`); } }); async function fetchUserPermissions(userId) { try { const userData = await makeAdminRequest(`/admin/keys?client_id=${CLIENT_ID}&sub=${userId}`); if (userData?.data?.data && Array.isArray(userData.data.data)) { userData.data.data = userData.data.data.filter((key) => key.status === 'active'); } if (userData?.data?.data && Array.isArray(userData.data.data)) { // Deduplicate permissions by using a Set const allPermissions = userData.data.data.flatMap((key) => key.scopes || []); const dedupedPermissions = Array.from(new Set(allPermissions)); return dedupedPermissions; } else if (userData?.scopes) { const permissions = Array.isArray(userData.scopes) ? userData.scopes : [userData.scopes]; return permissions; } return []; } catch (error) { console.error('[fetchUserPermissions] Error fetching user permissions:', error); try { const allKeysData = await makeAdminRequest('/admin/keys'); const userKey = allKeysData.data.data.find((key) => key.sub === userId && key.client_id === CLIENT_ID && key.status === 'active'); if (userKey) { const permissions = Array.isArray(userKey.scopes) ? userKey.scopes : [userKey.scopes]; return permissions; } return []; } catch (searchError) { console.error('[fetchUserPermissions] Error searching for user by sub:', searchError); throw new Error('Failed to fetch user permissions'); } } } export const getUserPermissions = query('unchecked', async (userId) => { try { const permissions = await fetchUserPermissions(userId); // Ensure permissions array is serializable return JSON.parse(JSON.stringify(permissions)); } catch (error) { console.error('[getUserPermissions] Error:', error); throw new Error(`Failed to fetch user permissions: ${error instanceof Error ? error.message : 'Unknown error'}`); } }); async function refreshTokenIfSelfUpdate(userId) { try { const event = getRequestEvent(); if (!event?.locals) return; // Try to get current user - this is app-specific, so we check if the method exists const getAuth = event.locals.auth; if (!getAuth) return; const currentUser = getAuth(); const isSelfUpdate = currentUser?.userId === userId; if (isSelfUpdate) { try { // Try to dynamically import token manager - this is app-specific // Use Function constructor to avoid TypeScript checking the import path const importPath = '$lib/server/auth-hooks/token-manager.server'; const tokenManager = await new Function('path', 'return import(path)')(importPath).catch(() => null); if (tokenManager?.clearTokenCookies && tokenManager?.getAccessToken && tokenManager?.setClientAccessibleToken) { tokenManager.clearTokenCookies(event.cookies); const newToken = await tokenManager.getAccessToken(event.cookies, event, true); tokenManager.setClientAccessibleToken(event.cookies, newToken); } } catch { // Token refresh not available in this app, skip silently } } } catch { // Token refresh not available in this app, skip silently } } export const updateUserPermissions = command('unchecked', async (options) => { const { userId, permissions } = options; try { // Fetch user's active keys const allKeysData = await makeAdminRequest(`/admin/keys?client_id=${CLIENT_ID}&sub=${userId}`); const userKeys = (allKeysData?.data?.data || []).filter((key) => key.status === 'active'); const filteredPermissions = PERMISSION_PREFIX ? permissions.filter((scope) => scope.startsWith(PERMISSION_PREFIX)) : permissions; if (userKeys.length === 0) { // No active key exists, create new one const newKeyResult = await createUserPermissions(userId, permissions); const newApiKey = newKeyResult?.data?.key; if (newApiKey) { const currentUser = await makeClerkRequest(`/users/${userId}`); await makeClerkRequest(`/users/${userId}`, { method: 'PATCH', body: JSON.stringify({ private_metadata: { ...(currentUser.private_metadata || {}), mako_api_key: newApiKey } }) }); } } else { // Use PUT to update existing key (per Mako Auth API spec) const keyId = userKeys[0].id; // Get the API key string before updating const keyData = await makeAdminRequest(`/admin/keys/${keyId}`); const apiKeyString = keyData?.data?.key; await makeAdminRequest(`/admin/keys/${keyId}`, { method: 'PUT', body: JSON.stringify({ scopes: filteredPermissions }) }); // Verify the token has updated scopes if (apiKeyString) { try { const verification = await verifyApiKeyToken(apiKeyString); console.log('[updateUserPermissions] Key verification:', verification); if (verification.valid) { // Check if the scopes match what we expect const scopesMatch = filteredPermissions.every((perm) => verification.scopes?.includes(perm)); if (!scopesMatch) { console.warn('[updateUserPermissions] Scopes mismatch. Expected:', filteredPermissions, 'Got:', verification.scopes); } } else { console.warn('[updateUserPermissions] Token verification failed:', verification.error); } } catch (verifyError) { console.warn('[updateUserPermissions] Could not verify token:', verifyError); } } // Clean up any extra keys (there should only be one) if (userKeys.length > 1) { await Promise.all(userKeys.slice(1).map((key) => makeAdminRequest(`/admin/keys/${key.id}`, { method: 'DELETE' }).catch((err) => { console.warn(`[updateUserPermissions] Failed to delete extra key ${key.id}:`, err); }))); } } await refreshTokenIfSelfUpdate(userId); } catch (error) { console.error('[updateUserPermissions] Error:', error); throw new Error(`Failed to update user permissions: ${error instanceof Error ? error.message : 'Unknown error'}`); } }); export const generateApiKey = command('unchecked', async (options) => { try { let filteredPermissions = PERMISSION_PREFIX ? options.permissions.filter((scope) => scope.startsWith(PERMISSION_PREFIX)) : options.permissions; // Default to readonly permission if none provided (first-time key generation) if (filteredPermissions.length === 0 && PERMISSION_PREFIX) { filteredPermissions = [`${PERMISSION_PREFIX}readonly`]; } // Check if user has existing active key const allKeysData = await makeAdminRequest(`/admin/keys?client_id=${CLIENT_ID}&sub=${options.userId}`); const userKeys = (allKeysData?.data?.data || []).filter((key) => key.status === 'active'); let newApiKey; let wasRotated = false; let oldApiKey; let currentUser = null; if (userKeys.length > 0 && options.revokeOld) { // Use rotate endpoint (per Mako Auth API spec) const keyId = userKeys[0].id; // Get the old API key from Clerk's private_metadata currentUser = await makeClerkRequest(`/users/${options.userId}`); oldApiKey = currentUser?.private_metadata?.mako_api_key; const rotateResult = await makeAdminRequest(`/admin/keys/${keyId}/rotate`, { method: 'POST', body: JSON.stringify({ scopes: filteredPermissions }) }); // Rotate endpoint returns key in data.key field newApiKey = rotateResult?.data?.key; wasRotated = true; if (!newApiKey) { throw new Error('Failed to rotate API key - no key in response'); } // Verify old key is revoked if (oldApiKey) { try { const oldKeyVerification = await verifyApiKeyToken(oldApiKey); console.log('[generateApiKey] Old key verification:', oldKeyVerification.valid ? 'Still valid ⚠️' : 'Revoked ✓'); if (oldKeyVerification.valid) { console.warn('[generateApiKey] Old API key still valid after rotation'); } } catch (verifyError) { console.warn('[generateApiKey] Could not verify old key revocation:', verifyError); } } // Verify new key works with correct scopes try { const newKeyVerification = await verifyApiKeyToken(newApiKey); console.log('[generateApiKey] New key verification:', newKeyVerification); if (newKeyVerification.valid) { // Check if the scopes match what we expect const scopesMatch = filteredPermissions.every((perm) => newKeyVerification.scopes?.includes(perm)); if (!scopesMatch) { console.warn('[generateApiKey] Scopes mismatch. Expected:', filteredPermissions, 'Got:', newKeyVerification.scopes); } } else { console.warn('[generateApiKey] New key verification failed:', newKeyVerification.error); } } catch (verifyError) { console.warn('[generateApiKey] Could not verify new key:', verifyError); } } else { // Create new key if none exists or revokeOld is false const createData = await createUserPermissions(options.userId, filteredPermissions); if (!createData) { throw new Error('Failed to create admin key'); } newApiKey = createData?.data?.key; if (!newApiKey) { throw new Error('Failed to generate API key - no key in response'); } } // Update Clerk profile with new key try { // Reuse currentUser if already fetched (during rotation), otherwise fetch it if (!currentUser) { currentUser = await makeClerkRequest(`/users/${options.userId}`); } if (currentUser) { await makeClerkRequest(`/users/${options.userId}`, { method: 'PATCH', body: JSON.stringify({ private_metadata: { ...(currentUser.private_metadata || {}), mako_api_key: newApiKey } }) }); } } catch (clerkError) { console.error('[generateApiKey] Failed to update Clerk profile:', clerkError); console.warn('[generateApiKey] Key generated but could not update Clerk profile'); } const result = { success: true, apiKey: newApiKey, message: wasRotated ? 'API key rotated successfully' : 'API key generated successfully' }; return JSON.parse(JSON.stringify(result)); } catch (error) { console.error('[generateApiKey] Error:', error); throw new Error(`Failed to generate API key: ${error instanceof Error ? error.message : 'Unknown error'}`); } }); export const verifyToken = command('unchecked', async (options) => { try { const result = await verifyApiKeyToken(options.apiKey); // Also return the issued token for debugging if (result.valid) { try { const tokenResult = await makeAuthRequest('/auth/token', { method: 'POST', headers: { 'X-API-Key': options.apiKey } }); const finalResult = { valid: result.valid, scopes: result.scopes, token: tokenResult.data?.data?.access_token }; // Ensure result is serializable return JSON.parse(JSON.stringify(finalResult)); } catch (tokenError) { console.warn('[verifyToken] Could not fetch token:', tokenError); // Return result without token return JSON.parse(JSON.stringify({ valid: result.valid, scopes: result.scopes })); } } // Ensure result is serializable return JSON.parse(JSON.stringify(result)); } catch (error) { console.error('[verifyToken] Error:', error); const errorResult = { valid: false, error: error instanceof Error ? error.message : 'Unknown error during verification' }; return JSON.parse(JSON.stringify(errorResult)); } });