Akkuea's compliance layer is entirely off-chain. There are no whitelist addresses on the Soroban contract - investor eligibility is controlled through a state machine stored in PostgreSQL and enforced (or intended to be enforced) at the API layer.
Key files:
apps/api/src/controllers/KYCController.ts- all KYC business logicapps/api/src/repositories/KYCRepository.ts- database operationsapps/api/src/routes/kyc.ts- HTTP endpointsapps/api/src/db/schema/users.ts-users.kycStatuscolumnapps/api/src/db/schema/kyc.ts-kycDocumentstable
KYC in this system works like a manual oracle: an off-chain human operator (the admin) observes uploaded documents and decides whether to approve or reject them. The system has no automated identity verification provider - the admin IS the compliance decision-maker.
Investor (user) Platform Admin Database
| | |
| Upload documents | |
|-----------------------------------------------> |
| | kycDocuments.status |
| | = 'pending' |
| | |
| | Review docs manually |
| | |
| | POST /kyc/verify/:id |
| | { verified: true } |
| |-----------------------> |
| | doc.status='approved' |
| | users.kycStatus=... |
| |<----------------------- |
| Notification sent | |
|<-----------------------------------------------|
The admin acts as the oracle for compliance truth. There is no third-party KYC provider integration in the current codebase.
The users.kycStatus column (KYCRepository.ts:78-86) holds the user-level compliance verdict. It is separate from per-document status.
┌─────────────┐
│ not_started │ (default on user creation)
└──────┬──────┘
│ uploadDocument() called
▼
┌─────────────┐
│ pending │◄──────────────────────────────┐
└──────┬──────┘ │
│ verifyDocument() called │
▼ │
┌──────────────────────┐ │
│ anyRejected? │ │
├──────────────────────┤ │
│ YES → rejected │ │
│ NO + allApproved │ │
│ → approved │ uploadDocument() again │
└──────────────────────┘ (re-upload resets doc │
│ to pending, may trigger │
│ re-review loop)─────────┘
▼
┌─────────────┐
│ approved │ ← compliance gate satisfied
└─────────────┘
┌─────────────┐
│ expired │ ← set manually or by future
└─────────────┘ expiry job (not implemented)
Rules governing state transitions (source: KYCController.ts:191-209):
Condition after a verifyDocument() call |
users.kycStatus result |
|---|---|
Any document in rejected state |
rejected |
ALL documents in approved state |
approved |
Some approved, none rejected, others pending |
unchanged (still pending) |
A user with a single rejected document blocks approval even if all other documents pass. Every document must reach approved for the user-level status to flip.
These are two separate concepts stored in two separate tables:
| Level | Table | Column | Values |
|---|---|---|---|
| Document | kycDocuments |
status |
pending, approved, rejected |
| User | users |
kycStatus |
not_started, pending, approved, rejected, expired |
The user-level status is the compliance gate. Per-document statuses are intermediate signals used to compute it.
Base path: /kyc
POST /kyc/upload
Content-Type: multipart/form-data
Fields:
userId string User UUID
documentType string One of: passport, id_card, national_id, drivers_license,
proof_of_address, bank_statement, tax_document, other
file File PDF, PNG, or JPEG (size limit enforced by StorageService)Behavior:
- If a document of the same
documentTypealready exists for this user, it is replaced (old file deleted, new file stored). - Sets the document's
statustopending. - Sets
users.kycStatustopendingregardless of previous value.
Response (200):
{ "documentId": "<uuid>", "submissionId": "<userId>" }POST /kyc/submit
Content-Type: application/json
{
"userId": "<uuid>",
"documents": [
{ "type": "passport", "documentUrl": "<url>" }
]
}This endpoint sets users.kycStatus to pending if any documents exist. It is a soft signal to the admin that the user considers their submission complete. It does not perform any verification.
GET /kyc/status/:userIdResponse:
{
"status": "pending" | "verified" | "rejected",
"documents": [...]
}Note: the public-facing status vocabulary differs from the DB vocabulary:
users.kycStatus (DB) |
status returned by API |
|---|---|
not_started |
pending |
pending |
pending |
approved |
verified |
rejected |
rejected |
expired |
rejected |
GET /kyc/documents/:userIdReturns all documents with pre-signed file access URLs (/kyc/file/:documentId).
GET /kyc/file/:documentIdReturns the raw file buffer with correct Content-Type (application/pdf, image/png, image/jpeg).
POST /kyc/verify/:documentId
Content-Type: application/json
{
"verified": true | false,
"notes": "optional rejection reason or approval note"
}This is the admin's oracle action. Calling it:
- Sets
kycDocuments.statustoapprovedorrejected. - Sets
kycDocuments.rejectionReason(if rejected, usesnotesvalue). - Sets
kycDocuments.reviewedAtto current timestamp. - Re-evaluates ALL documents for the user to compute the new
users.kycStatus. - Sends an in-app notification to the user.
Response (200):
{ "success": true }Source: KYCController.ts:196-210
| Outcome | Notification sent |
|---|---|
| Any doc rejected | notificationService.notifyVerificationRejected(userId, notes, 'IN_APP') |
| All docs approved | notificationService.notifyVerificationApproved(userId, 'IN_APP') |
| Partially approved (no rejections yet) | No notification |
POST /kyc/verify/:documentId in routes/kyc.ts:130-139 has no authentication middleware. Any HTTP client that knows a documentId can call this endpoint and approve or reject a document without credentials.
This endpoint must be protected before production. The recommended approach is to check OPERATIONS_BACKEND_CREDENTIAL (already defined in .env.example) or require the x-user-address header to match OPERATIONS_ALLOWED_WALLETS.
POST /properties/:id/buy-shares (routes/properties.ts:156-183) does not check users.kycStatus before allowing a share purchase. A user with kycStatus = 'not_started' can buy shares today.
The users.kycStatus = 'approved' field exists in the schema and is correctly maintained by the KYC flow. The enforcement gate is the missing piece. The controller comment marks this feature as "planned for Cycle 2" (PropertyController.ts:496).
Intended integration (not yet implemented):
// Pseudocode for the intended guard in buyShares
const kycStatus = await kycRepository.getUserKycStatus(buyer.id);
if (kycStatus !== "approved") {
throw new AuthorizationError("KYC verification required to purchase shares");
}Until this guard is implemented, KYC approval is advisory, not enforced.
Stability Note - Issue #725: Structured audit logging, tamper-evident event trails, and compliance export utilities for KYC actions are currently under design and development in Issue #725 - Audit Log Models & Compliance Export. The section below documents the current logging state. This section will be expanded significantly once Issue #725 is merged.
Admin actions on the KYC flow currently produce structured application-level logs via the logger service. These are present in the controllers but are not persisted to a dedicated audit table.
Events that currently emit log entries:
| Action | Log level | Fields logged |
|---|---|---|
| Document uploaded | info (implicit via uploadDocument) |
userId, documentType, documentId |
| Document verified (approved/rejected) | info / error not explicitly logged in verifyDocument |
(no explicit audit log call - see gap below) |
| KYC status updated | via updateUserKycStatus DB write |
timestamp recorded in users.updatedAt |
Current gap: KYCController.verifyDocument() (KYCController.ts:178-217) does not emit an explicit audit log entry recording who made the verification decision, when, and what notes were provided. Only the DB columns kycDocuments.reviewedAt and kycDocuments.rejectionReason capture partial state.
Once closed, Issue #725 is expected to deliver:
- A dedicated
auditLogstable recording every state transition with: actor identity, timestamp, previous state, new state, and reason. - Structured log entries on all admin actions (
verifyDocument,updateUserKycStatus, manual status overrides). - A compliance export endpoint (format TBD) for regulatory reporting - list of approved/rejected investors with timestamps and document references.
- Retention policy configuration for KYC documents and audit records.
When Issue #725 is merged: Replace this placeholder section with the actual audit log schema, the new export endpoint reference, and the updated verifyDocument flow that includes the actor-identity log call.
Step-by-step procedure for the compliance operator:
# 1. List pending documents for a user
curl http://localhost:3001/kyc/documents/<userId>
# 2. View each document file
curl http://localhost:3001/kyc/file/<documentId> -o document.pdf
# 3a. Approve a document
curl -X POST http://localhost:3001/kyc/verify/<documentId> \
-H "Content-Type: application/json" \
-d '{"verified": true}'
# 3b. Reject a document with a reason
curl -X POST http://localhost:3001/kyc/verify/<documentId> \
-H "Content-Type: application/json" \
-d '{"verified": false, "notes": "Passport image is blurry. Please resubmit."}'
# 4. Confirm user-level status after all documents processed
curl http://localhost:3001/kyc/status/<userId>
# status should be "verified" if all documents approvedRepeat Step 3a for every document the user uploaded. The user-level status only flips to approved when every document is in approved state.
docs/api/minting-workflow.md- howproperty.verified(separate from user KYC) gates tokenizationdocs/deployment/environment-variables.md-OPERATIONS_BACKEND_CREDENTIALandOPERATIONS_ALLOWED_WALLETS- Issue #725 - Audit Log Models & Compliance Export (pending - will expand the Audit & Compliance Logging section above)
- Issue #722 - Dividend & Cashflow Distribution (pending - adds new investor payout workflows; see
docs/operations/runbook-dividends-placeholder.md)