Skip to content

Commit 83555f7

Browse files
Merge pull request #274 from Mrchinedum/feature/sep12-kyc-persistence
feat: implement SEP-12 KYC persistence layer
2 parents ee3512f + d4d0051 commit 83555f7

7 files changed

Lines changed: 954 additions & 0 deletions

File tree

index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,12 @@ async function createApp(dependencies = {}) {
483483
app.use('/auth', require('./routes/auth'));
484484
app.use('/auth', require('./routes/stellarAuth'));
485485

486+
// ── SEP-12 Customer Identification (KYC) routes ─────────────────────────────
487+
app.use('/sep12', require('./routes/sep12'));
488+
489+
// ── SumSub KYC Webhook ───────────────────────────────────────────────────────
490+
app.use('/webhooks', require('./routes/sumsubWebhook'));
491+
486492
// ── SEP-24 Interactive Flow routes ───────────────────────────────────────────
487493
app.use('/sep24', require('./routes/sep24'));
488494

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* SEP-12 KYC Tables
3+
*
4+
* customer_profiles : Stores encrypted PII per Stellar pubkey.
5+
* All sensitive fields are AES-256 ciphertext blobs.
6+
* merchant_kyc_requirements: Per-merchant tier requirements that drive
7+
* "requirement masking" — which fields are needed
8+
* before a customer is cleared for a given plan.
9+
*/
10+
exports.up = async function up(knex) {
11+
await knex.schema.createTable('customer_profiles', (table) => {
12+
table.string('id').primary().defaultTo(knex.raw("(lower(hex(randomblob(16))))"));
13+
table.string('stellar_account').notNullable().unique();
14+
15+
// Encrypted PII blobs (AES-256-GCM, base64-encoded: iv:authTag:ciphertext)
16+
table.text('enc_full_name').nullable();
17+
table.text('enc_address').nullable();
18+
table.text('enc_date_of_birth').nullable();
19+
table.text('enc_id_photo_cid').nullable(); // IPFS CID of uploaded ID document
20+
21+
// KYC status managed by SumSub webhook
22+
table.string('verification_status').notNullable().defaultTo('NEEDS_INFO');
23+
// NEEDS_INFO | PENDING | APPROVED | REJECTED
24+
25+
table.string('sumsub_applicant_id').nullable().unique();
26+
table.text('rejection_reason').nullable();
27+
28+
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
29+
table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
30+
31+
table.index(['stellar_account']);
32+
table.index(['verification_status']);
33+
});
34+
35+
await knex.schema.createTable('merchant_kyc_requirements', (table) => {
36+
table.string('id').primary().defaultTo(knex.raw("(lower(hex(randomblob(16))))"));
37+
table.string('merchant_id').notNullable();
38+
table.string('tier_name').notNullable(); // e.g. 'Tier_1', 'Tier_2'
39+
40+
// Which fields are required for this merchant/tier combination
41+
table.boolean('requires_full_name').notNullable().defaultTo(true);
42+
table.boolean('requires_address').notNullable().defaultTo(false);
43+
table.boolean('requires_date_of_birth').notNullable().defaultTo(false);
44+
table.boolean('requires_id_photo').notNullable().defaultTo(false);
45+
46+
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
47+
table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
48+
49+
table.unique(['merchant_id', 'tier_name']);
50+
table.index(['merchant_id']);
51+
});
52+
};
53+
54+
exports.down = async function down(knex) {
55+
await knex.schema.dropTableIfExists('merchant_kyc_requirements');
56+
await knex.schema.dropTableIfExists('customer_profiles');
57+
};

routes/sep12.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
'use strict';
2+
3+
/**
4+
* SEP-12 Customer Identification Routes
5+
*
6+
* GET /sep12/customer – retrieve KYC status (with requirement masking)
7+
* PUT /sep12/customer – submit / update PII fields
8+
* PUT /sep12/customer/requirements – admin: set merchant KYC requirements
9+
*
10+
* All endpoints require a valid JWT (authenticateToken).
11+
* The authenticated wallet address is used as the Stellar account unless an
12+
* admin explicitly passes ?account= (future extension).
13+
*/
14+
15+
const express = require('express');
16+
const router = express.Router();
17+
const { authenticateToken } = require('../middleware/auth');
18+
const { CustomerService } = require('../src/services/customerService');
19+
const logger = require('../src/utils/logger');
20+
21+
// Stellar public key regex (G + 55 uppercase alphanumeric chars)
22+
const STELLAR_ACCOUNT_RE = /^G[A-Z0-9]{55}$/;
23+
24+
function getService(req) {
25+
const db = req.app.get('database');
26+
return new CustomerService(db);
27+
}
28+
29+
// ─── GET /sep12/customer ────────────────────────────────────────────────────
30+
31+
/**
32+
* @query account Stellar public key (defaults to authenticated user)
33+
* @query merchant_id Optional – enables requirement masking
34+
* @query tier Optional – tier name for requirement masking
35+
*/
36+
router.get('/customer', authenticateToken, async (req, res) => {
37+
try {
38+
const account = req.query.account || req.user?.id;
39+
if (!account || !STELLAR_ACCOUNT_RE.test(account)) {
40+
return res.status(400).json({ error: 'Invalid or missing Stellar account' });
41+
}
42+
43+
const { merchant_id, tier } = req.query;
44+
const svc = getService(req);
45+
const result = await svc.getCustomer(account, merchant_id || null, tier || null);
46+
47+
return res.json(result);
48+
} catch (err) {
49+
logger.error('[SEP-12] GET /customer error', { error: err.message });
50+
return res.status(500).json({ error: 'Internal server error' });
51+
}
52+
});
53+
54+
// ─── PUT /sep12/customer ────────────────────────────────────────────────────
55+
56+
/**
57+
* @body account Stellar public key (defaults to authenticated user)
58+
* @body full_name string
59+
* @body address string
60+
* @body date_of_birth string YYYY-MM-DD
61+
* @body id_photo_cid string IPFS CID
62+
*/
63+
router.put('/customer', authenticateToken, async (req, res) => {
64+
try {
65+
const account = req.body.account || req.user?.id;
66+
if (!account || !STELLAR_ACCOUNT_RE.test(account)) {
67+
return res.status(400).json({ error: 'Invalid or missing Stellar account' });
68+
}
69+
70+
const { full_name, address, date_of_birth, id_photo_cid } = req.body;
71+
const fields = {};
72+
if (full_name !== undefined) fields.full_name = full_name;
73+
if (address !== undefined) fields.address = address;
74+
if (date_of_birth !== undefined) fields.date_of_birth = date_of_birth;
75+
if (id_photo_cid !== undefined) fields.id_photo_cid = id_photo_cid;
76+
77+
if (Object.keys(fields).length === 0) {
78+
return res.status(400).json({ error: 'No KYC fields provided' });
79+
}
80+
81+
const svc = getService(req);
82+
const result = await svc.putCustomer(account, fields);
83+
84+
return res.status(202).json(result);
85+
} catch (err) {
86+
logger.error('[SEP-12] PUT /customer error', { error: err.message });
87+
return res.status(500).json({ error: 'Internal server error' });
88+
}
89+
});
90+
91+
// ─── PUT /sep12/customer/requirements ──────────────────────────────────────
92+
93+
/**
94+
* Admin endpoint: define which fields a merchant requires for a given tier.
95+
*
96+
* @body merchant_id
97+
* @body tier_name
98+
* @body requires_full_name boolean
99+
* @body requires_address boolean
100+
* @body requires_date_of_birth boolean
101+
* @body requires_id_photo boolean
102+
*/
103+
router.put('/customer/requirements', authenticateToken, async (req, res) => {
104+
try {
105+
const { merchant_id, tier_name, ...rest } = req.body;
106+
if (!merchant_id || !tier_name) {
107+
return res.status(400).json({ error: 'merchant_id and tier_name are required' });
108+
}
109+
110+
const allowed = ['requires_full_name', 'requires_address', 'requires_date_of_birth', 'requires_id_photo'];
111+
const requirements = {};
112+
for (const key of allowed) {
113+
if (rest[key] !== undefined) requirements[key] = Boolean(rest[key]);
114+
}
115+
116+
const svc = getService(req);
117+
await svc.setMerchantRequirements(merchant_id, tier_name, requirements);
118+
119+
return res.json({ success: true, merchant_id, tier_name, requirements });
120+
} catch (err) {
121+
logger.error('[SEP-12] PUT /customer/requirements error', { error: err.message });
122+
return res.status(500).json({ error: 'Internal server error' });
123+
}
124+
});
125+
126+
module.exports = router;

routes/sumsubWebhook.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
'use strict';
2+
3+
/**
4+
* SumSub Webhook Handler
5+
*
6+
* POST /webhooks/sumsub
7+
*
8+
* SumSub sends a signed payload whenever an applicant's review status changes.
9+
* We verify the HMAC-SHA256 signature, map the SumSub review result to our
10+
* internal STATUS, then call CustomerService.updateVerificationStatus().
11+
*
12+
* Signature verification:
13+
* X-App-Token : SumSub app token (identifies the sender)
14+
* X-App-Access-Sig : HMAC-SHA256(secret, timestamp + body)
15+
* X-App-Access-Ts : Unix timestamp (used in signature)
16+
*
17+
* Env vars:
18+
* SUMSUB_WEBHOOK_SECRET – shared secret for HMAC verification
19+
* SUMSUB_APP_TOKEN – expected app token header value
20+
*/
21+
22+
const express = require('express');
23+
const crypto = require('crypto');
24+
const router = express.Router();
25+
const { CustomerService, STATUS } = require('../src/services/customerService');
26+
const logger = require('../src/utils/logger');
27+
28+
// SumSub reviewResult → internal STATUS
29+
const REVIEW_RESULT_MAP = {
30+
GREEN: STATUS.APPROVED,
31+
RED: STATUS.REJECTED,
32+
// Intermediate states
33+
PENDING: STATUS.PENDING,
34+
RETRY: STATUS.NEEDS_INFO,
35+
};
36+
37+
/**
38+
* Verify SumSub HMAC-SHA256 signature.
39+
* Signature = HMAC-SHA256(secret, timestamp + rawBody)
40+
*/
41+
function verifySignature(rawBody, timestamp, sig) {
42+
const secret = process.env.SUMSUB_WEBHOOK_SECRET;
43+
if (!secret) {
44+
logger.warn('[SumSub] SUMSUB_WEBHOOK_SECRET not set — skipping signature verification');
45+
return true; // allow in dev; enforce in prod via env
46+
}
47+
const expected = crypto
48+
.createHmac('sha256', secret)
49+
.update(timestamp + rawBody)
50+
.digest('hex');
51+
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
52+
}
53+
54+
// Raw body is needed for HMAC verification — mount before express.json()
55+
router.post(
56+
'/sumsub',
57+
express.raw({ type: 'application/json' }),
58+
async (req, res) => {
59+
const sig = req.headers['x-app-access-sig'];
60+
const ts = req.headers['x-app-access-ts'];
61+
const appToken = req.headers['x-app-token'];
62+
63+
// Validate app token if configured
64+
const expectedToken = process.env.SUMSUB_APP_TOKEN;
65+
if (expectedToken && appToken !== expectedToken) {
66+
logger.warn('[SumSub] Webhook rejected: invalid app token');
67+
return res.status(401).json({ error: 'Unauthorized' });
68+
}
69+
70+
const rawBody = req.body.toString('utf8');
71+
72+
if (sig && ts && !verifySignature(rawBody, ts, sig)) {
73+
logger.warn('[SumSub] Webhook rejected: invalid signature');
74+
return res.status(401).json({ error: 'Invalid signature' });
75+
}
76+
77+
let payload;
78+
try {
79+
payload = JSON.parse(rawBody);
80+
} catch {
81+
return res.status(400).json({ error: 'Invalid JSON' });
82+
}
83+
84+
const { applicantId, externalUserId, type, reviewResult } = payload;
85+
86+
// We only care about applicantReviewed events
87+
if (type !== 'applicantReviewed') {
88+
return res.status(200).json({ received: true });
89+
}
90+
91+
if (!externalUserId) {
92+
logger.warn('[SumSub] Webhook missing externalUserId', { applicantId });
93+
return res.status(400).json({ error: 'externalUserId required' });
94+
}
95+
96+
const reviewAnswer = reviewResult?.reviewAnswer; // GREEN | RED
97+
const internalStatus = REVIEW_RESULT_MAP[reviewAnswer] || STATUS.PENDING;
98+
const rejectionReason = reviewResult?.rejectLabels?.join(', ') || null;
99+
100+
try {
101+
const db = req.app.get('database');
102+
const svc = new CustomerService(db);
103+
await svc.updateVerificationStatus(externalUserId, internalStatus, {
104+
applicantId,
105+
rejectionReason,
106+
});
107+
108+
logger.info('[SumSub] Verification status updated', {
109+
stellarAccount: externalUserId,
110+
status: internalStatus,
111+
applicantId,
112+
});
113+
114+
return res.status(200).json({ received: true });
115+
} catch (err) {
116+
logger.error('[SumSub] Failed to update verification status', { error: err.message });
117+
return res.status(500).json({ error: 'Internal server error' });
118+
}
119+
}
120+
);
121+
122+
module.exports = router;

0 commit comments

Comments
 (0)