Skip to content

Server API Reference

The Extension Server API (ExtensionServerAPI) is available to server-side handlers running in V8 isolates on the organization's API server. This sandboxed environment provides secure access to database storage, secrets, and external HTTP requests.

Overview

Server handlers run in isolated V8 environments with:

  • No Node.js access — Pure JavaScript only
  • Memory limits — 128MB per isolate
  • Timeout — 30 seconds per request
  • Domain restrictions — Only declared domains allowed
  • Rate limiting — 100 requests/minute per extension (configurable)
typescript
// server/sync.ts
export default async function handler(api: ExtensionServerAPI) {
  const { request, user, response, storage, secrets, http } = api
  
  // Your handler logic here
  
  return response.json({ success: true })
}

Handler Structure

Every server handler must export a default async function:

typescript
import type { ExtensionServerAPI } from '@blueplm/extension-api'

export default async function handler(
  api: ExtensionServerAPI
): Promise<ExtensionResponse> {
  // Handler implementation
}

ExtensionServerAPI Interface

typescript
interface ExtensionServerAPI {
  storage: StorageAPI       // Extension-scoped database storage
  secrets: SecretsAPI       // Encrypted secrets storage
  http: HttpAPI             // Domain-restricted HTTP client
  request: RequestContext   // Current HTTP request
  user: UserContext | null  // Authenticated user (null for public)
  response: ResponseHelpers // Response builders
}

Request Context

request

Information about the incoming HTTP request.

typescript
interface ExtensionRequestContext {
  method: string                      // 'GET', 'POST', etc.
  path: string                        // Route path
  body: unknown                       // Parsed request body
  headers: Record<string, string>     // Request headers
  query: Record<string, string>       // Query parameters
  params: Record<string, string>      // Route parameters
}

Example:

typescript
export default async function handler(api: ExtensionServerAPI) {
  const { method, body, query, headers } = api.request
  
  console.log('Method:', method)           // 'POST'
  console.log('Body:', body)               // { vaultId: 'xxx' }
  console.log('Query:', query)             // { force: 'true' }
  console.log('Auth:', headers.authorization) // 'Bearer ...'
  
  return api.response.json({ received: true })
}

user

Authenticated user context. null for public endpoints.

typescript
interface ExtensionUserContext {
  id: string        // User UUID
  email: string     // User email
  orgId: string     // Organization UUID
  role: string      // User role in org
}

Example:

typescript
export default async function handler(api: ExtensionServerAPI) {
  const { user, response } = api
  
  // Check authentication
  if (!user) {
    return response.error('Authentication required', 401)
  }
  
  // Check role
  if (user.role !== 'admin') {
    return response.error('Admin access required', 403)
  }
  
  // Use user info
  console.log(`Request from ${user.email} in org ${user.orgId}`)
  
  return response.json({ userId: user.id })
}

Response Helpers

response.json

Return a JSON response.

typescript
response.json(data: unknown, status?: number): ExtensionResponse

Example:

typescript
return api.response.json({
  success: true,
  data: { fileCount: 42 }
})

// With custom status
return api.response.json({ created: true }, 201)

response.error

Return an error response.

typescript
response.error(message: string, status?: number): ExtensionResponse

Example:

typescript
return api.response.error('Invalid vault ID', 400)
return api.response.error('Not found', 404)
return api.response.error('Internal error', 500)

response.redirect

Return a redirect response.

typescript
response.redirect(url: string, status?: number): ExtensionResponse

Example:

typescript
// OAuth callback redirect
return api.response.redirect('https://accounts.google.com/oauth/...')

// Permanent redirect
return api.response.redirect('/new-location', 301)

Storage API

Permission required: storage:database

Extension-scoped key-value storage persisted to the organization's database.

storage.get

Retrieve a value from storage.

typescript
api.storage.get<T>(key: string): Promise<T | undefined>

Example:

typescript
const lastSync = await api.storage.get<number>('lastSync:vault-123')
const config = await api.storage.get<Config>('settings')

if (lastSync) {
  console.log('Last sync:', new Date(lastSync))
}

storage.set

Store a value.

typescript
api.storage.set<T>(key: string, value: T): Promise<void>

Values must be JSON-serializable.

Example:

typescript
await api.storage.set('lastSync:vault-123', Date.now())
await api.storage.set('settings', {
  syncInterval: 300,
  autoSync: true
})

storage.delete

Delete a value.

typescript
api.storage.delete(key: string): Promise<void>

Example:

typescript
await api.storage.delete('cachedData')

storage.list

List all keys (optionally filtered by prefix).

typescript
api.storage.list(prefix?: string): Promise<string[]>

Example:

typescript
// All keys
const allKeys = await api.storage.list()

// Keys with prefix
const syncKeys = await api.storage.list('lastSync:')
// ['lastSync:vault-1', 'lastSync:vault-2', ...]

Secrets API

Permission required: secrets:read and/or secrets:write

Encrypted secrets storage for sensitive data like API keys. All access is audited.

Limits

LimitValue
Maximum secrets50 per extension
Maximum size10KB per secret

secrets.get

Retrieve a secret value.

typescript
api.secrets.get(name: string): Promise<string | undefined>

Permission: secrets:read

Example:

typescript
const apiKey = await api.secrets.get('google_api_key')
const clientSecret = await api.secrets.get('oauth_client_secret')

if (!apiKey) {
  return api.response.error('API key not configured', 400)
}

secrets.set

Store a secret value (encrypted at rest).

typescript
api.secrets.set(name: string, value: string): Promise<void>

Permission: secrets:write

Example:

typescript
// Store OAuth tokens
await api.secrets.set('access_token', accessToken)
await api.secrets.set('refresh_token', refreshToken)

secrets.delete

Delete a secret.

typescript
api.secrets.delete(name: string): Promise<void>

Permission: secrets:write

Example:

typescript
// Clean up on disconnect
await api.secrets.delete('access_token')
await api.secrets.delete('refresh_token')

HTTP API

Permission required: http:fetch or specific http:domain:* permissions

Domain-restricted HTTP client for external API calls.

http.fetch

Make an HTTP request to an external URL.

typescript
api.http.fetch(url: string, options?: RequestInit): Promise<SerializableResponse>

interface SerializableResponse {
  ok: boolean
  status: number
  statusText: string
  headers: Record<string, string>
  body: string  // Response body as string
}

Manifest requirement:

json
"permissions": {
  "server": [
    "http:domain:api.googleapis.com",
    "http:domain:accounts.google.com"
  ]
}

Example:

typescript
export default async function handler(api: ExtensionServerAPI) {
  const { http, secrets, response } = api
  
  // Get API key from secrets
  const apiKey = await secrets.get('api_key')
  if (!apiKey) {
    return response.error('API key not configured', 400)
  }
  
  try {
    // Make external request
    const result = await http.fetch('https://api.googleapis.com/drive/v3/files', {
      method: 'GET',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json'
      }
    })
    
    if (!result.ok) {
      return response.error(`API error: ${result.statusText}`, result.status)
    }
    
    // Parse JSON response
    const data = JSON.parse(result.body)
    
    return response.json({ files: data.files })
    
  } catch (error) {
    return response.error(`Request failed: ${error.message}`, 500)
  }
}

POST Request Example

typescript
const result = await api.http.fetch('https://api.example.com/sync', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    vault: vaultName,
    files: fileList
  })
})

Domain Restrictions

Requests are only allowed to domains declared in your manifest:

json
"permissions": {
  "server": [
    "http:domain:api.example.com",
    "http:domain:*.googleapis.com"
  ]
}

Attempting to access other domains results in an error:

Error: Domain api.unauthorized.com not in extension's allowed domains

Complete Handler Examples

Simple GET Handler

typescript
// server/status.ts
import type { ExtensionServerAPI } from '@blueplm/extension-api'

export default async function handler(api: ExtensionServerAPI) {
  const { user, storage, response } = api
  
  if (!user) {
    return response.error('Authentication required', 401)
  }
  
  const lastSync = await storage.get<number>('lastSync')
  const syncCount = await storage.get<number>('syncCount') ?? 0
  
  return response.json({
    connected: true,
    lastSync: lastSync ? new Date(lastSync).toISOString() : null,
    totalSyncs: syncCount
  })
}

POST Handler with Validation

typescript
// server/sync.ts
import type { ExtensionServerAPI } from '@blueplm/extension-api'

interface SyncRequest {
  vaultId: string
  force?: boolean
}

interface SyncResult {
  fileCount: number
  duration: number
}

export default async function handler(api: ExtensionServerAPI) {
  const { request, user, storage, secrets, http, response } = api
  const startTime = Date.now()
  
  // Authentication check
  if (!user) {
    return response.error('Authentication required', 401)
  }
  
  // Parse and validate request body
  const body = request.body as SyncRequest
  
  if (!body.vaultId) {
    return response.error('vaultId is required', 400)
  }
  
  // Get credentials
  const apiToken = await secrets.get('api_token')
  if (!apiToken) {
    return response.error('Extension not configured. Please set up API credentials.', 400)
  }
  
  // Get last sync time
  const lastSyncKey = `lastSync:${body.vaultId}`
  const lastSync = await storage.get<number>(lastSyncKey)
  
  try {
    // Call external API
    const externalResponse = await http.fetch('https://api.example.com/sync', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        vault: body.vaultId,
        since: lastSync ? new Date(lastSync).toISOString() : null,
        force: body.force ?? false,
        user: user.email
      })
    })
    
    if (!externalResponse.ok) {
      const errorBody = externalResponse.body
      return response.error(`External API error: ${errorBody}`, externalResponse.status)
    }
    
    const result = JSON.parse(externalResponse.body)
    
    // Update storage
    await storage.set(lastSyncKey, Date.now())
    
    // Increment sync counter
    const syncCount = (await storage.get<number>('syncCount') ?? 0) + 1
    await storage.set('syncCount', syncCount)
    
    return response.json({
      fileCount: result.filesProcessed,
      duration: Date.now() - startTime
    } as SyncResult)
    
  } catch (error) {
    return response.error(`Sync failed: ${(error as Error).message}`, 500)
  }
}

OAuth Callback Handler (Public Endpoint)

typescript
// server/oauth-callback.ts
// Manifest: { "public": true }
import type { ExtensionServerAPI } from '@blueplm/extension-api'

export default async function handler(api: ExtensionServerAPI) {
  const { request, storage, secrets, http, response } = api
  const { code, state, error } = request.query
  
  // Handle OAuth error
  if (error) {
    return response.redirect(`blueplm://extensions/myext/oauth-error?error=${error}`)
  }
  
  // Validate state
  const savedState = await storage.get<string>('oauth_state')
  if (!state || state !== savedState) {
    return response.error('Invalid state parameter', 400)
  }
  
  try {
    // Exchange code for tokens
    const tokenResponse = await http.fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        code,
        client_id: await secrets.get('client_id') ?? '',
        client_secret: await secrets.get('client_secret') ?? '',
        redirect_uri: 'https://your-org-api.com/extensions/myext/oauth-callback',
        grant_type: 'authorization_code'
      }).toString()
    })
    
    if (!tokenResponse.ok) {
      throw new Error(`Token exchange failed: ${tokenResponse.body}`)
    }
    
    const tokens = JSON.parse(tokenResponse.body)
    
    // Store tokens securely
    await secrets.set('access_token', tokens.access_token)
    if (tokens.refresh_token) {
      await secrets.set('refresh_token', tokens.refresh_token)
    }
    
    // Clean up state
    await storage.delete('oauth_state')
    
    // Redirect back to app
    return response.redirect('blueplm://extensions/myext/oauth-success')
    
  } catch (error) {
    return response.redirect(
      `blueplm://extensions/myext/oauth-error?error=${encodeURIComponent((error as Error).message)}`
    )
  }
}

Configuration Handler

typescript
// server/configure.ts
import type { ExtensionServerAPI } from '@blueplm/extension-api'

interface ConfigureRequest {
  apiKey?: string
  apiEndpoint?: string
  syncInterval?: number
}

export default async function handler(api: ExtensionServerAPI) {
  const { request, user, storage, secrets, response } = api
  
  if (!user) {
    return response.error('Authentication required', 401)
  }
  
  // Only admins can configure
  if (user.role !== 'admin' && user.role !== 'owner') {
    return response.error('Admin access required', 403)
  }
  
  const body = request.body as ConfigureRequest
  
  // Store secrets
  if (body.apiKey) {
    await secrets.set('api_key', body.apiKey)
  }
  
  if (body.apiEndpoint) {
    await secrets.set('api_endpoint', body.apiEndpoint)
  }
  
  // Store regular config
  if (body.syncInterval !== undefined) {
    await storage.set('syncInterval', body.syncInterval)
  }
  
  return response.json({
    success: true,
    message: 'Configuration saved'
  })
}

Error Handling Best Practices

typescript
export default async function handler(api: ExtensionServerAPI) {
  const { response } = api
  
  try {
    // Your logic here
    
  } catch (error) {
    const err = error as Error
    
    // Log for debugging (visible in org's logs)
    console.error('Handler error:', err.message, err.stack)
    
    // Return user-friendly error
    if (err.message.includes('timeout')) {
      return response.error('Request timed out. Please try again.', 504)
    }
    
    if (err.message.includes('rate limit')) {
      return response.error('Too many requests. Please wait.', 429)
    }
    
    // Generic error (don't expose internals)
    return response.error('An unexpected error occurred.', 500)
  }
}

Security Considerations

1. Always Validate Input

typescript
const { vaultId, action } = request.body as { vaultId?: string; action?: string }

if (!vaultId || typeof vaultId !== 'string') {
  return response.error('Invalid vaultId', 400)
}

if (!['sync', 'backup', 'restore'].includes(action ?? '')) {
  return response.error('Invalid action', 400)
}

2. Check Authentication for Non-Public Endpoints

typescript
if (!user) {
  return response.error('Authentication required', 401)
}

3. Implement Role-Based Access

typescript
if (user.role !== 'admin') {
  return response.error('Admin access required', 403)
}

4. Never Expose Secrets

typescript
// ❌ Bad
return response.json({ apiKey: await secrets.get('api_key') })

// ✓ Good
return response.json({ configured: !!(await secrets.get('api_key')) })

5. Validate External Responses

typescript
const result = await http.fetch(url, options)

if (!result.ok) {
  // Don't expose external error details
  return response.error('External service unavailable', 502)
}

// Validate response structure
const data = JSON.parse(result.body)
if (!data.files || !Array.isArray(data.files)) {
  return response.error('Invalid response from service', 502)
}

Testing Handlers

Local Testing

Use the BluePLM CLI to test handlers locally:

bash
# Start local handler server
blueplm-ext serve

# Test with curl
curl -X POST http://localhost:3000/extensions/myext/sync \
  -H "Content-Type: application/json" \
  -d '{"vaultId": "test-vault"}'

Mock API Object

typescript
// test/sync.test.ts
import handler from '../server/sync'

const mockApi = {
  request: {
    method: 'POST',
    path: 'sync',
    body: { vaultId: 'test-vault' },
    headers: {},
    query: {},
    params: {}
  },
  user: {
    id: 'user-1',
    email: '[email protected]',
    orgId: 'org-1',
    role: 'admin'
  },
  storage: {
    get: jest.fn(),
    set: jest.fn(),
    delete: jest.fn(),
    list: jest.fn()
  },
  secrets: {
    get: jest.fn().mockResolvedValue('mock-api-key'),
    set: jest.fn(),
    delete: jest.fn()
  },
  http: {
    fetch: jest.fn().mockResolvedValue({
      ok: true,
      status: 200,
      body: JSON.stringify({ filesProcessed: 42 })
    })
  },
  response: {
    json: (data: unknown, status = 200) => ({ type: 'json', data, status }),
    error: (message: string, status = 500) => ({ type: 'error', message, status }),
    redirect: (url: string, status = 302) => ({ type: 'redirect', url, status })
  }
}

test('sync handler returns file count', async () => {
  const result = await handler(mockApi as any)
  
  expect(result.type).toBe('json')
  expect(result.data.fileCount).toBe(42)
})

← Client API Reference | Contributions Reference →