@makolabs/ripple
Version:
Simple Svelte 5 powered component library ✨
646 lines (645 loc) • 27.7 kB
JavaScript
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));
}
});