diff --git a/README.md b/README.md index 2e38dee..e0d919e 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ import { createClient } from '@base44/sdk'; // Create a client instance const base44 = createClient({ serverUrl: 'https://base44.app', // Optional, defaults to 'https://base44.app' - appId: 'your-app-id', // Required - env: 'prod', // Optional, defaults to 'prod' - token: 'your-token', // Optional - autoInitAuth: true, // Optional, defaults to true - auto-detects tokens from URL or localStorage + appId: 'your-app-id', // Required + token: 'your-user-token', // Optional, for user authentication + serviceToken: 'your-service-token', // Optional, for service role authentication + autoInitAuth: true, // Optional, defaults to true - auto-detects tokens from URL or localStorage }); ``` @@ -63,6 +63,77 @@ const newProducts = await base44.entities.Product.bulkCreate([ ]); ``` +### Service Role Authentication + +Service role authentication allows server-side applications to perform operations with elevated privileges. This is useful for administrative tasks, background jobs, and server-to-server communication. + +```javascript +import { createClient } from '@base44/sdk'; + +// Create a client with service role token +const base44 = createClient({ + appId: 'your-app-id', + token: 'user-token', // For user operations + serviceToken: 'service-token' // For service role operations +}); + +// User operations (uses user token) +const userEntities = await base44.entities.User.list(); + +// Service role operations (uses service token) +const allEntities = await base44.asServiceRole.entities.User.list(); + +// Service role has access to: +// - base44.asServiceRole.entities +// - base44.asServiceRole.integrations +// - base44.asServiceRole.functions +// Note: Service role does NOT have access to auth module for security + +// If no service token is provided, accessing asServiceRole throws an error +const clientWithoutService = createClient({ appId: 'your-app-id' }); +try { + await clientWithoutService.asServiceRole.entities.User.list(); +} catch (error) { + // Error: Service token is required to use asServiceRole +} +``` + +### Server-Side Usage + +For server-side applications, you can create a client from incoming HTTP requests: + +```javascript +import { createClientFromRequest } from '@base44/sdk'; + +// In your server handler (Express, Next.js, etc.) +app.get('/api/data', async (req, res) => { + try { + // Extract client configuration from request headers + const base44 = createClientFromRequest(req); + + // Headers used: + // - Authorization: Bearer + // - Base44-Service-Authorization: Bearer + // - Base44-App-Id: + // - Base44-Api-Url: (optional) + + // Use appropriate authentication based on available tokens + let data; + if (base44.asServiceRole) { + // Service token available - use elevated privileges + data = await base44.asServiceRole.entities.SensitiveData.list(); + } else { + // Only user token available - use user permissions + data = await base44.entities.PublicData.list(); + } + + res.json(data); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); +``` + ### Working with Integrations ```javascript @@ -103,7 +174,7 @@ import { getAccessToken } from '@base44/sdk/utils/auth-utils'; // Create a client with authentication const base44 = createClient({ appId: 'your-app-id', - accessToken: getAccessToken() // Automatically retrieves token from localStorage or URL + token: getAccessToken() // Automatically retrieves token from localStorage or URL }); // Check authentication status @@ -167,7 +238,7 @@ function AuthProvider({ children }) { const [client] = useState(() => createClient({ appId: 'your-app-id', - accessToken: getAccessToken() + token: getAccessToken() }) ); @@ -347,6 +418,24 @@ async function fetchProducts() { } } +// Service role operations with TypeScript +async function adminOperations() { + const base44 = createClient({ + appId: 'your-app-id', + serviceToken: 'service-token' + }); + + // TypeScript knows asServiceRole requires a service token + try { + const allUsers: Entity[] = await base44.asServiceRole.entities.User.list(); + console.log(`Total users: ${allUsers.length}`); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); // Service token is required to use asServiceRole + } + } +} + // Authentication with TypeScript async function handleAuth(auth: AuthModule) { // Check authentication @@ -456,6 +545,25 @@ try { } ``` +## Functions + +The SDK supports invoking custom functions: + +```javascript +// Invoke a function without parameters +const result = await base44.functions.myFunction(); + +// Invoke a function with parameters +const result = await base44.functions.calculateTotal({ + items: ['item1', 'item2'], + discount: 0.1 +}); + +// Functions are automatically authenticated with the user token +// Service role can also invoke functions +const serviceResult = await base44.asServiceRole.functions.adminFunction(); +``` + ## Testing The SDK includes comprehensive tests to ensure reliability. diff --git a/src/client.ts b/src/client.ts index d60687f..f8061db 100644 --- a/src/client.ts +++ b/src/client.ts @@ -10,44 +10,41 @@ import { createFunctionsModule } from "./modules/functions.js"; * @param {Object} config - Client configuration * @param {string} [config.serverUrl='https://base44.app'] - API server URL * @param {string|number} config.appId - Application ID - * @param {string} [config.env='prod'] - Environment ('prod' or 'dev') * @param {string} [config.token] - Authentication token + * @param {string} [config.serviceToken] - Service role authentication token * @param {boolean} [config.requiresAuth=false] - Whether the app requires authentication * @returns {Object} Base44 client instance */ export function createClient(config: { serverUrl?: string; appId: string; - env?: string; token?: string; + serviceToken?: string; requiresAuth?: boolean; }) { const { serverUrl = "https://base44.app", appId, - env = "prod", token, + serviceToken, requiresAuth = false, } = config; - // Create the base axios client const axiosClient = createAxiosClient({ baseURL: `${serverUrl}/api`, headers: { "X-App-Id": String(appId), - "X-Environment": env, }, token, - requiresAuth, // Pass requiresAuth to axios client - appId, // Pass appId for login redirect - serverUrl, // Pass serverUrl for login redirect + requiresAuth, + appId, + serverUrl, }); const functionsAxiosClient = createAxiosClient({ baseURL: `${serverUrl}/api`, headers: { "X-App-Id": String(appId), - "X-Environment": env, }, token, requiresAuth, @@ -56,18 +53,46 @@ export function createClient(config: { interceptResponses: false, }); - // Create modules - const entities = createEntitiesModule(axiosClient, appId); - const integrations = createIntegrationsModule(axiosClient, appId); - const auth = createAuthModule(axiosClient, appId, serverUrl); - const functions = createFunctionsModule(functionsAxiosClient, appId); + const serviceRoleAxiosClient = createAxiosClient({ + baseURL: `${serverUrl}/api`, + headers: { + "X-App-Id": String(appId), + }, + token: serviceToken, + serverUrl, + appId, + }); + + const serviceRoleFunctionsAxiosClient = createAxiosClient({ + baseURL: `${serverUrl}/api`, + headers: { + "X-App-Id": String(appId), + }, + token: serviceToken, + serverUrl, + appId, + interceptResponses: false, + }); + + const userModules = { + entities: createEntitiesModule(axiosClient, appId), + integrations: createIntegrationsModule(axiosClient, appId), + auth: createAuthModule(axiosClient, functionsAxiosClient, appId), + functions: createFunctionsModule(functionsAxiosClient, appId), + }; + + const serviceRoleModules = { + entities: createEntitiesModule(serviceRoleAxiosClient, appId), + integrations: createIntegrationsModule(serviceRoleAxiosClient, appId), + functions: createFunctionsModule(serviceRoleFunctionsAxiosClient, appId), + }; // Always try to get token from localStorage or URL parameters if (typeof window !== "undefined") { // Get token from URL or localStorage const accessToken = token || getAccessToken(); if (accessToken) { - auth.setToken(accessToken); + userModules.auth.setToken(accessToken); } } @@ -76,30 +101,27 @@ export function createClient(config: { // We perform this check asynchronously to not block client creation setTimeout(async () => { try { - const isAuthenticated = await auth.isAuthenticated(); + const isAuthenticated = await userModules.auth.isAuthenticated(); if (!isAuthenticated) { - auth.redirectToLogin(window.location.href); + userModules.auth.redirectToLogin(window.location.href); } } catch (error) { console.error("Authentication check failed:", error); - auth.redirectToLogin(window.location.href); + userModules.auth.redirectToLogin(window.location.href); } }, 0); } // Assemble and return the client - return { - entities, - integrations, - auth, - functions, + const client = { + ...userModules, /** * Set authentication token for all requests * @param {string} newToken - New auth token */ setToken(newToken: string) { - auth.setToken(newToken); + userModules.auth.setToken(newToken); }, /** @@ -110,9 +132,61 @@ export function createClient(config: { return { serverUrl, appId, - env, requiresAuth, }; }, + + /** + * Access service role modules - throws error if no service token was provided + * @throws {Error} When accessed without a service token + */ + get asServiceRole() { + if (!serviceToken) { + throw new Error('Service token is required to use asServiceRole. Please provide a serviceToken when creating the client.'); + } + return serviceRoleModules; + } }; + + return client; +} + +export function createClientFromRequest(request: Request) { + const authHeader = request.headers.get("Authorization"); + const serviceRoleAuthHeader = request.headers.get( + "Base44-Service-Authorization" + ); + const appId = request.headers.get("Base44-App-Id"); + const serverUrlHeader = request.headers.get("Base44-Api-Url"); + + if (!appId) { + throw new Error( + "Base44-App-Id header is required, but is was not found on the request" + ); + } + + // Validate authorization header formats + let serviceRoleToken: string | undefined; + let userToken: string | undefined; + + if (serviceRoleAuthHeader !== null) { + if (serviceRoleAuthHeader === '' || !serviceRoleAuthHeader.startsWith('Bearer ') || serviceRoleAuthHeader.split(' ').length !== 2) { + throw new Error('Invalid authorization header format. Expected "Bearer "'); + } + serviceRoleToken = serviceRoleAuthHeader.split(' ')[1]; + } + + if (authHeader !== null) { + if (authHeader === '' || !authHeader.startsWith('Bearer ') || authHeader.split(' ').length !== 2) { + throw new Error('Invalid authorization header format. Expected "Bearer "'); + } + userToken = authHeader.split(' ')[1]; + } + + return createClient({ + serverUrl: serverUrlHeader || "https://base44.app", + appId, + token: userToken, + serviceToken: serviceRoleToken, + }); } diff --git a/src/index.ts b/src/index.ts index b3461a3..2002286 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { createClient } from "./client.js"; +import { createClient, createClientFromRequest } from "./client.js"; import { Base44Error } from "./utils/axios-client.js"; import { getAccessToken, @@ -9,8 +9,8 @@ import { export { createClient, + createClientFromRequest, Base44Error, - // Export auth utilities for easier access getAccessToken, saveAccessToken, removeAccessToken, diff --git a/src/modules/auth.ts b/src/modules/auth.ts index c18bf2e..0eae2b5 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -9,8 +9,8 @@ import { AxiosInstance } from "axios"; */ export function createAuthModule( axios: AxiosInstance, - appId: string, - serverUrl: string + functionsAxiosClient: AxiosInstance, + appId: string ) { return { /** @@ -98,6 +98,9 @@ export function createAuthModule( if (!token) return; axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; + functionsAxiosClient.defaults.headers.common[ + "Authorization" + ] = `Bearer ${token}`; // Save token to localStorage if requested if ( diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index 73b0572..f1a0c5c 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -1,5 +1,6 @@ -import { createClient } from '../../src/index.ts'; -import { describe, test, expect } from 'vitest'; +import { createClient, createClientFromRequest } from '../../src/index.ts'; +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; describe('Client Creation', () => { test('should create a client with default options', () => { @@ -15,15 +16,16 @@ describe('Client Creation', () => { const config = client.getConfig(); expect(config.appId).toBe('test-app-id'); expect(config.serverUrl).toBe('https://base44.app'); - expect(config.env).toBe('prod'); expect(config.requiresAuth).toBe(false); + + // Should throw error when accessing asServiceRole without service token + expect(() => client.asServiceRole).toThrow('Service token is required to use asServiceRole. Please provide a serviceToken when creating the client.'); }); test('should create a client with custom options', () => { const client = createClient({ appId: 'test-app-id', serverUrl: 'https://custom-server.com', - env: 'dev', requiresAuth: true, token: 'test-token', }); @@ -33,7 +35,384 @@ describe('Client Creation', () => { const config = client.getConfig(); expect(config.appId).toBe('test-app-id'); expect(config.serverUrl).toBe('https://custom-server.com'); - expect(config.env).toBe('dev'); expect(config.requiresAuth).toBe(true); }); + + test('should create a client with service token', () => { + const client = createClient({ + appId: 'test-app-id', + serviceToken: 'service-token-123', + }); + + expect(client).toBeDefined(); + expect(client.entities).toBeDefined(); + expect(client.integrations).toBeDefined(); + expect(client.auth).toBeDefined(); + expect(client.asServiceRole).toBeDefined(); + expect(client.asServiceRole.entities).toBeDefined(); + expect(client.asServiceRole.integrations).toBeDefined(); + expect(client.asServiceRole.functions).toBeDefined(); + // Service role should not have auth module + expect(client.asServiceRole.auth).toBeUndefined(); + }); + + test('should create a client with both user token and service token', () => { + const client = createClient({ + appId: 'test-app-id', + token: 'user-token-123', + serviceToken: 'service-token-123', + requiresAuth: true, + }); + + expect(client).toBeDefined(); + expect(client.entities).toBeDefined(); + expect(client.integrations).toBeDefined(); + expect(client.auth).toBeDefined(); + expect(client.asServiceRole).toBeDefined(); + expect(client.asServiceRole.entities).toBeDefined(); + expect(client.asServiceRole.integrations).toBeDefined(); + expect(client.asServiceRole.functions).toBeDefined(); + expect(client.asServiceRole.auth).toBeUndefined(); + }); + +}); + +describe('createClientFromRequest', () => { + test('should create client from request with all headers', () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Authorization': 'Bearer user-token-123', + 'Base44-Service-Authorization': 'Bearer service-token-123', + 'Base44-App-Id': 'test-app-id', + 'Base44-Api-Url': 'https://custom-server.com' + }; + return headers[name] || null; + } + } + }; + + const client = createClientFromRequest(mockRequest); + + expect(client).toBeDefined(); + expect(client.entities).toBeDefined(); + expect(client.integrations).toBeDefined(); + expect(client.auth).toBeDefined(); + expect(client.asServiceRole).toBeDefined(); + + const config = client.getConfig(); + expect(config.appId).toBe('test-app-id'); + expect(config.serverUrl).toBe('https://custom-server.com'); + }); + + test('should create client from request with minimal headers', () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Base44-App-Id': 'minimal-app-id' + }; + return headers[name] || null; + } + } + }; + + const client = createClientFromRequest(mockRequest); + + expect(client).toBeDefined(); + const config = client.getConfig(); + expect(config.appId).toBe('minimal-app-id'); + expect(config.serverUrl).toBe('https://base44.app'); // Default value + }); + + test('should create client with only user token', () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Authorization': 'Bearer user-only-token', + 'Base44-App-Id': 'user-app-id' + }; + return headers[name] || null; + } + } + }; + + const client = createClientFromRequest(mockRequest); + + expect(client).toBeDefined(); + expect(client.auth).toBeDefined(); + // Should throw error when accessing asServiceRole without service token + expect(() => client.asServiceRole).toThrow('Service token is required to use asServiceRole. Please provide a serviceToken when creating the client.'); + }); + + test('should create client with only service token', () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Base44-Service-Authorization': 'Bearer service-only-token', + 'Base44-App-Id': 'service-app-id' + }; + return headers[name] || null; + } + } + }; + + const client = createClientFromRequest(mockRequest); + + expect(client).toBeDefined(); + expect(client.auth).toBeDefined(); + expect(client.asServiceRole).toBeDefined(); + }); + + test('should throw error when Base44-App-Id header is missing', () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Authorization': 'Bearer some-token' + }; + return headers[name] || null; + } + } + }; + + expect(() => createClientFromRequest(mockRequest)).toThrow( + 'Base44-App-Id header is required, but is was not found on the request' + ); + }); + + test('should throw error for malformed authorization headers', () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Authorization': 'InvalidFormat', + 'Base44-Service-Authorization': 'AlsoInvalid', + 'Base44-App-Id': 'test-app-id' + }; + return headers[name] || null; + } + } + }; + + // Should throw error for malformed headers instead of continuing silently + expect(() => createClientFromRequest(mockRequest)).toThrow('Invalid authorization header format. Expected "Bearer "'); + }); + + test('should throw error for empty authorization headers', () => { + const mockRequest = { + headers: { + get: (name) => { + const headers = { + 'Authorization': '', + 'Base44-Service-Authorization': '', + 'Base44-App-Id': 'test-app-id' + }; + return headers[name] === '' ? '' : headers[name] || null; + } + } + }; + + // Should throw error for empty headers instead of continuing silently + expect(() => createClientFromRequest(mockRequest)).toThrow('Invalid authorization header format. Expected "Bearer "'); + }); +}); + + +describe('Service Role Authorization Headers', () => { + + let scope; + const appId = 'test-app-id'; + const serverUrl = 'https://api.base44.com'; + + beforeEach(() => { + // Create a nock scope for mocking API calls + scope = nock(serverUrl); + + // Enable request debugging for Nock + nock.disableNetConnect(); + nock.emitter.on('no match', (req) => { + console.log(`Nock: No match for ${req.method} ${req.path}`); + console.log('Headers:', req.getHeaders()); + }); + }); + + afterEach(() => { + // Clean up any pending mocks + nock.cleanAll(); + nock.emitter.removeAllListeners('no match'); + nock.enableNetConnect(); + }); + + test('should use user token for regular client operations and service token for service role operations', async () => { + const userToken = 'user-token-123'; + const serviceToken = 'service-token-456'; + + const client = createClient({ + serverUrl, + appId, + token: userToken, + serviceToken: serviceToken, + }); + + // Mock user entities request (should use user token) + scope.get(`/api/apps/${appId}/entities/Todo`) + .matchHeader('Authorization', `Bearer ${userToken}`) + .reply(200, { items: [], total: 0 }); + + // Mock service role entities request (should use service token) + scope.get(`/api/apps/${appId}/entities/Todo`) + .matchHeader('Authorization', `Bearer ${serviceToken}`) + .reply(200, { items: [], total: 0 }); + + // Make requests + await client.entities.Todo.list(); + await client.asServiceRole.entities.Todo.list(); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('should use service token for service role entities operations', async () => { + const serviceToken = 'service-token-only-123'; + + const client = createClient({ + serverUrl, + appId, + serviceToken: serviceToken, + }); + + // Mock service role entities request + scope.get(`/api/apps/${appId}/entities/User/123`) + .matchHeader('Authorization', `Bearer ${serviceToken}`) + .reply(200, { id: '123', name: 'Test User' }); + + // Make request + const result = await client.asServiceRole.entities.User.get('123'); + + // Verify response + expect(result.id).toBe('123'); + expect(result.name).toBe('Test User'); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('should use service token for service role integrations operations', async () => { + const serviceToken = 'service-token-integration-456'; + + const client = createClient({ + serverUrl, + appId, + serviceToken: serviceToken, + }); + + // Mock service role integrations request + scope.post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`) + .matchHeader('Authorization', `Bearer ${serviceToken}`) + .reply(200, { success: true, messageId: '123' }); + + // Make request + const result = await client.asServiceRole.integrations.Core.SendEmail({ + to: 'test@example.com', + subject: 'Test', + body: 'Test message' + }); + + // Verify response + expect(result.success).toBe(true); + expect(result.messageId).toBe('123'); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('should use service token for service role functions operations', async () => { + const serviceToken = 'service-token-functions-789'; + + const client = createClient({ + serverUrl, + appId, + serviceToken: serviceToken, + }); + + // Mock service role functions request + scope.post(`/api/apps/${appId}/functions/testFunction`, { param: 'test' }) + .matchHeader('Authorization', `Bearer ${serviceToken}`) + .reply(200, { result: 'function executed' }); + + // Make request + const result = await client.asServiceRole.functions.invoke('testFunction', { + param: 'test' + }); + + // Verify response + expect(result.data.result).toBe('function executed'); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('should use user token for regular operations when both tokens are present', async () => { + const userToken = 'user-token-regular-123'; + const serviceToken = 'service-token-regular-456'; + + const client = createClient({ + serverUrl, + appId, + token: userToken, + serviceToken: serviceToken, + }); + + // Mock regular user entities request (should use user token) + scope.get(`/api/apps/${appId}/entities/Task`) + .matchHeader('Authorization', `Bearer ${userToken}`) + .reply(200, { items: [{ id: 'task1', title: 'User Task' }], total: 1 }); + + // Mock regular integrations request (should use user token) + scope.post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`) + .matchHeader('Authorization', `Bearer ${userToken}`) + .reply(200, { success: true, messageId: 'email123' }); + + // Make requests using regular client (not service role) + const taskResult = await client.entities.Task.list(); + const emailResult = await client.integrations.Core.SendEmail({ + to: 'user@example.com', + subject: 'User Test', + body: 'User message' + }); + + // Verify responses + expect(taskResult.items[0].title).toBe('User Task'); + expect(emailResult.success).toBe(true); + expect(emailResult.messageId).toBe('email123'); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('should work without authorization header when no tokens are provided', async () => { + const client = createClient({ + serverUrl, + appId, + }); + + // Mock request without authorization header + scope.get(`/api/apps/${appId}/entities/PublicData`) + .matchHeader('Authorization', (val) => !val) // Should not have Authorization header + .reply(200, { items: [{ id: 'public1', data: 'public' }], total: 1 }); + + // Make request + const result = await client.entities.PublicData.list(); + + // Verify response + expect(result.items[0].data).toBe('public'); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + }); \ No newline at end of file diff --git a/tests/unit/functions.test.ts b/tests/unit/functions.test.ts index 11101c4..3cd7d59 100644 --- a/tests/unit/functions.test.ts +++ b/tests/unit/functions.test.ts @@ -408,4 +408,39 @@ describe("Functions Module", () => { // Verify all mocks were called expect(scope.isDone()).toBe(true); }); + + test("should send user token as Authorization header when invoking functions", async () => { + const functionName = "testAuth"; + const userToken = "user-test-token"; + const functionData = { + test: "data", + }; + + // Create client with user token + const authenticatedBase44 = createClient({ + serverUrl, + appId, + token: userToken, + }); + + // Mock the API response, verifying the Authorization header + scope + .post(`/api/apps/${appId}/functions/${functionName}`, functionData) + .matchHeader("Content-Type", "application/json") + .matchHeader("Authorization", `Bearer ${userToken}`) + .reply(200, { + success: true, + authenticated: true, + }); + + // Call the function + const result = await authenticatedBase44.functions.invoke(functionName, functionData); + + // Verify the response + expect(result.data.success).toBe(true); + expect(result.data.authenticated).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); });