Skip to content

Data inventory — ZevID

Every distinct data field ZevID collects, derives, or stores. Each row is a verifiable claim — when a field lives at a specific column in a specific schema file, the cell points at it.

Columns are fixed

Don't add columns without proposing the change in TEMPLATE.md at the repo root (visible to engineers on GitHub). The DPO reads N products with the same table shape — adding columns to one breaks that.

Schema source of truth

All references are to files under zevid-backend/src/common/database/schema/. Retention specifics live in retention.md.


Identity data

The "who is this user" fields. Owned by ZevID; every other Zev product reads these via GET /v1/internal/users/:accountId rather than duplicating.

Field Type Source Lawful basis Storage Retention Notes
accountId UUID Server-generated on signup Contract — necessary to provide the account accounts.id Account lifetime Single cross-ecosystem identifier; every Zev product addresses users by this
publicId string (21 chars) Server-generated on signup Contract accounts.public_id Account lifetime Human-friendly short id (shown in admin UI, support tickets); never used for auth
email string User input at signup Contract — login identifier accounts.email (unique) Account lifetime UNIQUE constraint
passwordHash string bcrypt hash of user-supplied password Contract accounts.password_hash Account lifetime bcrypt cost 12 (auth.service.ts:37 BCRYPT_ROUNDS); plaintext is never persisted
firstName string User input Contract accounts.first_name Account lifetime Validated via @IsSafeName() (zevid-backend/src/common/validators/safe-name.validator.ts) to block spam-laundering attack patterns
lastName string User input Contract accounts.last_name Account lifetime Same validation as firstName
phone (legacy column) string Synced from primary verified phone Contract accounts.phone Mirrors the primary verified phone in account_phones; auto-cleared when last verified phone is removed Older code paths (KYC, login alerts) read this column; new code paths use account_phones
displayPicture URL User upload (R2-hosted) Consent (optional) accounts.display_picture Account lifetime Object stored in Cloudflare R2 bucket; see third-parties.md
isEmailVerified boolean Set after email-OTP confirmation Contract accounts.is_email_verified Account lifetime Default false; flipped true after first successful OTP
isBanned / banReason bool + text Admin action via admin panel Legitimate interest (fraud prevention) accounts.is_banned, accounts.ban_reason Account lifetime Set by POST /v1/admin/accounts/:id/ban; logged in admin_audit_logs
createdAt / updatedAt timestamp Server-generated Contract accounts.created_at, accounts.updated_at Account lifetime

Phones (account_phones)

Multi-phone, encrypted at rest. Schema: account-phones.schema.ts.

Field Type Source Lawful basis Storage Retention Notes
Full E.164 number string (encrypted) User input at "add phone" Consent (phone is optional; MFA + verification) account_phones.phone_e164_encrypted (AES-256-GCM via FieldCryptoService) Until user removes via portal Decryption requires FIELD_ENCRYPTION_KEY; only the owner (JWT) or super-admin sees decrypted value
Phone hash SHA-256 hex Derived from E.164 + pepper Legitimate interest (cap-of-3 uniqueness check across accounts, linked-account discovery) account_phones.phone_e164_hash Until phone removed Peppered with PHONE_HASH_PEPPER env var to defeat rainbow-table on the small E.164 keyspace
Last 2 digits string Derived from E.164 Legitimate interest (display hint in portal + admin) account_phones.phone_last_digits Until phone removed Plaintext — low-sensitivity (needle-in-haystack against keyspace)
isPrimary / verifiedAt / mfaEnabled flags User actions in portal Consent account_phones.* Until phone removed Partial-unique-index enforces one-primary-per-account; CHECK constraint ensures mfa_enabled requires verified_at not null

Account-portal security settings

Per-account opt-in security features. Schema: account-security.schema.ts and security fields on accounts.

Field Type Source Lawful basis Storage Retention Notes
totpSecret (encrypted) string TOTP setup flow generates Consent (MFA opt-in) accounts.totp_secret (encrypted via FieldCryptoService) Until user disables TOTP Plaintext secret never persisted
totpEnabled boolean User toggle Consent accounts.totp_enabled Account lifetime
recoveryCodes hashed string[] Generated at TOTP setup Consent accounts.recovery_codes (JSONB; SHA-256 hashes) Until regenerated or TOTP disabled Plaintext codes shown once at setup; only hashes stored
mfaEnforceOnOauth boolean User toggle Consent accounts.mfa_enforce_on_oauth Account lifetime
loginNotifications boolean User toggle Consent accounts.login_notifications Account lifetime Default true; user can opt out
Geo rule mode + countries[] User input via portal Consent account_geo_rules.* Until user removes Mode allow or restrict; ISO-3166-1 alpha-2 codes
Allowed IPs name + type + IP/range User input via portal Consent account_allowed_ips.* Until user removes Types: current / static / range
App passwords name + hash + lastUsedAt User-generated in portal Consent (alternative auth for apps without TOTP support) account_app_passwords.* Until user revokes bcrypt hash; plaintext shown once at creation

KYC / verified-identity data

Schema: kyc-verifications.schema.ts. Written by the KYC-running product (today: ZevPay) via POST /v1/internal/kyc-report; ZevID stores the canonical outcome so every product can read it without re-running KYC.

Field Type Source Lawful basis Storage Retention Notes
status enum KYC-running product report Legal obligation (NDPC GAID + CBN AML/CFT) kyc_verifications.status 7 years post-account-deletion (CBN AML retention; confirmed by deployment owner). Currently no automatic purge mechanism — see retention.md for the implementation gap One of none / pending / verified / rejected
verifiedFirstName / verifiedLastName string KYC verification result Legal obligation kyc_verifications.verified_*_name Same as status Authoritative legal name post-KYC
dateOfBirth date KYC verification result Legal obligation kyc_verifications.date_of_birth Same as status
bvnHash SHA-256 hex Derived from BVN at verification time Legal obligation kyc_verifications.bvn_hash (varchar(128)) Same as status Plaintext BVN is NEVER stored — only the hash. The hash is one-way; we can prove "this user has BVN X" but cannot reverse to BVN
ninHash SHA-256 hex Derived from NIN at verification time Legal obligation kyc_verifications.nin_hash Same as status Same hash-only treatment as BVN
faceMatch boolean KYC verification result Legal obligation kyc_verifications.face_match Same as status True iff KYC provider returned a passing face-match
verifiedBy string clientId of reporting product Legal obligation kyc_verifications.verified_by Same as status Typically zevpay
verifiedAt timestamp Server-set at write time Legal obligation kyc_verifications.verified_at Same as status

Behavioural / activity data

Authentication events + active sessions. Used for security forensics, "Connected Apps & Activity" portal view, and admin investigation.

login_events

Schema: login-events.schema.ts. The account-security event log.

Field Type Source Lawful basis Storage Retention Notes
eventType string Server-set at event time Legitimate interest (security) login_events.event_type 90 days (cleanup.service.ts:67-71 — daily 3am cron). Confirmed by deployment owner Values include: signup_initiated, otp_sent, login_success, login_failed, login_blocked_geo, login_blocked_ip, password_changed, product_authorized, phone_added, phone_verified, phone_removed, phone_set_primary, phone_mfa_enabled, phone_mfa_disabled, phone_reverification_requested, phone_reverification_confirmed
ipAddress string (45) Captured from request Legitimate interest (security) login_events.ip_address 90 days Includes IPv4 + IPv6
userAgent string (512) Captured from request Legitimate interest (security) login_events.user_agent 90 days
productHint string (30) Set at auth flow Legitimate interest login_events.product_hint 90 days Which OAuth client triggered the event (portal, zevpay, zevcloud, etc.)
details JSONB Event-specific context Legitimate interest login_events.details 90 days Free-form per event type (e.g. phone events carry phoneId, phoneHint)

sessions

Schema: sessions.schema.ts. Active refresh-token sessions.

Field Type Source Lawful basis Storage Retention Notes
tokenHash SHA-256 Derived from refresh token Contract sessions.token_hash (unique) Until session expiry (then cleanup cron sweeps) Plaintext refresh token NEVER persisted
previousTokenHash SHA-256 Previous-rotation token hash Contract sessions.previous_token_hash Same Refresh-token rotation with reuse-detection is enabled in production
clientId string OAuth client the session was issued for Contract sessions.client_id Same Becomes the aud claim on identity tokens
userAgent / ipAddress / location / deviceName / platform / browser / os strings Parsed from request UA + IP geolocation Contract + Legitimate interest sessions.* Same UA parsed via ua-parser-js; IP geolocated via ip-api.com (cached)
appVersion string Caller-supplied Contract sessions.app_version Same Used by mobile clients
lastActiveAt / expiresAt / createdAt timestamps Server-set Contract sessions.* Same Cleanup cron deletes rows where expires_at < now() daily

Device / network data

Captured inline with sessions + login events above. ZevID does not run a separate device-fingerprinting product — the userAgent + ipAddress + parsed platform/browser/os are the entire signal set. No third-party device-ID service is involved.

Field Where it lives
IP address (login + session events) login_events.ip_address, sessions.ip_address
User-Agent string login_events.user_agent, sessions.user_agent
Parsed device descriptors (platform, browser, os, deviceName) sessions.*
IP-derived location (city, country) sessions.location

Retention same as host row (login_events: 90 days; sessions: until expiry).


Communications

System-generated transient messages (OTPs) and invitations.

OTPs

Schema: otps.schema.ts. One-time codes for email + SMS verification.

Field Type Source Lawful basis Storage Retention Notes
code string (10) Server-generated 6-digit Contract (auth) otps.code Deleted on expiry (10-min TTL) by daily cleanup cron Per-row attempts counter caps at maxAttempts (default 5)
channel enum Set at generation Contract otps.channel Same email (default) or sms
purpose string (100) Set at generation Contract otps.purpose Same Namespaced: login, phone_verification:<phoneId>, mfa_sms:<phoneId>, etc.
attempts / maxAttempts / expiresAt / usedAt counters/timestamps Server-managed Contract otps.* Same Account-level lockout layered on via LockoutService

invites

Schema: invites.schema.ts. Product-issued invitations (e.g. ZevCommerce inviting a teammate).

Field Type Source Lawful basis Storage Retention Notes
email string Inviter input Consent (the inviting product collected and forwarded) invites.email Retained for audit; rows expire via the status=expired state but row stays on file Email is the invitee, not the inviter; inviter identity lives in context JSONB
clientId string Inviting product's clientId Contract invites.client_id Same
tokenHash SHA-256 Derived from signed invite token Contract invites.token_hash (unique) Same Plaintext token sent to user via email; only hash persisted
accountId (nullable) UUID Linked when the invitee accepts Contract invites.account_id Same Null until acceptance
context JSONB Inviting product supplies role/team info Consent invites.context Same Opaque to ZevID; relayed back at accept time
status enum Lifecycle managed by ZevID Contract invites.status Same pending / accepted / expired / revoked

Cross-product enrollment data

Schema: product-enrollments.schema.ts. One row per (user × product); kept in sync by each product calling PUT /v1/internal/users/:accountId/enrollments/:productClientId.

Field Type Source Lawful basis Storage Retention Notes
product string Product's clientId Contract product_enrollments.product Account lifetime; isActive flag flips false on de-enrollment One of the registered oauth_clients.client_id values
productUserId (nullable) string Product-defined identifier Contract product_enrollments.product_user_id Same Product's internal id for the user (e.g. ZevPay's wallet id)
tier (nullable) string Product-defined tier Contract product_enrollments.tier Same Opaque to ZevID; e.g. personal_kyc1, business_kyc2, merchant
metadata JSONB Product-defined sub-entities Contract product_enrollments.metadata Same Opaque to ZevID; conventional shape: arrays of owned entities with { id, name, ... }
enrolledAt / updatedAt / isActive timestamps + flag Server-managed at write time Contract product_enrollments.* Same

Cross-product permissions and the audit trail of every cross-product token issued. Schema: zpip-tokens.schema.ts.

Pending consent screens. 10-min TTL; terminal-state rows retained for audit. See retention.md.

Field Type Source Lawful basis Storage Retention Notes
callerClientId string Service-key calling token-exchange Contract zpip_consent_requests.caller_client_id Retained for audit The product requesting consent
scopes text[] Caller-supplied Contract zpip_consent_requests.scopes Indefinite All scopes in one batch share owner-product
accountId UUID Caller-supplied; FK to accounts Contract zpip_consent_requests.account_id Indefinite
grantedScopes / grantIds text[] / uuid[] Set on approval (subset of scopes) Consent zpip_consent_requests.granted_scopes, .grant_ids Indefinite Empty on Deny
returnTo URL Caller-supplied; validated against oauth_clients.redirect_uris Contract zpip_consent_requests.return_to Indefinite Subject to redirect-URI allowlist
status / approvedAt / deniedAt / expiresAt / createdAt enum + timestamps Lifecycle managed Contract zpip_consent_requests.* Indefinite pending / approved / denied / expired

zpip_scope_grants

Active user-granted scopes; the canonical "user X has consented for product Y to do Z."

Field Type Source Lawful basis Storage Retention Notes
accountId / callerClientId / scope UUID + strings From consent approval Consent (NDPA §25(1)(a)) zpip_scope_grants.* (unique on the triple) Until user revokes; revoked rows kept for audit (revoked_at set, row retained) Per-scope row; batched consent inserts N rows
grantedAt / revokedAt / expiresAt timestamps Lifecycle Consent zpip_scope_grants.* Indefinite revokedAt set by user-driven revoke from Connected Apps
metadata JSONB Captured at consent time Legitimate interest (audit) zpip_scope_grants.metadata Indefinite Currently stores userAgent of consent action

zpip_token_log

Every cross-product token issued. Replay-protection + audit.

Field Type Source Lawful basis Storage Retention Notes
jti UUID Server-generated per token Legitimate interest (replay protection + audit) zpip_token_log.jti (unique) Retained for audit (CBN 7-year retention applies to financial-record-adjacent rows)
callerClientId / audience / actForAccountId / scope / consentId / issuedAt / expiresAt strings + UUID + timestamps Server-set at issuance Legitimate interest zpip_token_log.* Same actForAccountId null for service-only tokens

Financial data (Zev Credit ledger)

ZevID owns the centralised "Zev Credit" ledger — non-withdrawable spending power. Subject to financial-record retention.

Schema: credits.schema.ts. Four tables: credit_grants, credit_transactions, credit_transaction_lines, credit_idempotency.

Field group Source Lawful basis Storage Retention Notes
Grant rows: accountId, subAccount, currency, amountGranted, remainingAmount, source, scopeType/scopeValue, expiresAt, status, issuedByType/Id, idempotencyKey, metadata Server + product calls Legal obligation (financial records, CBN 7-year retention) credit_grants.* Retained for CBN 7-year financial-record obligation. ON DELETE RESTRICT on accounts — hard delete refused while grants exist Source: referral / promo / prepayment / refund / goodwill / signup_bonus
Transaction headers: id, type, actorClientId, currency, amount, reason, idempotencyKey, createdAt Cascade engine Legal obligation credit_transactions.* Same Type: debit / reversal
Transaction lines: header link + grant link + amount Cascade engine Legal obligation credit_transaction_lines.* Same One line per drained grant; double-entry
Idempotency: (scope, key) Server Legal obligation credit_idempotency.* Same Stable replay responses

Referrals

Schema: referrals.schema.ts. Two tables: referral_codes, referrals.

Field Type Source Lawful basis Storage Retention Notes
referralCodes.code string (30, unique) User-created or admin-created Consent (user shared their code) referral_codes.code Account lifetime; isActive flag for soft disable Codes can be scoped to one or more products
referrals.referrerId / referredAccountId UUIDs Captured at referred user's signup Consent + Legitimate interest (referral-reward tracking) referrals.* Account lifetime
sourceProduct / method / status strings Lifecycle Contract referrals.* Same Method: referral_link or promo_code

Special categories (NDPA Sensitive Personal Data)

Not applicable — ZevID itself processes no special-category data (health, biometric, racial/ethnic, political, religious, sexual-orientation, genetic).

Edge note: the face-match boolean in kyc_verifications.face_match is a derived signal, not biometric data. ZevID stores only the boolean outcome; the underlying face image lives at the KYC provider (currently ZevPay's KYC vendor — see ZevPay's section). If ZevID ever starts storing biometric templates directly, this section must be updated and the DPO consulted before merge.


Future changes

None currently planned. When fields are added, the matching schema migration PR must include the row update here and an entry in change-log.md.