{ "name": "facevault-kyc", "version": 1, "type": "kyc", "author": "FaceVault", "bio": "AI-powered identity verification with face matching, document OCR, and anti-spoofing.", "description": "Replace manual KYC review with FaceVault — AI-powered identity verification. Features include face matching (ArcFace), document OCR/MRZ extraction, 15-signal document fraud detection, liveness detection, anti-spoofing, and proof of address verification. Flat pricing from $0.35/check. Self-hosted option for enterprise.", "documentation": "https://facevault.id/docs", "logo": "https://facevault.id/logo-shield.png", "icon": "https://facevault.id/favicon.svg", "url": "https://facevault.id", "meta": { "api_key": { "type": "string", "required": true, "description": "Your FaceVault API key (starts with fv_live_ or fv_test_)", "value": "" }, "api_url": { "type": "string", "required": true, "description": "FaceVault API base URL (default: https://api.facevault.id — or your self-hosted URL)", "value": "https://api.facevault.id" }, "webhook_secret": { "type": "string", "required": true, "description": "Webhook signing secret from your FaceVault dashboard (devdash.facevault.id)", "value": "" }, "verified_level": { "type": "number", "required": false, "description": "HollaEx verification level to assign on successful KYC (default: 2)", "value": 2 }, "require_poa": { "type": "boolean", "required": false, "description": "Require proof of address (utility bill, bank statement) in addition to ID", "value": false } }, "public_meta": { "app_url": { "type": "string", "required": false, "description": "FaceVault KYC webapp URL (default: https://app.facevault.id)", "value": "https://app.facevault.id" } }, "prescript": { "install": [], "run": null }, "postscript": { "run": null }, "script": "/**\n * FaceVault KYC Plugin — Server Script for HollaEx\n *\n * Routes:\n * POST /plugins/facevault/session — Start a new verification session\n * POST /plugins/facevault/webhook — Receive verification result callback\n * GET /plugins/facevault/status — Check current user's verification status\n *\n * Available globals (provided by HollaEx plugin runtime):\n * app — Express router\n * meta — Private plugin config (api_key, api_url, webhook_secret, ...)\n * publicMeta — Public plugin config (app_url)\n * toolsLib — HollaEx user/admin utility library\n * loggerPlugin — Plugin logger\n *\n * Auth: HollaEx populates req.auth on authenticated plugin routes.\n * Webhook HMAC: FaceVault signs with JSON.dumps(payload, separators=(\",\",\":\"),\n * sort_keys=True) — compact, recursively sorted keys, no whitespace.\n * Signature is hex-encoded SHA256 in the X-Signature header.\n */\n\nconst crypto = require('crypto');\nconst https = require('https');\nconst http = require('http');\n\n// ─── Helpers ────────────────────────────────────────────\n\nfunction facevaultRequest(method, path, body) {\n\tconst apiKey = meta.api_key.value;\n\t// String concat to preserve baseUrl path (new URL() would drop it)\n\tconst baseUrl = meta.api_url.value.replace(/\\/+$/, '');\n\tconst fullUrl = baseUrl + path;\n\n\treturn new Promise((resolve, reject) => {\n\t\tconst url = new URL(fullUrl);\n\t\tconst mod = url.protocol === 'https:' ? https : http;\n\n\t\tconst options = {\n\t\t\tmethod,\n\t\t\thostname: url.hostname,\n\t\t\tport: url.port || (url.protocol === 'https:' ? 443 : 80),\n\t\t\tpath: url.pathname + url.search,\n\t\t\theaders: {\n\t\t\t\t'Authorization': 'Bearer ' + apiKey,\n\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\t'User-Agent': 'HollaEx-FaceVault-Plugin/1.0',\n\t\t\t},\n\t\t};\n\n\t\tconst req = mod.request(options, (res) => {\n\t\t\tlet data = '';\n\t\t\tres.on('data', (chunk) => { data += chunk; });\n\t\t\tres.on('end', () => {\n\t\t\t\ttry {\n\t\t\t\t\tresolve({ status: res.statusCode, data: JSON.parse(data) });\n\t\t\t\t} catch (_) {\n\t\t\t\t\tresolve({ status: res.statusCode, data: data });\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\n\t\treq.on('error', reject);\n\t\t// Timeout destroys the socket; the promise rejects and the route\n\t\t// handler's try/catch returns 500 to the caller.\n\t\treq.setTimeout(15000, () => { req.destroy(); reject(new Error('Request timeout')); });\n\n\t\tif (body) req.write(JSON.stringify(body));\n\t\treq.end();\n\t});\n}\n\n/**\n * Recursively sort object keys to match Python's json.dumps(sort_keys=True).\n * Returns a new object with all keys sorted at every nesting level.\n */\nfunction sortKeys(obj) {\n\tif (obj === null || typeof obj !== 'object') return obj;\n\tif (Array.isArray(obj)) return obj.map(sortKeys);\n\tconst sorted = {};\n\tObject.keys(obj).sort().forEach((k) => { sorted[k] = sortKeys(obj[k]); });\n\treturn sorted;\n}\n\n/**\n * Verify HMAC-SHA256 signature.\n * FaceVault signs with compact JSON (no whitespace, recursively sorted keys).\n * Signature is hex-encoded.\n */\nfunction verifyHmac(secret, payload, signature) {\n\tif (!secret || !signature) return false;\n\tconst expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');\n\ttry {\n\t\treturn crypto.timingSafeEqual(\n\t\t\tBuffer.from(expected, 'hex'),\n\t\t\tBuffer.from(signature, 'hex')\n\t\t);\n\t} catch (_) {\n\t\treturn false;\n\t}\n}\n\n// ─── POST /plugins/facevault/session ────────────────────\n// Creates a FaceVault verification session for the authenticated user.\n// Returns the verification URL that the frontend opens in a new tab.\n\napp.post('/plugins/facevault/session', async (req, res) => {\n\ttry {\n\t\tconst user = req.auth;\n\t\tif (!user || !user.id) {\n\t\t\treturn res.status(401).json({ message: 'Authentication required' });\n\t\t}\n\n\t\tconst idData = (user.id_data || {});\n\n\t\t// Don't allow re-verification if already verified\n\t\tif (idData.status === 3) {\n\t\t\treturn res.status(400).json({ message: 'Already verified' });\n\t\t}\n\n\t\t// Block if already pending — prevents spamming FaceVault sessions.\n\t\t// Allow retry after 10 minutes to unstick users whose session expired\n\t\t// or who closed the browser before completing verification.\n\t\tif (idData.status === 1) {\n\t\t\tconst note = idData.note || '';\n\t\t\tconst tsMatch = note.match(/\\[(\\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z)\\]$/);\n\t\t\tif (tsMatch) {\n\t\t\t\tconst age = Date.now() - new Date(tsMatch[1]).getTime();\n\t\t\t\tif (age < 600000) {\n\t\t\t\t\treturn res.status(409).json({ message: 'Verification already in progress' });\n\t\t\t\t}\n\t\t\t}\n\t\t\t// No timestamp = assume stale (e.g. admin-set pending), allow retry\n\t\t}\n\n\t\t// Build query params\n\t\tconst externalId = 'hollaex_' + user.id;\n\t\tlet qs = '?external_user_id=' + encodeURIComponent(externalId);\n\t\tif (meta.require_poa && meta.require_poa.value) {\n\t\t\tqs += '&require_poa=true';\n\t\t}\n\n\t\t// Create session via FaceVault API\n\t\tconst result = await facevaultRequest('POST', '/api/v1/sessions' + qs);\n\n\t\tif (result.status !== 200 && result.status !== 201) {\n\t\t\tloggerPlugin.error('FaceVault session creation failed:', result.data);\n\t\t\treturn res.status(502).json({ message: 'Failed to create verification session' });\n\t\t}\n\n\t\t// Only mark as pending AFTER successful session creation.\n\t\t// Timestamp in note enables stale-session detection (10 min TTL).\n\t\tawait toolsLib.user.updateUserInfo(user.id, {\n\t\t\tid_data: { status: 1, note: 'FaceVault verification in progress [' + new Date().toISOString() + ']' }\n\t\t});\n\n\t\tconst sessionData = result.data;\n\t\tconst sessionToken = sessionData.session_token;\n\t\tconst sessionId = sessionData.session_id;\n\t\tconst appUrl = ((publicMeta.app_url && publicMeta.app_url.value) || 'https://app.facevault.id').replace(/\\/+$/, '');\n\t\t// The webapp expects ?st=&sid= for API-key-mode\n\t\t// sessions. ?token= is reserved for shared verification-link tokens, which\n\t\t// route through /api/v1/links//init and would 404 here.\n\t\tconst verificationUrl = appUrl + '/?st=' + encodeURIComponent(sessionToken) + '&sid=' + encodeURIComponent(sessionId);\n\n\t\tres.json({\n\t\t\turl: verificationUrl,\n\t\t\tsession_id: sessionId,\n\t\t});\n\t} catch (err) {\n\t\tloggerPlugin.error('FaceVault session error:', err.message);\n\t\tres.status(500).json({ message: 'Internal error creating verification session' });\n\t}\n});\n\n// ─── POST /plugins/facevault/webhook ────────────────────\n// Receives HMAC-signed webhook from FaceVault when verification completes.\n// Updates the HollaEx user's verification level based on the result.\n//\n// HMAC verification: FaceVault signs with compact, recursively sorted JSON\n// (Python's json.dumps(separators=(\",\",\":\"), sort_keys=True)). We reproduce\n// this by recursively sorting keys and using JSON.stringify with no whitespace.\n//\n// Note: HollaEx's plugin runtime pre-parses req.body. We re-serialize to\n// match the signed format. This is tested against FaceVault's actual signing.\n\napp.post('/plugins/facevault/webhook', async (req, res) => {\n\ttry {\n\t\tconst signature = req.headers['x-signature'] || req.headers['x-facevault-signature'];\n\t\tconst webhookSecret = meta.webhook_secret.value;\n\n\t\t// Re-serialize to match FaceVault's signing format:\n\t\t// compact JSON, recursively sorted keys, no whitespace\n\t\tconst rawBody = typeof req.body === 'string'\n\t\t\t? req.body\n\t\t\t: JSON.stringify(sortKeys(req.body));\n\t\tif (!verifyHmac(webhookSecret, rawBody, signature)) {\n\t\t\tloggerPlugin.warn('FaceVault webhook: invalid signature');\n\t\t\treturn res.status(401).json({ message: 'Invalid signature' });\n\t\t}\n\n\t\tconst event = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;\n\n\t\t// Only handle verification.completed events\n\t\tif (event.event !== 'verification.completed') {\n\t\t\treturn res.status(200).json({ message: 'Ignored' });\n\t\t}\n\n\t\t// Replay protection: reject if signed_at is missing or older than 5 minutes\n\t\tif (!event.signed_at) {\n\t\t\tloggerPlugin.warn('FaceVault webhook: missing signed_at');\n\t\t\treturn res.status(401).json({ message: 'Missing signed_at' });\n\t\t}\n\t\tconst age = Date.now() - new Date(event.signed_at).getTime();\n\t\tif (age > 300000 || age < -60000) {\n\t\t\tloggerPlugin.warn('FaceVault webhook: stale or future signature (age=%dms)', age);\n\t\t\treturn res.status(401).json({ message: 'Stale webhook' });\n\t\t}\n\n\t\t// Extract HollaEx user ID from external_user_id\n\t\tconst externalId = event.external_user_id || '';\n\t\tconst match = externalId.match(/^hollaex_(\\d+)$/);\n\t\tif (!match) {\n\t\t\tloggerPlugin.warn('FaceVault webhook: unrecognized external_user_id:', externalId);\n\t\t\treturn res.status(200).json({ message: 'Not a HollaEx session' });\n\t\t}\n\n\t\t// HollaEx kit uses numeric user IDs\n\t\tconst userId = parseInt(match[1], 10);\n\t\tconst status = event.status;\n\t\tconst trustScore = event.trust_score;\n\t\tconst trustDecision = event.trust_decision;\n\t\tconst confirmedData = event.confirmed_data || {};\n\n\t\tloggerPlugin.info(\n\t\t\t'FaceVault webhook: user=%d status=%s trust=%s score=%d face_match=%s',\n\t\t\tuserId, status, trustDecision, trustScore, event.face_match_passed\n\t\t);\n\n\t\tif (status === 'passed' && trustDecision === 'accept') {\n\t\t\t// Verification passed — upgrade user level and store name in one update\n\t\t\tconst targetLevel = (meta.verified_level && meta.verified_level.value) || 2;\n\t\t\tawait toolsLib.user.changeUserVerificationLevelById(userId, targetLevel);\n\n\t\t\tconst update = {\n\t\t\t\tid_data: {\n\t\t\t\t\tstatus: 3,\n\t\t\t\t\tnote: 'Verified via FaceVault (trust score: ' + trustScore + ')'\n\t\t\t\t}\n\t\t\t};\n\t\t\tif (confirmedData.full_name) {\n\t\t\t\tupdate.full_name = confirmedData.full_name;\n\t\t\t}\n\t\t\tawait toolsLib.user.updateUserInfo(userId, update);\n\t\t} else if (status === 'failed') {\n\t\t\t// Verification failed\n\t\t\tawait toolsLib.user.updateUserInfo(userId, {\n\t\t\t\tid_data: {\n\t\t\t\t\tstatus: 2,\n\t\t\t\t\tnote: 'Verification failed (trust score: ' + trustScore + ')'\n\t\t\t\t}\n\t\t\t});\n\t\t} else {\n\t\t\t// Under review\n\t\t\tawait toolsLib.user.updateUserInfo(userId, {\n\t\t\t\tid_data: {\n\t\t\t\t\tstatus: 1,\n\t\t\t\t\tnote: 'Under manual review (trust score: ' + trustScore + ')'\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\n\t\tres.json({ message: 'OK' });\n\t} catch (err) {\n\t\tloggerPlugin.error('FaceVault webhook error:', err.message);\n\t\tres.status(500).json({ message: 'Webhook processing failed' });\n\t}\n});\n\n// ─── GET /plugins/facevault/status ──────────────────────\n// Returns the current user's verification status.\n// Note: reads from req.auth (token-time state). A user who just completed\n// verification via webhook will see stale data until their next token refresh.\n\napp.get('/plugins/facevault/status', (req, res) => {\n\tconst user = req.auth;\n\tif (!user || !user.id) {\n\t\treturn res.status(401).json({ message: 'Authentication required' });\n\t}\n\n\tconst idData = user.id_data || {};\n\tconst statusMap = { 0: 'unverified', 1: 'pending', 2: 'rejected', 3: 'verified' };\n\n\t// Strip internal timestamp from note before returning to frontend\n\tconst rawNote = idData.note || null;\n\tconst cleanNote = rawNote ? rawNote.replace(/ \\[\\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z\\]$/, '') : null;\n\n\tres.json({\n\t\tstatus: idData.status || 0,\n\t\tlabel: statusMap[idData.status] || 'unverified',\n\t\tnote: cleanNote,\n\t\tverified: idData.status === 3,\n\t});\n});\n", "web_view": [ { "src": "https://facevault.id/plugins/facevault-kyc-view.js", "meta": { "is_verification_tab": true, "type": "home", "string": { "id": "FACEVAULT_KYC_VERIFICATION", "value": "Identity Verification" }, "icon": { "id": "FACEVAULT_SHIELD_ICON", "value": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNGFkZTgwIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTIgMjJzOC00IDgtMTBWNWwtOC0zLTggM3Y3YzAgNiA4IDEwIDggMTB6Ii8+PHBhdGggZD0iTTkgMTJsMiAyIDQtNCIvPjwvc3ZnPg==" } } } ], "admin_view": null }