Skip to content

Architecture

Overview

Activation System is a Saudi Arabia telecom SIM activation platform built as a monorepo with Turborepo. It handles the full SIM lifecycle from ICCID validation through identity verification to payment — compliant with CITC regulations.

Monorepo Structure

activation-sys/
├── apps/
│   ├── api/          # Hono API server (Node.js)
│   ├── admin/        # Next.js admin and B2B portal
│   ├── web-b2c/      # Next.js B2C web activation flow
│   ├── site/         # Vite + React public landing page
│   └── mobile/       # Flutter mobile app (B2C)
├── packages/
│   ├── database/     # Drizzle ORM + PostgreSQL schemas
│   ├── shared/       # Types, Zod schemas, constants, errors
│   └── queue/        # BullMQ workers (Redis-backed)
├── docs/             # VitePress documentation site
└── docker-compose.yml

System Architecture

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│ Flutter/Mobile │   │ Next.js Web  │     │  3rd Party   │
│ + B2C Web    │     │ Admin/B2B    │     │  Webhooks    │
└──────┬───────┘     └──────┬───────┘     └──────┬───────┘
       │                    │                     │
       └────────────────────┼─────────────────────┘

                   ┌────────▼────────┐
                   │   Hono API      │
                   │   (REST + CORS) │
                   └────────┬────────┘

          ┌─────────────────┼─────────────────┐
          │                 │                  │
   ┌──────▼──────┐  ┌──────▼──────┐  ┌───────▼──────┐
   │ PostgreSQL   │  │   Redis      │  │  Operator     │
   │ (Supabase)  │  │  (BullMQ)    │  │  Plugins      │
   │             │  │             │  │  (STC/Mobily/ │
   │ PII Encrypted│  │ 3 Queues:   │  │   Zain)       │
   │ RLS Policies │  │ • activation│  │               │
   └─────────────┘  │ • payment   │  └───────┬──────┘
                     │ • notif      │          │
                     └─────────────┘          │
                              ┌───────────────▼───────────────┐
                              │     External APIs              │
                              │  Absher/Elm │ Payment Gateways │
                              └───────────────────────────────┘

Key Design Decisions

DecisionRationale
Hono over ExpressType-safe, lightweight, edge-ready, native Zod validation
Drizzle over PrismaSQL-first, better RLS support, transparent PII encryption via custom types
BullMQ over Bull v3Active maintenance, better TypeScript, namespace-prefixed queues
SupabaseManaged PostgreSQL + RLS + Auth (Phase 2)
AES-256-GCM encryptionAuthenticated encryption for PII at rest, key rotation support
Plugin pattern for operatorsDecoupled operator logic — new operators via OperatorPlugin interface

Data Flow: SIM Activation

1. User submits ICCID → Validate (19 digits, CITC format)
2. Check SIM limits per identity type (citizen: 10, resident: 2, visitor: 1)
3. Create order → Create payment intent
4. Process payment (STC Pay / Apple Pay / Mada)
5. Queue activation job → Operator plugin initiates activation
6. Identity verification (Absher/Elm)
7. Activation complete → Notify user

Security

  • PII Encryption: Phone, email, name, ID number encrypted with AES-256-GCM via ENCRYPTION_KEY
  • Row Level Security: PostgreSQL RLS policies per domain (auth, catalog, activation, payment, support, b2b)
  • Rate Limiting: 100 req/15min per IP (Redis-backed in production)
  • Bilingual Errors: All error responses include messageEn and messageAr
  • Audit Trail: Immutable audit logs with auditImmutabilityTrigger
  • Idempotency: Payment intents require idempotencyKey for safe retries

Middleware Chain

The Hono API applies middleware in a strict order. Each middleware runs on every request before the route handlers execute:

logger → cors → error-handler → language → rate-limit → auth → routes
OrderMiddlewareSourcePurpose
1logger()hono/loggerStructured request/response logging
2cors()hono/corsCORS with environment-specific origins
3errorHandlerMiddlewaresrc/middleware/error-handler.tsGlobal error catch → bilingual JSON
4languageMiddlewaresrc/middleware/language.tsExtract Accept-Languagelanguage + clientType context
5rateLimitMiddleware()src/middleware/rate-limit.ts100 req/15min per IP, sets X-RateLimit-* headers
6authMiddlewaresrc/middleware/auth.tsJWT verification, Redis blocklist check, set userId/appRole/identityType

CORS origins:

Allowed origins are configured through CORS_ORIGINS as a comma-separated list. Development defaults to http://localhost:3000, http://localhost:3001, and http://localhost:8888; production must set real owned origins in the deployment environment.

Allowed headers: Content-Type, Authorization, Accept-Language. Allowed methods: GET, POST, PUT, PATCH, DELETE, OPTIONS.

API Route Map

MethodPathAuthDescription
GET/healthNoneHealth check (API, database, Redis connectivity)
POST/auth/registerNoneRegister new user, send OTP
POST/auth/loginNoneLogin, send OTP to existing user
POST/auth/verify-otpNoneVerify OTP, create session (dual strategy: cookie for web, JWT for mobile)
POST/auth/logoutRequiredClear session (web: delete cookie, mobile: Redis blocklist)
GET/auth/sessionRequiredReturn current session user from JWT
GET/auth/meRequiredReturn user profile with decrypted PII
PUT/auth/meRequiredUpdate profile (name, email; phone via /change-phone)
POST/auth/change-phoneRequiredSend OTP to new phone number
GET/operatorsNoneList active operators with Arabic/English names
GET/packagesNoneList packages with filters (operator, data, voice, validity, promotional)
GET/packages/searchNoneFull-text + fuzzy trigram search across packages
GET/packages/compareNoneSide-by-side comparison (2–5 package IDs)
GET/packages/:idNoneSingle package detail with operator info
GET/packages/:id/availabilityNoneReal-time availability via operator plugin (Redis cache, 30s TTL)
POST/activations/validate-iccidNoneValidate ICCID via operator plugin (public pre-check)
POST/activationsRequiredCreate activation order with package selection
POST/activations/:id/verify-identityRequiredInitiate Nafath identity verification
GET/activations/:id/verify-identityRequiredCheck verification status (5 Nafath states)
GET/activations/:id/statusRequiredActivation + identity status with estimated timing
GET/activations/historyRequiredPaginated activation history (keyset cursor)
GET/activations/recordssuper_adminAdmin dashboard activation records with filters and related order/package/operator/user contact data
POST/payments/create-intentRequiredCreate payment intent (Mada, Visa, Mastercard)
POST/payments/apple-payRequiredCreate Apple Pay payment
POST/payments/stcpayRequiredInitiate STC Pay direct payment
GET/payments/:id/receiptRequiredOwner-scoped receipt with bilingual messages
GET/profileRequiredReturn authenticated user profile aggregate
PUT/profileRequiredUpdate allowed profile fields (name, email)
GET/profile/exportRequiredPDPL data portability export
DELETE/profileRequiredRequest account deletion (soft delete + retention period)
POST/compliance/retention/sweepsuper_adminExecute PDPL retention sweep
POST/compliance/breach-reportsuper_adminGenerate SDAIA breach notification
POST/webhooks/stripeNone*Handle Stripe webhook events (*Stripe signature verification)

Auth model notes:

  • Public routes skip JWT verification entirely (PUBLIC_PATHS list in auth.ts)
  • Protected routes require JWT from httpOnly cookie (web) or Authorization: Bearer header (mobile)
  • Compliance routes add a second middleware layer requiring appRole === 'super_admin'
  • Webhook routes verify Stripe-Signature header instead of JWT

Plugin Architecture

The API uses a plugin pattern to decouple external service integrations. Each provider category has its own interface, manager class, and concrete implementations. Managers use a Map<string, Interface> registry pattern — the startup code registers a Mock provider by default, then conditionally registers production providers based on environment variables.

OperatorPlugin (apps/api/src/plugins/operator-plugin.ts)

The core interface for telecom operator integration. Each operator (STC, Mobily, Zain) implements these 6 domain operations:

MethodPurpose
getPackages(filters?)Browse operator packages with optional filters
validateICCID(iccid)Validate SIM card serial number via operator API
initiateActivation(params)Start SIM activation on operator network
checkActivationStatus(activationId)Poll current activation status
verifyIdentity(params)Initiate Nafath identity verification
checkVerificationStatus(transId)Check verification transaction status
healthCheck()Verify operator API reachability

PluginManager (plugin-manager.ts) holds Map<string, OperatorPlugin>. Routes call pluginManager.get(operatorId).method(). Unregistered operators throw NotFoundError.

Implementations:

PluginFileEnvironment
MockOperatorPluginmock-operator.tsDefault (development/test)
STC, Mobily, ZainFuturePAYMENT_PROVIDER=stripe + operator credentials

PaymentProvider (apps/api/src/plugins/payment-provider.ts)

MethodPurpose
createPaymentIntent(input)Create card-like payment intent via gateway

Separate StcPayProvider interface for STC Pay direct flow (initiateStcPay(input)).

ProviderFileWhen Active
MockPaymentProvidermock-payment-provider.tsDefault
StripePaymentProviderstripe-payment-provider.tsPAYMENT_PROVIDER=stripe
MockStcPayProvidermock-stcpay-provider.tsDevelopment/test

SmsProvider (apps/api/src/plugins/sms-plugin.ts)

MethodPurpose
sendOtp(phone, code)Deliver OTP SMS to phone number
sendInvite(phone, content)Deliver a one-off bilingual invitation carrying the portal login link. Best-effort — resolves to whether it was actually delivered
healthCheck()Verify provider API reachability

SmsProviderManager mirrors PluginManager pattern. Provider selected via SMS_PROVIDER env var.

ProviderFileWhen Active
MockSmsProvidermock-sms-provider.tsDefault
TwilioSmsProvidertwilio-sms-provider.tsSMS_PROVIDER=twilio
TaqnyatSmsProvidertaqnyat-sms-provider.tsSMS_PROVIDER=taqnyat

EmailProvider (apps/api/src/plugins/email-plugin.ts)

MethodPurpose
sendOtp(email, code, language?)Deliver OTP email with bilingual subject
sendInvite(email, content)Deliver a one-off invitation/notice email (admin invite, company approval, magic-link QR)
healthCheck()Verify provider API reachability

EmailProviderManager mirrors SmsProviderManager. The active provider is admin-configurable (plugin_settings row via EmailProviderSettingsService), not an env var — mock and smtp are implemented; resend and sendgrid are recognized provider ids with no live implementation yet.

ProviderFileWhen Active
MockEmailProvidermock-email-provider.tsDefault
SmtpEmailProvidersmtp-email-provider.tsAdmin selects smtp in plugin settings

Shared email shell (apps/api/src/email/shell.ts, templates.ts): every outbound email is composed from the same primitives — a dark header with the Skyte wordmark and cyan beacon bar, an optional bilingual status chip, a bilingual body split by a dotted divider, a CTA button, and one of three footer variants. Six composers render through this shell: activationLinkEmail (bilingual, app-store links, QR code attachment), adminInviteEmail, companyApprovedEmail, readinessEmail, testEmail, and otpEmail. Each returns { subject, body|text, html } (activationLinkEmail also returns attachments); the plain-text half is the multipart fallback for clients that don't render HTML.

Database Schema

27 tables across 8 domain groups, defined in packages/database/src/schema/. Each domain file exports tables, relations, RLS policy SQL, and domain-specific infrastructure (triggers, indexes).

Auth Domain (schema/auth.ts)

TablePrimary KeyKey ColumnsNotes
usersid (uuid)phone (encrypted), phoneHash, email (encrypted), emailHash, name (encrypted), idNumber (encrypted), identityType, appRoleSingle table for all roles. appRole is dynamic text (end_user, b2b_admin, super_admin, support, activation_officer, admin). Hash columns for deterministic lookups.
sessionsid (uuid)userId, tokenHash, ipAddress (inet), userAgent, clientType, revokedAtSupplements Supabase Auth JWT sessions with metadata tracking.

Catalog Domain (schema/catalog.ts)

TablePrimary KeyKey ColumnsNotes
operatorsid (uuid)slug (unique), nameEn, nameAr, isActive, serviceType, paymentMode, isNameArFallback, apiBaseUrl, apiConfigBilingual display names (rarely change). Slug: stc, mobily, zain. eSIM store providers are rows in this table with serviceType='esim'.
packagesid (uuid)operatorId (FK), nameEn, nameAr, descriptionEn, descriptionAr, searchVector (tsvector), priceSar, priceSarB2b, isNameCustomized, isPriceCustomized, isActiveCustomizedFull-text search via GIN index + pg_trgm fuzzy fallback. Admin customization flags protect overrides from sync.

Activation Domain (schema/activation.ts)

TablePrimary KeyKey ColumnsNotes
ordersid (uuid)userId (FK), companyId (FK, nullable), status, totalAmount, currencycurrency is 'SAR' or 'USD' (default 'SAR'). companyId set for B2B orders only.
order_itemsid (uuid)orderId (FK), packageId (FK), iccid, unitPriceSarOne order item per SIM + package selection.
activationsid (uuid)orderItemId (FK), userId (FK), operatorId (FK), iccid, packageId (FK), status, identityStatus, identityRef, assignedOfficerId, retainUntil, deletedAtCore activation record. Retention fields for PDPL compliance. Status transitions tracked in activation_status_events.
identity_verificationsid (uuid)activationId (FK), transId, randomCode, status, verifiedAtNafath transaction tracking. 5 states: waiting, completed, rejected, expired, error.
activation_status_eventsid (uuid)activationId (FK), userId (FK), oldStatus, newStatus, source, noteImmutable audit trail for every activation status transition. Source: system, user, officer, operator_api.

Payment Domain (schema/payment.ts)

TablePrimary KeyKey ColumnsNotes
paymentsid (uuid)orderId (FK), userId (FK), amount, currency, method, status, idempotencyKey (unique), gatewayTransactionId, gatewayResponse, receiptNumber (unique)Methods: mada, apple_pay, credit_card, stc_pay. Idempotency key prevents double-charging.
payment_webhook_eventsid (uuid)gateway, eventId (unique composite), eventType, paymentId (FK), processedAt, payloadDeduplication: (gateway, eventId) unique index. Gateway: stripe, mock.
invoicesid (uuid)companyId (FK), orderId (FK), amountSar, zatcaInvoiceId, pdfUrl, statusB2B only. ZATCA e-invoicing integration.

FX Domain (schema/fx.ts)

TablePrimary KeyKey ColumnsNotes
fx_ratescurrency (text)ratePerUsd (numeric 14,6), fetchedAt, sourceUSD-base rate table fed by a provider adapter. Deny-all RLS.

Store Domain (schema/store.ts)

TablePrimary KeyKey ColumnsNotes
esim_productsid (uuid)providerSlug, operatorId (FK), providerSku, nameEn, nameAr, destinationScope, destinationCountries, validityValue, wholesaleUsd, retailUsd, isActive, isNameCustomized, isPriceCustomized, isActiveCustomized, source*, providerPayloadGlobal eSIM catalog synced from the provider. Catalog prices are base USD. Customization flags protect admin overrides from the sync. The source* columns keep the provider's current values. providerPayload is opaque adapter metadata persisted at sync and handed back at issuance.
esim_store_ordersid (uuid)orderId (FK, unique), userId (FK), productId (FK), presentmentCurrency, presentmentAmount, fxRateUsed, retailUsdSnapshot, smdpAddress, matchingId, activationCodeRaw, iccid (partial unique when set), issuedAt, failedReason, issuanceState, externalOrderId, verificationUrl, retainUntil, deletionRequestedAt, deletedAtOne row per store purchase. Presentment currency, amount and FX rate are snapshotted at order time. matchingId and activationCodeRaw are encrypted. issuanceState + externalOrderId drive the provider-agnostic fulfillment state machine (in-flight stamps, reconciliation, async KYC verification). A partial unique index refuses the same issued ICCID on two orders. Retention fields for PDPL compliance — the sweep nulls the eSIM payload columns and stamps deletedAt, keeping the row as the financial record of the purchase.

Both tables are API-only: no PostgREST or realtime client access, locked with deny-all client policies.

Support Domain (schema/support.ts)

TablePrimary KeyKey ColumnsNotes
ticketsid (uuid)userId (FK), subject, description, status, priority, assignedTo, activationId (FK, nullable)Linked to activation for context. Priorities: low, medium, high, critical.
ticket_commentsid (uuid)ticketId (FK), authorId (FK), content, isInternalInternal notes not visible to end users (RLS-enforced).

B2B Domain (schema/b2b.ts)

TablePrimary KeyKey ColumnsNotes
companiesid (uuid)crNumber (unique), name, contactName, contactEmail, contactPhone (encrypted), status, paymentMode (nullable), approvedByCommercial registration number is unique. contactPhone is encrypted PII. paymentMode is the company's own pre-contract pick (prepaid/flexible, null until chosen), inherited by the contract at confirm time.
company_usersid (uuid)companyId (FK), userId (FK), roleMany-to-many between companies and users. Role: admin, member.
bulk_ordersid (uuid)companyId (FK), orderId (FK), totalSims, status, csvDataBulk SIM activation orders for B2B.
company_rosterid (uuid)companyId (FK), userId (FK, unique), nationality, status, packageId (FK, nullable), contractItemId (FK, nullable), unitPriceSar (nullable)One row per CSV-staged B2B beneficiary (employee or visitor). Created eagerly at CSV upload alongside the users row; contact/identity PII stays on users. Status: staged, enabled. unitPriceSar is the flexible price snapshot — written at magic-link mint time (no contract line to snapshot against), null for prepaid rows. Removing a beneficiary (DELETE /b2b/roster/:rosterId) is a hard delete of this row, its magic_links history and the eagerly-created users row, and is refused once a link was redeemed or any activation exists.
magic_linksid (uuid)rosterId (FK), tokenHash (unique), status, expiresAt, usedAt, revokedAtEphemeral single-use invite tokens (24h TTL), modeled on sessions. Token stored as a SHA-256 hash only. Status: issued, sent, exported, used, expired, failed, revoked.
quote_requestsid (uuid)companyId (FK), version, parentId (self FK), needs (jsonb), status, note, companyNote, reviewedByVersioned needs-form → catalog quote, immutable once submitted. A counter creates a new version linked via parentId. note is the reviewer's, written by an internal admin on a counter or a rejection; company_note is the company's own, written only when it declines a counter, held apart so a decline reason can never overwrite or be mistaken for the offer it declines. Status: draft, submitted, under_review, countered, accepted, rejected, expired. needs.fundingSar carries a flexible funding/top-up amount — a zero-item quote is valid when it is present.
quote_request_itemsid (uuid)quoteRequestId (FK), packageId (FK), qtyQuote line items, one set per quote version. Empty for a flexible funding/top-up request.
contractsid (uuid)companyId (FK), quoteRequestId (FK), paymentMode, status, totalSar, confirmedByCreated atomically from the accepted quote version. Payment mode: prepaid, flexible. Status: active, amended, terminated (only active used today). For flexible, totalSar is the declared funding amount and the contract holds zero contract_items ever.
contract_itemsid (uuid)contractId (FK), packageId (FK), qty, unitPriceSarContract line items; unitPriceSar snapshots packages.priceSarB2b at confirmation time. Prepaid only — a flexible contract never has rows here.
contract_paymentsid (uuid)contractId (FK), method, amountSar, documentName, documentMime, documentBytes (bytea), confirmedByPayment record with the supporting document stored in-row (size-capped, no file-storage infra) — prepaid pay-in-full or a flexible funding/top-up amount. Method: bank_transfer, cheque. Internal-only — no company-member RLS policy.

Settings Domain (schema/settings.ts)

TablePrimary KeyKey ColumnsNotes
platform_settingsid (uuid)key (unique), value (jsonb)Generic admin-configurable key/value store. First consumer: the organization key — seller-of-record identity and a VAT toggle rendered onto generated PDFs. An unset key returns null; the documents renderer omits the seller block rather than failing.

Audit Domain (schema/audit.ts)

TablePrimary KeyKey ColumnsNotes
audit_logsid (uuid)actorId (nullable FK), action, entityType, entityId, oldValues, newValues, ipAddress (inet), userAgentAppend-only. prevent_audit_log_modification trigger blocks UPDATE/DELETE.
consent_recordsid (uuid)userId (FK), consentType, version, givenAt, ipAddress, revokedAtPDPL explicit consent tracking. Types: terms_of_service, privacy_policy, marketing, data_processing.

Key Relationships

users ──< sessions
users ──< orders ──< order_items >── packages >── operators
users ──< activations ──< identity_verifications
users ──< activations ──< activation_status_events
orders ──< payments ──< payment_webhook_events
companies ──< company_users
companies ──< bulk_orders
companies ──< invoices
companies ──< company_roster ──< magic_links
companies ──< quote_requests ──< quote_request_items
quote_requests ──< contracts ──< contract_items
contracts ──< contract_payments
users ──< tickets ──< ticket_comments
users ──< audit_logs
users ──< consent_records

Documents & PDF Generation

Generated PDFs (B2C receipts, corporate charge receipts, B2B contract-payment receipts) are served from one route, GET /documents/:type/:entityId.pdf, backed by a per-type registry (apps/api/src/documents/registry.ts) of { policy, provider, template } triples (documents/types.ts). The route resolves the type, runs the type's access policy (a pure boolean check that returns a bare false on denial — the route throws 403 AUTH_FORBIDDEN, leaking nothing about the entity's existence), loads the Facts a template needs via the type's provider, then hands facts + the organization's seller settings + a language to the type's template to build a renderer-agnostic DocumentModel.

The in-app renderer at apps/api/src/documents/pdf/ turns that model into a PDF Buffer with BiDi-correct Arabic and OpenType shaping via an embedded font. It has no receipt/order/contract concepts of its own — domain meaning lives entirely in the apps/api/src/documents/templates/ files that build the model.

Document TypeProviderTemplate
receipt_b2cproviders/receipt-b2c.tstemplates/receipt.ts
corporate_chargeproviders/corporate-charge.tstemplates/receipt.ts
contract_paymentproviders/contract-payment.tstemplates/contract-payment.ts

The seller-of-record block on every generated PDF comes from the organization platform setting (see Settings Domain above) — when unset, the template omits the block instead of failing.

Queue Architecture

Eight BullMQ queues. Four are in packages/queue, use shared constants from @activation-sys/shared, and get dedicated Redis connections with namespace-prefixed keys. Four live in apps/api, configure their options inline, and share the API's Redis singleton without a BullMQ prefix.

Queue Summary

QueueNameLocationPurpose
Activationactivationpackages/queueDrives operator activation steps (initiate, verify_identity, complete)
Paymentpaymentpackages/queueSettles completed payments by enqueuing activation handoff
Notificationnotificationpackages/queueDelivers activation and payment status notifications
Store Fulfillmentstore-fulfillmentpackages/queueIssues eSIM profiles for paid store orders
Package Syncpackage-syncapps/apiPeriodic sync of telecom operator packages
eSIM Product Syncesim-product-syncapps/apiPeriodic sync of eSIM provider catalogs
FX Refreshfx-refreshapps/apiScheduled fetch of USD exchange rates from the configured provider
Status Notificationsstatus-notificationsapps/apiDelivers realtime activation status events to connected subscribers

The packages/queue queues use QUEUE_NAMES and RETRY_CONFIG from @activation-sys/shared. The apps/api queues configure attempts, backoff, removeOnComplete, and removeOnFail directly at queue creation time.

Activation Queue (packages/queue/src/queues/activation-queue.ts)

  • Job ID: Activation UUID (prevents duplicate jobs for same activation)
  • Steps: verify_identity (poll operator status), complete (finalize with operator), initiate (acknowledgment only — handled synchronously in route)
  • Custom backoff: [1000, 5000, 30000] ms — exact delays rather than standard exponential doubling
  • Worker (workers/activation-worker.ts): PluginManager injected at startup to avoid cross-package imports

Payment Queue (packages/queue/src/queues/payment-queue.ts)

  • Job ID: {paymentId}:{action} (deterministic deduplication)
  • Actions: settle_payment (re-read DB status → enqueue activation job), reconcile_failure (log and skip)
  • Worker (workers/payment-worker.ts): Re-reads payment status from DB before activation handoff (T-05-12). Derives operator from activation record, not client input (T-05-15)
  • Longer failed-job retention (7 days) supports payment reconciliation audits

Notification Queue (packages/queue/src/queues/notification-queue.ts)

  • Job ID: Provided in job data (prevents duplicates)
  • Worker (workers/notification-worker.ts): Phase 1 stub — logs and succeeds. Real notification delivery in future phase
  • Highest concurrency (20) because notifications are lightweight operations

Redis Connection Strategy (packages/queue/src/config/redis.ts)

BullMQ requires dedicated connection instances per Queue and Worker. Each domain connection uses a keyPrefix for namespace isolation (e.g., activation:, payment:, notification:). Production connections enforce TLS 1.3 (rediss:// scheme with minVersion: 'TLSv1.3').

PII Encryption Flow

All personally identifiable information (PII) is encrypted at rest using AES-256-GCM authenticated encryption, implemented as a custom Drizzle ORM column type.

Custom Encrypted Type (packages/database/src/utils/encryption.ts)

typescript
const encryptedText = customType<{ data: string; driverData: string }>({
  dataType() { return 'text'; },
  toDriver(value: string): string { return encrypt(value); },
  fromDriver(value: string): string { return decrypt(value); },
});
  • Algorithm: AES-256-GCM (provides both confidentiality and integrity)
  • Key source: ENCRYPTION_KEY environment variable (64-char hex string = 32 bytes)
  • Key rotation support: Key is read at call time (getKey()) — not at module initialization — so rotating the env var takes effect without restart
  • Storage format: Hex-encoded IV (12 bytes) + authTag (16 bytes) + ciphertext
  • IV: Random 12 bytes per encryption operation (non-deterministic ciphertext)

Encrypted Columns

TableColumnNote
usersphoneNullable (email-only users per D-25)
usersemailNullable (phone-only users)
usersnameNot null
usersidNumberNullable, collected during activation
companiescontactPhoneB2B contact phone

Lookup Strategy

Encrypted values are non-deterministic (random IV). For uniqueness lookups and searching, parallel hash columns store SHA-256 hashes:

Encrypted ColumnHash ColumnPurpose
phonephoneHashPhone uniqueness + login lookup
emailemailHashEmail uniqueness + login lookup

The hash columns are indexed (e.g., idx_users_phone_hash, idx_users_email_hash) for efficient queries.

Threat Model

  • T-04-06: Encryption key never logged or returned in API responses
  • T-05-06: Payment metadata limited to non-PII fields only
  • Corruption detection: GCM auth tag verification causes decrypt() to throw if data is tampered or wrong key is used

Authentication Flow

Authentication uses JWTs issued by the Hono API (signed with SUPABASE_JWT_SECRET via HS256). Supabase Auth provides the underlying identity provider.

Registration → Login → Session

1. POST /auth/register  → Create user + send OTP (phone/email)
2. POST /auth/login     → Send OTP to existing user
3. POST /auth/verify-otp → Verify OTP + create JWT session
4. POST /auth/logout     → Revoke session (web: clear cookie, mobile: Redis blocklist)

Dual Session Strategy (Web vs Mobile)

The API detects client type via X-Client-Type header or User-Agent pattern matching (Flutter/Dart/Android/iPhone → mobile, otherwise → web). This affects how the JWT is delivered and revoked:

AspectWeb ClientMobile Client
Token deliveryhttpOnly, Secure, SameSite=Strict cookie named activation-sys-sessionJWT in response body (session.token)
Token storageBrowser cookie jarflutter_secure_storage on device
Token sendingAutomatic via cookieAuthorization: Bearer <token> header
LogoutdeleteCookie() — cookie clearedSHA-256(JWT) → auth:blocklist:<hash> in Redis with TTL matching remaining JWT expiry
Session recordclientType: 'web' in sessions tableclientType: 'mobile'

JWT Payload

json
{
  "sub": "user-uuid",
  "app_role": "end_user",
  "identity_type": "citizen",
  "iat": 1714896000,
  "exp": 1714903200
}

Auth Middleware Flow (apps/api/src/middleware/auth.ts)

  1. Check if path is in PUBLIC_PATHS → skip auth
  2. Check if path matches a PROTECTED_PREFIXES entry → enforce auth; unknown paths pass through
  3. Extract token: try activation-sys-session cookie first, then Authorization: Bearer header
  4. Check Redis blocklist: SHA-256(token)auth:blocklist:<hash> (prevents token leakage in logs)
  5. Verify JWT signature and expiry with SUPABASE_JWT_SECRET (HS256)
  6. Set context: userId, appRole, identityType, rawToken

Roles Enforced

RoleAccess Scope
end_userOwn data only (users, orders, activations, payments, profile)
b2b_adminOwn data + company-scoped data
activation_officerAssigned activations + linked users
supportRead-only access to users, orders, activations, payments, tickets
adminPlatform admin scoped to assigned areas, each held as View or Manage
super_adminFull read/write access + compliance routes + audit logs

Bilingual Response System

All API responses respect the Accept-Language header for Arabic/English bilingual output. The language determination flows through middleware to the error handler and route handlers.

Flow

Accept-Language header → languageMiddleware → c.set('language', 'ar'|'en')
                                         → c.set('clientType', 'web'|'mobile')
Error path: errorHandlerMiddleware → toApiResponse(error, language)
Normal path: route handler reads c.get('language')

Language Middleware (apps/api/src/middleware/language.ts)

  • Reads Accept-Language header (defaults to en)
  • If value starts with ar → language is ar; otherwise → en
  • Also detects X-Client-Type header or User-Agent pattern for clientType

Error Response (Bilingual)

The global error handler (apps/api/src/middleware/error-handler.ts) converts AppError instances using toApiResponse(error, language), which selects messageAr or messageEn based on the detected language:

json
// Accept-Language: ar
{
  "error": {
    "code": "AUTH_REQUIRED",
    "message": "مطلوب المصادقة"
  }
}
json
// Accept-Language: en
{
  "error": {
    "code": "AUTH_REQUIRED",
    "message": "Authentication required"
  }
}

Health Check Response

json
// GET /health (200 or 503)
{
  "status": "ok",
  "version": "1.0.0",
  "timestamp": "2026-05-05T08:00:00.000Z",
  "checks": {
    "database": "ok",
    "redis": "ok"
  }
}

Auth Verify-OTP Response (Dual Strategy)

Web client — JWT set as httpOnly cookie, not in body:

json
// POST /auth/verify-otp (web client)
{
  "user": {
    "id": "a1b2c3d4-...",
    "phone": "+966500000001",
    "name": "محمد",
    "email": null,
    "appRole": "end_user",
    "identityType": "citizen",
    "isActive": true,
    "createdAt": "2026-01-15T10:30:00.000Z"
  },
  "session": {
    "clientType": "web",
    "expiresAt": "2026-05-05T10:00:00.000Z"
  }
}

Mobile client — JWT returned in response body:

json
// POST /auth/verify-otp (mobile client)
{
  "user": {
    "id": "a1b2c3d4-...",
    "phone": "+966500000001",
    "name": "محمد",
    "email": null,
    "appRole": "end_user",
    "identityType": "citizen",
    "isActive": true,
    "createdAt": "2026-01-15T10:30:00.000Z"
  },
  "session": {
    "token": "eyJhbGciOiJIUzI1NiIs...",
    "clientType": "mobile",
    "expiresAt": "2026-05-05T10:00:00.000Z"
  }
}

Catalog Bilingual Fields

Package and operator data is stored with separate Arabic and English columns. Route handlers select the appropriate field based on language:

json
// GET /packages (Accept-Language: ar)
{
  "data": [{
    "id": "pkg-uuid",
    "name": "باقة الإنترنت - 50 جيجا",
    "description": "باقة إنترنت شهرية من STC",
    "priceSar": 100.00,
    ...
  }]
}

Operator plugins also return bilingual data using { ar: string; en: string } objects (e.g., OperatorPackage.name.ar, OperatorPackage.name.en).

Application Surfaces

SurfaceWorkspaceRuntimeResponsibility
APIapps/apiHono on Node.jsValidated REST contracts, auth, ownership, activation, payment, compliance, webhooks
Admin/B2Bapps/adminNext.jsOperational admin workflows and company portal flows
B2C Webapps/web-b2cNext.jsBrowser customer activation funnel, package comparison, status entry points
Landing Siteapps/siteVite + ReactPublic marketing, bilingual positioning, routing into B2C web/docs
Mobileapps/mobileFlutterPrimary native customer app once Dart sources are added
DocsdocsVitePressInternal developer documentation

B2C web, mobile, admin, and site remain thin clients. Identity verification, payment side effects, activation transitions, audit logging, and ownership checks remain server-side in apps/api and packages/queue.

Internal documentation - Activation System