Developer Docs

Malet Vaults & Verification โ€” Developer Guide

Overview

The Malets subgraph provides two interconnected trust-and-safety systems for Malet Owners:

  1. Settings Vault: AES-256-GCM encrypted storage for sensitive merchant configuration (API keys, webhook secrets) with masked GraphQL read-only output.
  2. Automated Verification: A state-machine workflow that guides Malet Owners through a 4-step admin verification process before their Malet receives "verified" status.
Component Purpose Access
MaletSettings Encrypted embedded document on the Malet entity Owner-only (write), masked (read)
MaletSettingsView GraphQL-safe view with masked sensitive values Owner-only
VerificationWorkflow State machine tracking 4-step admin checklist Owner (read), Admin (write)
VerificationService Submit, advance-step, auto-verify/reject logic Internal

Settings Vault Architecture

graph TD
    A["Malet Owner"] -->|"updateMaletSettings"| B["Resolver"]
    B --> C{"Merge fields"}
    C --> D["Mongoose pre-save hook"]
    D --> E["AES-256-GCM Encryption"]
    E --> F[("MongoDB: encrypted at rest")]

    A -->|"maletSettings"| G["Resolver"]
    G --> H["Decrypt sensitive fields"]
    H --> I["maskSensitiveValue()"]
    I --> J["MaletSettingsView (โ€ขโ€ขโ€ขโ€ขโ€ขโ€ข7890)"]

    style E fill:#ef4444,color:#fff
    style I fill:#f59e0b,color:#fff
    style F fill:#3b82f6,color:#fff

Sensitive vs Non-Sensitive Fields

The vault distinguishes between fields that require encryption and those stored in plain text:

Field Type Encrypted GraphQL Output
paymentGatewayKey Sensitive โœ… AES-256-GCM โ€ขโ€ขโ€ขโ€ขโ€ขโ€ข7890
webhookSecret Sensitive โœ… AES-256-GCM โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขcret
customApiKey Sensitive โœ… AES-256-GCM โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขkey1
notificationEmail Non-sensitive โŒ Plain text owner@malet.com
autoFulfillment Non-sensitive โŒ Plain text true
orderPrefix Non-sensitive โŒ Plain text "ORD-"

Encryption Flow

// Sensitive fields constant
const SETTINGS_SENSITIVE_FIELDS = ['paymentGatewayKey', 'webhookSecret', 'customApiKey'] as const;

// Mongoose pre-save hook on the Malet entity:
// 1. For each sensitive field in settings
// 2. If the value is present and NOT already encrypted
// 3. Encrypt with AES-256-GCM using ENCRYPTION_KEY env var
// 4. Store the IV + authTag + ciphertext as a single string

Masking Logic

Sensitive values are never returned in plain text through GraphQL. The maskSensitiveValue() utility:

function maskSensitiveValue(decrypted: string | undefined): string | null {
	if (!decrypted) return null;
	if (decrypted.length <= 4) return 'โ€ขโ€ขโ€ขโ€ข';
	return 'โ€ขโ€ขโ€ขโ€ขโ€ขโ€ข' + decrypted.slice(-4);
}

// "sk_live_abc123xyz7890" โ†’ "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ข7890"
// "wh_sec"                โ†’ "โ€ขโ€ขโ€ขโ€ข"
// undefined               โ†’ null

Per-Field Merge Update

The updateMaletSettings mutation uses per-field merging rather than full replacement. This prevents the client from accidentally re-encrypting already-encrypted values:

mutation {
	updateMaletSettings(
		maletId: "malet-123"
		input: { paymentGatewayKey: "sk_live_new_key_here", autoFulfillment: true }
	) {
		paymentGatewayKey # "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขhere"
		autoFulfillment # true
		notificationEmail # unchanged from previous value
	}
}

Verification State Machine

Statuses

The verification workflow tracks these 6 statuses:

stateDiagram-v2
    [*] --> PENDING
    PENDING --> DOCUMENTS_REQUIRED: submitForVerification
    DOCUMENTS_REQUIRED --> UNDER_REVIEW: first step approved
    UNDER_REVIEW --> UNDER_REVIEW: subsequent steps approved
    UNDER_REVIEW --> VERIFIED: all 4 steps approved
    DOCUMENTS_REQUIRED --> REJECTED: any step rejected
    UNDER_REVIEW --> REJECTED: any step rejected
    REJECTED --> DOCUMENTS_REQUIRED: resubmit
    VERIFIED --> SUSPENDED: admin action
Status Description
PENDING Default state โ€” Malet Owner has not yet submitted
DOCUMENTS_REQUIRED Submitted โ€” awaiting admin review of first checklist item
UNDER_REVIEW At least one step approved, awaiting remaining
VERIFIED All 4 steps approved โ€” Malet is verified โœ…
REJECTED Any single step rejected โ€” short-circuit failure
SUSPENDED Admin manually suspended a verified Malet

4-Step Checklist

Every submission initialises a checklist of 4 steps, each reviewed independently by an admin:

# Step Purpose Order
1 IDENTITY_CHECK Verify Malet Owner's identity (ID document, selfie) First
2 BUSINESS_DOCS Verify business registration, tax IDs, licenses Second
3 COMPLIANCE_REVIEW Platform policy adherence, content review Third
4 FINAL_APPROVAL Final sign-off by senior admin Last

Each step has its own lifecycle:

interface VerificationChecklistItem {
	step: VerificationStep; // IDENTITY_CHECK | BUSINESS_DOCS | ...
	status: ChecklistItemStatus; // PENDING | APPROVED | REJECTED
	note?: string; // Reviewer's comment
	completedAt?: Date; // When reviewed
	reviewedBy?: string; // Admin user ID
}

Key Behaviours

Auto-verify: When all 4 steps are APPROVED, the workflow automatically transitions to VERIFIED. No separate "approve all" admin action is needed.

Rejection short-circuit: If any single step is REJECTED, the entire workflow transitions to REJECTED immediately โ€” remaining steps are not evaluated.

Resubmission: A REJECTED Malet can resubmit via submitForVerification, which resets the checklist to all-PENDING.

Audit trail: Every status transition is recorded in the history array with fromStatus, toStatus, reason, changedBy, and changedAt.


GraphQL API

Mutations

# Malet Owner submits their Malet for verification
mutation {
	submitForVerification(maletId: "malet-123") {
		id
		verificationWorkflow {
			status
			currentStep
			checklist {
				step
				status
			}
		}
	}
}

# Admin advances a verification step
mutation {
	advanceVerificationStep(
		maletId: "malet-123"
		step: IDENTITY_CHECK
		approved: true
		note: "ID verified via government database"
	) {
		verificationWorkflow {
			status # UNDER_REVIEW (or VERIFIED if all done)
			checklist {
				step
				status
				note
				reviewedBy
				completedAt
			}
			history {
				fromStatus
				toStatus
				reason
				changedBy
				changedAt
			}
		}
	}
}

Queries

# Malet Owner views their settings (masked)
query {
	maletSettings(maletId: "malet-123") {
		paymentGatewayKey # "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ข7890"
		webhookSecret # "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขcret"
		customApiKey # null (not set)
		notificationEmail # "owner@example.com"
		autoFulfillment # true
		orderPrefix # "ORD-"
	}
}

# View verification workflow
query {
	verificationStatus(maletId: "malet-123") {
		status
		currentStep
		submittedAt
		reviewedBy
		reviewedAt
		checklist {
			step
			status
			note
			completedAt
		}
		history {
			fromStatus
			toStatus
			reason
			changedAt
		}
	}
}

Security Considerations

Encryption Key Management

Concern Approach
Algorithm AES-256-GCM (authenticated encryption)
Key source ENCRYPTION_KEY environment variable
Key rotation Not yet implemented โ€” planned as future enhancement
IV (nonce) Randomly generated per encryption, stored alongside ciphertext
Auth tag Verified on decryption to detect tampering

Access Control

Operation Guard Who
updateMaletSettings Owner guard (x-org-id) Malet Owner only
maletSettings query Owner guard Malet Owner only
submitForVerification Owner guard Malet Owner only
advanceVerificationStep Admin guard Platform admins only

Legacy Compatibility

The Malet.verificationStatus string field ('pending', 'verified', 'rejected') is kept in sync with the new workflow status. This ensures existing queries and federation references continue to work without migration.


Environment Variables

Variable Default Description
ENCRYPTION_KEY โ€” Required. 32-byte hex key for AES-256-GCM

Generate a key:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Module Structure

apps/malets/src/malet/
โ”œโ”€โ”€ malet.entity.ts                # Core Malet entity with settings + workflow
โ”œโ”€โ”€ malet-settings.entity.ts       # MaletSettings, MaletSettingsView, mask helpers
โ”œโ”€โ”€ verification.types.ts          # Enums, checklist items, workflow type, defaults
โ”œโ”€โ”€ verification.service.ts        # Submit + advance step logic
โ”œโ”€โ”€ verification.service.spec.ts   # State machine unit tests
โ”œโ”€โ”€ malet-query.resolver.ts        # maletSettings, verificationStatus queries
โ”œโ”€โ”€ malet-crud.resolver.ts         # updateMaletSettings, submit/advance mutations
โ”œโ”€โ”€ dto/
โ”‚   โ””โ”€โ”€ create-malet.input.ts      # Settings input type
โ””โ”€โ”€ malet.module.ts                # Module wiring

Testing

Unit Tests

# Run malets unit tests (91 tests across 6 suites)
npm run test -- apps/malets --no-coverage

Key coverage:

  • VerificationService: submit flow, step approval, auto-verify on all-approved, rejection short-circuit, resubmission, guard rails (already verified, unknown step, already processed step)
  • MaletQueryResolver: settings masking, verification workflow query
  • MaletCrudResolver: settings merge update, encryption integration

E2E Tests

npx jest --config apps/malets/test/jest-e2e.json --forceExit