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#
- Architecture Overview
- Quickstart — 5 Steps
- AddinRenderer Interface
- AddinPanelProps — What Your Panel Receives
- OAuth2 Integration with GenericConnectStep
- File Browsing with GenericFileBrowser
- Analysis Pipeline
- Token Storage
- DB Registration via Admin API
- Security Requirements
- 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:
- Connect — OAuth2 or custom auth flow
- Preset — entity types, confidence, reversible anonymization
- Browse — folder navigation, file multi-select
- Analyze — per-file text extraction + Presidio NLP
- Review — entity groups, operator selection
- 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:
- User clicks "Sign in with Box" →
GET /api/addins/box/auth→ getsauthUrl→ opens popup - Popup redirects to Box → user consents → Box redirects to
/api/addins/box/callback - Callback exchanges code for tokens (stores encrypted in DB) → posts
BroadcastChannel('box-oauth')message with{ type: 'box-oauth-complete', success: true } GenericConnectStepreceives the message → callsonRefreshState()→ 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 FileBrowser → FileAnalysisProgress → FileResults → SaveBack. 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.successafter everyapiClientcall — it returns{ success: false }on error, never throws - Use
timeout: 60000for download calls (file download can be slow) - Use
timeout: 120000for analyze calls (Presidio language model loading can take 90s) - For images, route through
/api/presidio/imageinstead 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')
Cookie Security#
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.