Cloak.Business Addin SDK

Build custom cloud-storage addins for Cloak.Business — connect any cloud provider to the PII anonymization pipeline in under a day.


Table of Contents#

  1. Architecture Overview
  2. Quickstart — 5 Steps
  3. AddinRenderer Interface
  4. AddinPanelProps — What Your Panel Receives
  5. OAuth2 Integration with GenericConnectStep
  6. File Browsing with GenericFileBrowser
  7. Analysis Pipeline
  8. Token Storage
  9. DB Registration via Admin API
  10. Security Requirements
  11. Complete Example — Box.com Addin

1. Architecture Overview#

User Browser                   Next.js Server                  Provider API
───────────────────────────────────────────────────────────────────────────
Panel (React)          →       /api/addins/{slug}/auth         (OAuth start)
OAuth popup            ←→      /api/addins/{slug}/callback     (code exchange)
BroadcastChannel       ←       callback HTML                   (notify panel)
Panel calls files API  →       /api/addins/{slug}/files        (list folder)
Panel calls download   →       /api/addins/{slug}/download     (fetch + extract)
Panel calls analyze    →       /api/presidio/analyze           (Presidio)
Panel calls anonymize  →       /api/presidio/anonymize         (Presidio)
Panel calls upload     →       /api/addins/{slug}/upload       (save result)

Every provider API call is server-side. The browser never contacts the cloud provider directly. Access tokens are stored AES-256-GCM encrypted in the database per user.

The addin UI is a 6-step wizard:

  1. Connect — OAuth2 or custom auth flow
  2. Preset — entity types, confidence, reversible anonymization
  3. Browse — folder navigation, file multi-select
  4. Analyze — per-file text extraction + Presidio NLP
  5. Review — entity groups, operator selection
  6. Done — save results to cloud or download locally

2. Quickstart — 5 Steps#

Step 1: Create the service file#

// frontend/lib/addins/box-service.ts
import crypto from 'crypto'
import { generateCodeVerifier, generateCodeChallenge, encryptState, decryptState } from '@/lib/addins/oauth-helpers'
import { getAddinBySlug } from '@/lib/addins/addin-service'
import { getDecryptedTokens, storeOAuthTokens } from '@/lib/addins/user-addin-service'
import { decryptAddinSecret } from '@/lib/addins/encryption'
import type { CloudFile } from '@/types/addins'

const BOX_AUTH_URL = 'https://account.box.com/api/oauth2/authorize'
const BOX_TOKEN_URL = 'https://api.box.com/oauth2/token'
const BOX_API = 'https://api.box.com/2.0'

export async function buildAuthUrl(userId: string, redirectUri: string) {
  const addin = await getAddinBySlug('box')
  if (!addin?.oauthConfig) throw new Error('Box addin not configured')
  const config = JSON.parse(decryptAddinSecret(addin.oauthConfig))
  const codeVerifier = generateCodeVerifier()
  const state = encryptState({ userId, addinId: addin.id, nonce: crypto.randomBytes(16).toString('hex'), timestamp: Date.now() })
  const params = new URLSearchParams({
    client_id: config.clientId,
    response_type: 'code',
    redirect_uri: redirectUri,
    state,
    code_challenge: generateCodeChallenge(codeVerifier),
    code_challenge_method: 'S256',
  })
  return { authUrl: `${BOX_AUTH_URL}?${params}`, codeVerifier, addinId: addin.id }
}
// ... exchangeCodeForTokens, refreshAccessToken, getValidAccessToken, listFolder, downloadFile, uploadFile

Step 2: Create API routes#

frontend/app/api/addins/box/
├── auth/route.ts        GET  — start OAuth, return authUrl
├── callback/route.ts    GET  — exchange code, post BroadcastChannel message
├── disconnect/route.ts  POST — clear tokens, re-activate addin
├── files/route.ts       POST — list folder, return CloudFile[]
├── download/route.ts    POST — download + extract text, return textContent
└── upload/route.ts      POST — upload anonymized file

Step 3: Create components#

frontend/components/addins/box/
├── connect-step.tsx     — GenericConnectStep wrapper with Box icon
├── file-browser.tsx     — GenericFileBrowser wrapper with files API callback
└── panel.tsx            — Full 6-step wizard panel

Step 4: Register the addin#

// frontend/lib/addins/builtin/box.ts
import { registerAddin } from '@/lib/addins/registry'
import { BoxPanel } from '@/components/addins/box/panel'

registerAddin({
  slug: 'box',
  Panel: BoxPanel,
  requiresOAuth: true,
  capabilities: ['browse-files', 'upload', 'download'],
})
// frontend/lib/addins/builtin/index.ts — add this import
import './box'

Step 5: Seed the DB#

// In frontend/prisma/seed-addins.ts — add:
await prisma.addin.upsert({
  where: { slug: 'box' },
  create: {
    slug: 'box', name: 'Box', description: 'Browse Box files and anonymize PII.',
    type: 'CLOUD_STORAGE', status: 'ACTIVE', icon: 'hard-drive', version: '1.0.0',
    isBuiltin: true, requiresPlan: null, configSchema: {},
    metadata: { supportedFileTypes: ['docx', 'xlsx', 'pdf', 'txt', 'csv'] },
    sortOrder: 5, createdBy: admin.id,
  },
  update: { name: 'Box', icon: 'hard-drive' },
})

Then configure OAuth credentials via the admin API:

curl -X PUT https://cloak.business/api/admin/addins/{addinId} \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"oauthClientId":"YOUR_CLIENT_ID","oauthClientSecret":"YOUR_CLIENT_SECRET"}'

3. AddinRenderer Interface#

interface AddinRenderer {
  slug: string                                          // Must match DB slug
  Panel: React.ComponentType<AddinPanelProps>           // Main UI component
  Settings?: React.ComponentType<AddinSettingsProps>    // Optional settings UI
  requiresOAuth: boolean                                // Show connect step?
  capabilities: AddinCapability[]                       // 'browse-files' | 'upload' | 'download' | 'scan-url'
}

Register with:

import { registerAddin } from '@/lib/addins/sdk'
registerAddin({ slug: 'my-addin', Panel: MyPanel, requiresOAuth: true, capabilities: ['browse-files'] })

4. AddinPanelProps — What Your Panel Receives#

interface AddinPanelProps {
  addin: AddinDefinition        // DB record (id, slug, name, icon, metadata…)
  userAddin: UserAddinData      // Per-user state (hasConnection, accountInfo, settings…)
  onSettingsChange: (settings: Record<string, unknown>) => void  // Persist settings to DB
}

userAddin.hasConnection — true if an access token exists userAddin.connectionExpired — true only when token is expired AND there is no refresh token userAddin.accountInfo{ email?, displayName?, quotaTotal?, quotaUsed? } or null userAddin.settings — per-user JSON settings (not encrypted)


5. OAuth2 Integration with GenericConnectStep#

GenericConnectStep handles the full OAuth2 popup flow. You only need to supply provider-specific props:

import { GenericConnectStep } from '@/lib/addins/sdk'

const BOX_ICON = (
  <svg className="h-8 w-8" viewBox="0 0 24 24">
    <path fill="#0061D5" d="M12 2L2 7v10l10 5 10-5V7L12 2z"/>
  </svg>
)

export function ConnectStep({ userAddin, onConnected, onRefreshState }) {
  return (
    <GenericConnectStep
      providerName="Box"
      providerSlug="box"                           // drives BroadcastChannel name + event type
      authRoute="/addins/box/auth"
      disconnectRoute="/addins/box/disconnect"
      providerIcon={BOX_ICON}
      userAddin={userAddin}
      onConnected={onConnected}
      onRefreshState={onRefreshState}
      continueLabel="Continue to Files"
      connectLabel="Sign in with Box"
      description="Connect your Box account to browse and anonymize files."
    />
  )
}

How it works:

  1. User clicks "Sign in with Box" → GET /api/addins/box/auth → gets authUrl → opens popup
  2. Popup redirects to Box → user consents → Box redirects to /api/addins/box/callback
  3. Callback exchanges code for tokens (stores encrypted in DB) → posts BroadcastChannel('box-oauth') message with { type: 'box-oauth-complete', success: true }
  4. GenericConnectStep receives the message → calls onRefreshState() → UI updates

Callback HTML template (copy this for your /callback/route.ts):

const nonce = request.headers.get('x-nonce') || ''
const html = `<!DOCTYPE html><html><body><script nonce="${nonce}">
  (function() {
    var data = { type: 'box-oauth-complete', success: ${success}, message: ${JSON.stringify(message)} };
    try { new BroadcastChannel('box-oauth').postMessage(data); } catch(e) {}
    try { window.opener && window.opener.postMessage(data, ${JSON.stringify(origin)}); } catch(e) {}
    setTimeout(function() { window.close(); }, 300);
  })();
</script></body></html>`

6. File Browsing with GenericFileBrowser#

GenericFileBrowser handles breadcrumb navigation, folder drilling, multi-select checkboxes, and X remove buttons. You supply an onLoadFolder callback:

import { GenericFileBrowser } from '@/lib/addins/sdk'
import { apiClient } from '@/lib/api-client'
import type { CloudFile } from '@/types/addins'

export function FileBrowser({ selectedFiles, onSelectionChange }) {
  const loadFolder = useCallback(async (folderId?: string): Promise<CloudFile[]> => {
    const res = await apiClient.post('/addins/box/files', { folderId })
    const body = res.data as { data?: { files: CloudFile[] } }
    return body?.data?.files || []
  }, [])

  return (
    <GenericFileBrowser
      onLoadFolder={loadFolder}       // called on mount (no arg = root) and on folder click (folderId)
      selectedFiles={selectedFiles}
      onSelectionChange={onSelectionChange}
      rootLabel="Box"                 // shown in breadcrumb root
    />
  )
}

CloudFile type — what your files API must return:

interface CloudFile {
  id: string          // unique file/folder identifier (used as folderId in navigation)
  name: string        // display name
  mimeType: string    // MIME type (use 'folder' for folders if provider doesn't provide one)
  size: number        // bytes (0 for folders)
  modifiedAt: string  // ISO 8601 or empty string
  isFolder: boolean
  path: string        // full path string (e.g. '/Documents/report.pdf') or empty
  parentId?: string   // parent folder ID (used to determine upload destination)
}

Supported file extensions (shown with checkbox, others greyed out): txt csv json xml md html docx xlsx pdf pptx png jpg jpeg bmp tiff


7. Analysis Pipeline#

Your panel connects FileBrowserFileAnalysisProgressFileResultsSaveBack. Follow the MS365 panel pattern:

// 1. Download + extract text
const dlRes = await apiClient.post('/addins/box/download', {
  fileId: file.id, fileName: file.name, mimeType: file.mimeType,
}, { timeout: 60000 })
if (!dlRes.success) throw new Error(dlRes.error?.message || 'Download failed')
const { textContent } = (dlRes.data as { data: { textContent: string } }).data

// 2. Analyze with Presidio
const analyzeRes = await apiClient.post('/presidio/analyze', {
  text: textContent, language, entities, score_threshold: scoreThreshold,
}, { timeout: 120000 })
if (!analyzeRes.success) throw new Error(analyzeRes.error?.message || 'Analysis failed')

// 3. Show in FileResults (review step), let user adjust operators

// 4. Anonymize
const anonRes = await apiClient.post('/presidio/anonymize', {
  text: textContent, analyzer_results: analyzerResults, operators: operatorMap,
})

// 5. Upload anonymized file
await uploadFile(anonRes.text, file)

Key rules:

  • Always check res.success after every apiClient call — it returns { success: false } on error, never throws
  • Use timeout: 60000 for download calls (file download can be slow)
  • Use timeout: 120000 for analyze calls (Presidio language model loading can take 90s)
  • For images, route through /api/presidio/image instead of text pipeline

Shared components:

import { FileAnalysisProgress, FileResults, ImageHandler, SaveBack } from '@/lib/addins/sdk'

// Analyze step
<FileAnalysisProgress results={analysisResults} currentFile={currentFile} />

// Review step
<FileResults
  results={analysisResults}
  operators={operators}
  excludedEntities={excludedEntities}
  onOperatorChange={(type, config) => setOperators(prev => ({ ...prev, [type]: config }))}
  onToggleExclude={(fileId, index) => { /* toggle entity exclusion */ }}
  onBulkOperator={(config) => { /* apply to all types */ }}
/>

// Done step
<SaveBack
  files={anonymizedFiles}
  onDone={resetAll}
  onUpload={handleUploadFile}   // (file, targetFileName) => Promise<{ webUrl? }>
/>

8. Token Storage#

All OAuth tokens are stored AES-256-GCM encrypted per-user in the UserAddin table. Use these service functions:

import { storeOAuthTokens, getDecryptedTokens, updateLastUsed } from '@/lib/addins/sdk'

// After token exchange (in callback route):
await storeOAuthTokens(userId, addinId, {
  accessToken: tokens.access_token,
  refreshToken: tokens.refresh_token,
  expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
}, accountInfo)  // { email?, displayName? }

// In your getValidAccessToken function:
const tokens = await getDecryptedTokens(userId, addinId)
if (!tokens) throw new Error('Not connected')
if (tokens.expiresAt && tokens.expiresAt.getTime() - Date.now() < 5 * 60 * 1000) {
  return refreshAccessToken(userId, addinId)  // auto-refresh 5 min before expiry
}
return tokens.accessToken

Disconnect pattern — clears tokens but keeps addin activated:

import { deactivateAddin, activateAddin } from '@/lib/addins/user-addin-service'
await deactivateAddin(userId, addinId)  // clears tokens + sets isActive=false
await activateAddin(userId, addinId)    // re-activates without tokens

9. DB Registration via Admin API#

Addins are seeded at first deploy via prisma/seed-addins.ts. OAuth credentials are NOT in the seed — they're stored via the admin API after deployment:

# Get addin ID
GET /api/admin/addins

# Store OAuth credentials (encrypted in DB)
PUT /api/admin/addins/{id}
{
  "oauthClientId": "your-client-id",
  "oauthClientSecret": "your-client-secret"
}

Your service reads them at runtime:

const addin = await getAddinBySlug('box')
const config = JSON.parse(decryptAddinSecret(addin.oauthConfig))
// config.clientId, config.clientSecret

Icon slugs available in the addins page tab bar: cloud, globe, plug, search, hard-drive, folder-open, server


10. Security Requirements#

SSRF Protection#

Every URL that fetches from a provider API must be validated before use. Never pass user-controlled URLs directly to fetch().

function validateProviderUrl(url: string, allowedHostnames: string[]): void {
  const parsed = new URL(url)
  const allowed = allowedHostnames.some(h => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`))
  if (!allowed) throw new Error(`Blocked request to ${parsed.hostname}`)
}
// Always call this before fetch:
validateProviderUrl(downloadUrl, ['api.box.com'])

For Nextcloud (user-supplied server URLs), additionally block private IP ranges:

  • localhost, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16

Redirect Following#

When following HTTP 302 redirects from file downloads, always validate the redirect destination hostname before following. Use redirect: 'manual' and check the Location header:

const res = await fetch(url, { redirect: 'manual' })
if (res.status >= 300 && res.status < 400) {
  const location = res.headers.get('location')
  validateProviderUrl(location, allowedCdnHostnames)
  const finalRes = await fetch(location)
  return Buffer.from(await finalRes.arrayBuffer())
}

State Parameter#

Always encrypt the OAuth state parameter to prevent CSRF:

const state = encryptState({ userId, addinId, nonce: crypto.randomBytes(16).toString('hex'), timestamp: Date.now() })
// In callback, validate:
const stateData = decryptState(state)
if (Date.now() - stateData.timestamp > 10 * 60 * 1000) throw new Error('State expired')

Store code_verifier in an httpOnly cookie scoped to the callback path only:

cookieStore.set('box_cv', encryptAddinSecret(codeVerifier), {
  httpOnly: true, secure: true, sameSite: 'lax',
  path: '/api/addins/box/callback',
  maxAge: 300,  // 5 minutes
})

HTTPS Only#

All provider API calls must use HTTPS. Reject any server URL that uses HTTP.


11. Complete Example — Box.com Addin#

A fully working ~250-line implementation of a Box.com addin, showing every integration point.

lib/addins/box-service.ts#

import crypto from 'crypto'
import { generateCodeVerifier, generateCodeChallenge, encryptState, decryptState, OAuthState } from '@/lib/addins/oauth-helpers'
import { getAddinBySlug } from '@/lib/addins/addin-service'
import { getDecryptedTokens, storeOAuthTokens, updateLastUsed } from '@/lib/addins/user-addin-service'
import { decryptAddinSecret } from '@/lib/addins/encryption'
import { extractTextFromBuffer, isImageFile } from '@/lib/addins/microsoft365-service'
import type { CloudFile, CloudAccountInfo } from '@/types/addins'

const BOX_AUTH_URL = 'https://account.box.com/api/oauth2/authorize'
const BOX_TOKEN_URL = 'https://api.box.com/oauth2/token'
const BOX_API_BASE = 'https://api.box.com/2.0'

function validateBoxUrl(url: string) {
  const parsed = new URL(url)
  if (!['api.box.com', 'upload.box.com'].includes(parsed.hostname)) {
    throw new Error(`Blocked request to ${parsed.hostname}`)
  }
}

async function getOAuthConfig() {
  const addin = await getAddinBySlug('box')
  if (!addin?.oauthConfig) throw new Error('Box addin not configured. Admin must set OAuth credentials.')
  const config = JSON.parse(decryptAddinSecret(addin.oauthConfig))
  return { clientId: config.clientId as string, clientSecret: config.clientSecret as string, addinId: addin.id }
}

export async function buildAuthUrl(userId: string, redirectUri: string) {
  const { clientId, addinId } = await getOAuthConfig()
  const codeVerifier = generateCodeVerifier()
  const state = encryptState({ userId, addinId, nonce: crypto.randomBytes(16).toString('hex'), timestamp: Date.now() })
  const params = new URLSearchParams({
    client_id: clientId, response_type: 'code', redirect_uri: redirectUri, state,
    code_challenge: generateCodeChallenge(codeVerifier), code_challenge_method: 'S256',
  })
  return { authUrl: `${BOX_AUTH_URL}?${params}`, codeVerifier, addinId }
}

export async function exchangeCodeForTokens(code: string, codeVerifier: string, redirectUri: string, state: string) {
  const stateData = decryptState(state)
  if (Date.now() - stateData.timestamp > 10 * 60 * 1000) throw new Error('State expired')
  const { clientId, clientSecret } = await getOAuthConfig()
  const res = await fetch(BOX_TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret, code_verifier: codeVerifier }).toString(),
  })
  if (!res.ok) throw new Error(`Token exchange failed: ${await res.text()}`)
  const tokens = await res.json()
  const profileRes = await fetch(`${BOX_API_BASE}/users/me`, { headers: { Authorization: `Bearer ${tokens.access_token}` } })
  const profile = await profileRes.json()
  const accountInfo: CloudAccountInfo = { email: profile.login, displayName: profile.name }
  await storeOAuthTokens(stateData.userId, stateData.addinId, {
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token,
    expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
  }, accountInfo)
  return { userId: stateData.userId, addinId: stateData.addinId }
}

export async function getValidAccessToken(userId: string, addinId: string): Promise<string> {
  const tokens = await getDecryptedTokens(userId, addinId)
  if (!tokens) throw new Error('Not connected to Box')
  if (tokens.expiresAt && tokens.expiresAt.getTime() - Date.now() < 5 * 60 * 1000) {
    // refresh logic here
  }
  return tokens.accessToken
}

export async function listFolder(accessToken: string, folderId = '0'): Promise<CloudFile[]> {
  const url = `${BOX_API_BASE}/folders/${folderId}/items?fields=id,name,type,size,modified_at,parent`
  validateBoxUrl(url)
  const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } })
  if (!res.ok) throw new Error(`Box API error: ${res.status}`)
  const data = await res.json()
  return (data.entries || []).map((item: { id: string; name: string; type: string; size?: number; modified_at?: string; parent?: { id: string } }) => ({
    id: item.id, name: item.name, mimeType: item.type === 'folder' ? 'folder' : '',
    size: item.size || 0, modifiedAt: item.modified_at || '', isFolder: item.type === 'folder',
    path: '', parentId: item.parent?.id || '0',
  }))
}

export async function downloadFile(accessToken: string, fileId: string): Promise<Buffer> {
  const url = `${BOX_API_BASE}/files/${fileId}/content`
  validateBoxUrl(url)
  const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` }, redirect: 'manual' })
  if (res.status >= 300 && res.status < 400) {
    const location = res.headers.get('location') || ''
    // Box CDN URLs — no further SSRF risk since we generated the URL from Box API
    const finalRes = await fetch(location)
    return Buffer.from(await finalRes.arrayBuffer())
  }
  if (!res.ok) throw new Error(`Box download failed: ${res.status}`)
  return Buffer.from(await res.arrayBuffer())
}

export async function uploadFile(accessToken: string, parentId: string, fileName: string, content: string): Promise<{ id: string }> {
  const metadata = JSON.stringify({ name: fileName, parent: { id: parentId } })
  const boundary = `boundary_${crypto.randomBytes(8).toString('hex')}`
  const body = `--${boundary}\r\nContent-Disposition: form-data; name="attributes"\r\n\r\n${metadata}\r\n--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: text/plain\r\n\r\n${content}\r\n--${boundary}--`
  const res = await fetch('https://upload.box.com/api/2.0/files/content', {
    method: 'POST',
    headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': `multipart/form-data; boundary=${boundary}` },
    body,
  })
  if (!res.ok) throw new Error(`Box upload failed: ${res.status}`)
  const data = await res.json()
  return { id: data.entries?.[0]?.id }
}

export { extractTextFromBuffer, isImageFile }

app/api/addins/box/auth/route.ts#

export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { authenticateRequest } from '@/lib/auth-helpers'
import { buildAuthUrl } from '@/lib/addins/box-service'
import { encryptAddinSecret } from '@/lib/addins/encryption'
import { cookies } from 'next/headers'

export async function GET(request: NextRequest) {
  const user = await authenticateRequest(request)
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  try {
    const origin = process.env.NEXTAUTH_URL?.replace(/\/$/, '') || request.nextUrl.origin
    const redirectUri = `${origin}/api/addins/box/callback`
    const { authUrl, codeVerifier } = await buildAuthUrl(user.id, redirectUri)
    const cookieStore = await cookies()
    cookieStore.set('box_cv', encryptAddinSecret(codeVerifier), {
      httpOnly: true, secure: true, sameSite: 'lax',
      path: '/api/addins/box/callback', maxAge: 300,
    })
    return NextResponse.json({ success: true, data: { authUrl } })
  } catch (err) {
    return NextResponse.json({ error: err instanceof Error ? err.message : 'Failed' }, { status: 500 })
  }
}

components/addins/box/connect-step.tsx#

"use client"
import { GenericConnectStep } from '@/lib/addins/sdk'
import type { UserAddinData } from '@/types/addins'

const BOX_ICON = (
  <svg className="h-8 w-8" viewBox="0 0 24 24" fill="none">
    <rect width="24" height="24" rx="4" fill="#0061D5"/>
    <path d="M12 6l-6 3.5v7L12 20l6-3.5v-7L12 6z" fill="white" fillOpacity="0.9"/>
  </svg>
)

interface Props { userAddin: UserAddinData; onConnected: () => void; onRefreshState: () => void }

export function ConnectStep({ userAddin, onConnected, onRefreshState }: Props) {
  return (
    <GenericConnectStep
      providerName="Box"
      providerSlug="box"
      authRoute="/addins/box/auth"
      disconnectRoute="/addins/box/disconnect"
      providerIcon={BOX_ICON}
      userAddin={userAddin}
      onConnected={onConnected}
      onRefreshState={onRefreshState}
      continueLabel="Continue to Files"
      connectLabel="Sign in with Box"
      description="Connect your Box account to browse and anonymize files."
    />
  )
}

lib/addins/builtin/box.ts#

import { registerAddin } from '@/lib/addins/registry'
import { BoxPanel } from '@/components/addins/box/panel'

registerAddin({
  slug: 'box',
  Panel: BoxPanel,
  requiresOAuth: true,
  capabilities: ['browse-files', 'upload', 'download'],
})

FAQ#

Can I add a custom settings page? Yes — provide a Settings component in your AddinRenderer. It receives AddinSettingsProps and calls onSave(settings).

Can my addin work without OAuth? Yes — set requiresOAuth: false in your AddinRenderer. You handle the auth UI yourself in your panel.

How do image files work? Use isImageFile(mimeType) to detect images. Return { isImage: true, imageData: base64string } from your download route. The panel routes those files through ImageHandler which calls /api/presidio/image.

Can I use custom entity types? Users select entity types and presets in the Preset step. Your addin doesn't need to configure this — it's handled by the shared PresetConfigStep component.

How do I handle rate limits? The Presidio analyze endpoint returns HTTP 429 with Retry-After when per-user rate limits are hit. The apiClient does NOT auto-retry. Handle 429 in your panel by showing a toast and letting the user retry.

Where are tokens stored? Encrypted (AES-256-GCM, PBKDF2-derived key, per-record salt + nonce) in the UserAddin table. Tokens never leave the server.