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.ymlSystem 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
| Decision | Rationale |
|---|---|
| Hono over Express | Type-safe, lightweight, edge-ready, native Zod validation |
| Drizzle over Prisma | SQL-first, better RLS support, transparent PII encryption via custom types |
| BullMQ over Bull v3 | Active maintenance, better TypeScript, namespace-prefixed queues |
| Supabase | Managed PostgreSQL + RLS + Auth (Phase 2) |
| AES-256-GCM encryption | Authenticated encryption for PII at rest, key rotation support |
| Plugin pattern for operators | Decoupled 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 userSecurity
- 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
messageEnandmessageAr - Audit Trail: Immutable audit logs with
auditImmutabilityTrigger - Idempotency: Payment intents require
idempotencyKeyfor 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| Order | Middleware | Source | Purpose |
|---|---|---|---|
| 1 | logger() | hono/logger | Structured request/response logging |
| 2 | cors() | hono/cors | CORS with environment-specific origins |
| 3 | errorHandlerMiddleware | src/middleware/error-handler.ts | Global error catch → bilingual JSON |
| 4 | languageMiddleware | src/middleware/language.ts | Extract Accept-Language → language + clientType context |
| 5 | rateLimitMiddleware() | src/middleware/rate-limit.ts | 100 req/15min per IP, sets X-RateLimit-* headers |
| 6 | authMiddleware | src/middleware/auth.ts | JWT 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
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health | None | Health check (API, database, Redis connectivity) |
| POST | /auth/register | None | Register new user, send OTP |
| POST | /auth/login | None | Login, send OTP to existing user |
| POST | /auth/verify-otp | None | Verify OTP, create session (dual strategy: cookie for web, JWT for mobile) |
| POST | /auth/logout | Required | Clear session (web: delete cookie, mobile: Redis blocklist) |
| GET | /auth/session | Required | Return current session user from JWT |
| GET | /auth/me | Required | Return user profile with decrypted PII |
| PUT | /auth/me | Required | Update profile (name, email; phone via /change-phone) |
| POST | /auth/change-phone | Required | Send OTP to new phone number |
| GET | /operators | None | List active operators with Arabic/English names |
| GET | /packages | None | List packages with filters (operator, data, voice, validity, promotional) |
| GET | /packages/search | None | Full-text + fuzzy trigram search across packages |
| GET | /packages/compare | None | Side-by-side comparison (2–5 package IDs) |
| GET | /packages/:id | None | Single package detail with operator info |
| GET | /packages/:id/availability | None | Real-time availability via operator plugin (Redis cache, 30s TTL) |
| POST | /activations/validate-iccid | None | Validate ICCID via operator plugin (public pre-check) |
| POST | /activations | Required | Create activation order with package selection |
| POST | /activations/:id/verify-identity | Required | Initiate Nafath identity verification |
| GET | /activations/:id/verify-identity | Required | Check verification status (5 Nafath states) |
| GET | /activations/:id/status | Required | Activation + identity status with estimated timing |
| GET | /activations/history | Required | Paginated activation history (keyset cursor) |
| GET | /activations/records | super_admin | Admin dashboard activation records with filters and related order/package/operator/user contact data |
| POST | /payments/create-intent | Required | Create payment intent (Mada, Visa, Mastercard) |
| POST | /payments/apple-pay | Required | Create Apple Pay payment |
| POST | /payments/stcpay | Required | Initiate STC Pay direct payment |
| GET | /payments/:id/receipt | Required | Owner-scoped receipt with bilingual messages |
| GET | /profile | Required | Return authenticated user profile aggregate |
| PUT | /profile | Required | Update allowed profile fields (name, email) |
| GET | /profile/export | Required | PDPL data portability export |
| DELETE | /profile | Required | Request account deletion (soft delete + retention period) |
| POST | /compliance/retention/sweep | super_admin | Execute PDPL retention sweep |
| POST | /compliance/breach-report | super_admin | Generate SDAIA breach notification |
| POST | /webhooks/stripe | None* | Handle Stripe webhook events (*Stripe signature verification) |
Auth model notes:
- Public routes skip JWT verification entirely (
PUBLIC_PATHSlist inauth.ts) - Protected routes require JWT from httpOnly cookie (web) or
Authorization: Bearerheader (mobile) - Compliance routes add a second middleware layer requiring
appRole === 'super_admin' - Webhook routes verify
Stripe-Signatureheader 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:
| Method | Purpose |
|---|---|
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:
| Plugin | File | Environment |
|---|---|---|
MockOperatorPlugin | mock-operator.ts | Default (development/test) |
| STC, Mobily, Zain | Future | PAYMENT_PROVIDER=stripe + operator credentials |
PaymentProvider (apps/api/src/plugins/payment-provider.ts)
| Method | Purpose |
|---|---|
createPaymentIntent(input) | Create card-like payment intent via gateway |
Separate StcPayProvider interface for STC Pay direct flow (initiateStcPay(input)).
| Provider | File | When Active |
|---|---|---|
MockPaymentProvider | mock-payment-provider.ts | Default |
StripePaymentProvider | stripe-payment-provider.ts | PAYMENT_PROVIDER=stripe |
MockStcPayProvider | mock-stcpay-provider.ts | Development/test |
SmsProvider (apps/api/src/plugins/sms-plugin.ts)
| Method | Purpose |
|---|---|
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.
| Provider | File | When Active |
|---|---|---|
MockSmsProvider | mock-sms-provider.ts | Default |
TwilioSmsProvider | twilio-sms-provider.ts | SMS_PROVIDER=twilio |
TaqnyatSmsProvider | taqnyat-sms-provider.ts | SMS_PROVIDER=taqnyat |
EmailProvider (apps/api/src/plugins/email-plugin.ts)
| Method | Purpose |
|---|---|
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.
| Provider | File | When Active |
|---|---|---|
MockEmailProvider | mock-email-provider.ts | Default |
SmtpEmailProvider | smtp-email-provider.ts | Admin 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)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
users | id (uuid) | phone (encrypted), phoneHash, email (encrypted), emailHash, name (encrypted), idNumber (encrypted), identityType, appRole | Single table for all roles. appRole is dynamic text (end_user, b2b_admin, super_admin, support, activation_officer, admin). Hash columns for deterministic lookups. |
sessions | id (uuid) | userId, tokenHash, ipAddress (inet), userAgent, clientType, revokedAt | Supplements Supabase Auth JWT sessions with metadata tracking. |
Catalog Domain (schema/catalog.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
operators | id (uuid) | slug (unique), nameEn, nameAr, isActive, serviceType, paymentMode, isNameArFallback, apiBaseUrl, apiConfig | Bilingual display names (rarely change). Slug: stc, mobily, zain. eSIM store providers are rows in this table with serviceType='esim'. |
packages | id (uuid) | operatorId (FK), nameEn, nameAr, descriptionEn, descriptionAr, searchVector (tsvector), priceSar, priceSarB2b, isNameCustomized, isPriceCustomized, isActiveCustomized | Full-text search via GIN index + pg_trgm fuzzy fallback. Admin customization flags protect overrides from sync. |
Activation Domain (schema/activation.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
orders | id (uuid) | userId (FK), companyId (FK, nullable), status, totalAmount, currency | currency is 'SAR' or 'USD' (default 'SAR'). companyId set for B2B orders only. |
order_items | id (uuid) | orderId (FK), packageId (FK), iccid, unitPriceSar | One order item per SIM + package selection. |
activations | id (uuid) | orderItemId (FK), userId (FK), operatorId (FK), iccid, packageId (FK), status, identityStatus, identityRef, assignedOfficerId, retainUntil, deletedAt | Core activation record. Retention fields for PDPL compliance. Status transitions tracked in activation_status_events. |
identity_verifications | id (uuid) | activationId (FK), transId, randomCode, status, verifiedAt | Nafath transaction tracking. 5 states: waiting, completed, rejected, expired, error. |
activation_status_events | id (uuid) | activationId (FK), userId (FK), oldStatus, newStatus, source, note | Immutable audit trail for every activation status transition. Source: system, user, officer, operator_api. |
Payment Domain (schema/payment.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
payments | id (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_events | id (uuid) | gateway, eventId (unique composite), eventType, paymentId (FK), processedAt, payload | Deduplication: (gateway, eventId) unique index. Gateway: stripe, mock. |
invoices | id (uuid) | companyId (FK), orderId (FK), amountSar, zatcaInvoiceId, pdfUrl, status | B2B only. ZATCA e-invoicing integration. |
FX Domain (schema/fx.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
fx_rates | currency (text) | ratePerUsd (numeric 14,6), fetchedAt, source | USD-base rate table fed by a provider adapter. Deny-all RLS. |
Store Domain (schema/store.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
esim_products | id (uuid) | providerSlug, operatorId (FK), providerSku, nameEn, nameAr, destinationScope, destinationCountries, validityValue, wholesaleUsd, retailUsd, isActive, isNameCustomized, isPriceCustomized, isActiveCustomized, source*, providerPayload | Global 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_orders | id (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, deletedAt | One 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)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
tickets | id (uuid) | userId (FK), subject, description, status, priority, assignedTo, activationId (FK, nullable) | Linked to activation for context. Priorities: low, medium, high, critical. |
ticket_comments | id (uuid) | ticketId (FK), authorId (FK), content, isInternal | Internal notes not visible to end users (RLS-enforced). |
B2B Domain (schema/b2b.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
companies | id (uuid) | crNumber (unique), name, contactName, contactEmail, contactPhone (encrypted), status, paymentMode (nullable), approvedBy | Commercial 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_users | id (uuid) | companyId (FK), userId (FK), role | Many-to-many between companies and users. Role: admin, member. |
bulk_orders | id (uuid) | companyId (FK), orderId (FK), totalSims, status, csvData | Bulk SIM activation orders for B2B. |
company_roster | id (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_links | id (uuid) | rosterId (FK), tokenHash (unique), status, expiresAt, usedAt, revokedAt | Ephemeral 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_requests | id (uuid) | companyId (FK), version, parentId (self FK), needs (jsonb), status, note, companyNote, reviewedBy | Versioned 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_items | id (uuid) | quoteRequestId (FK), packageId (FK), qty | Quote line items, one set per quote version. Empty for a flexible funding/top-up request. |
contracts | id (uuid) | companyId (FK), quoteRequestId (FK), paymentMode, status, totalSar, confirmedBy | Created 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_items | id (uuid) | contractId (FK), packageId (FK), qty, unitPriceSar | Contract line items; unitPriceSar snapshots packages.priceSarB2b at confirmation time. Prepaid only — a flexible contract never has rows here. |
contract_payments | id (uuid) | contractId (FK), method, amountSar, documentName, documentMime, documentBytes (bytea), confirmedBy | Payment 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)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
platform_settings | id (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)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
audit_logs | id (uuid) | actorId (nullable FK), action, entityType, entityId, oldValues, newValues, ipAddress (inet), userAgent | Append-only. prevent_audit_log_modification trigger blocks UPDATE/DELETE. |
consent_records | id (uuid) | userId (FK), consentType, version, givenAt, ipAddress, revokedAt | PDPL 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_recordsDocuments & 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 Type | Provider | Template |
|---|---|---|
receipt_b2c | providers/receipt-b2c.ts | templates/receipt.ts |
corporate_charge | providers/corporate-charge.ts | templates/receipt.ts |
contract_payment | providers/contract-payment.ts | templates/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
| Queue | Name | Location | Purpose |
|---|---|---|---|
| Activation | activation | packages/queue | Drives operator activation steps (initiate, verify_identity, complete) |
| Payment | payment | packages/queue | Settles completed payments by enqueuing activation handoff |
| Notification | notification | packages/queue | Delivers activation and payment status notifications |
| Store Fulfillment | store-fulfillment | packages/queue | Issues eSIM profiles for paid store orders |
| Package Sync | package-sync | apps/api | Periodic sync of telecom operator packages |
| eSIM Product Sync | esim-product-sync | apps/api | Periodic sync of eSIM provider catalogs |
| FX Refresh | fx-refresh | apps/api | Scheduled fetch of USD exchange rates from the configured provider |
| Status Notifications | status-notifications | apps/api | Delivers 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)
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_KEYenvironment 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
| Table | Column | Note |
|---|---|---|
users | phone | Nullable (email-only users per D-25) |
users | email | Nullable (phone-only users) |
users | name | Not null |
users | idNumber | Nullable, collected during activation |
companies | contactPhone | B2B contact phone |
Lookup Strategy
Encrypted values are non-deterministic (random IV). For uniqueness lookups and searching, parallel hash columns store SHA-256 hashes:
| Encrypted Column | Hash Column | Purpose |
|---|---|---|
phone | phoneHash | Phone uniqueness + login lookup |
email | emailHash | Email 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:
| Aspect | Web Client | Mobile Client |
|---|---|---|
| Token delivery | httpOnly, Secure, SameSite=Strict cookie named activation-sys-session | JWT in response body (session.token) |
| Token storage | Browser cookie jar | flutter_secure_storage on device |
| Token sending | Automatic via cookie | Authorization: Bearer <token> header |
| Logout | deleteCookie() — cookie cleared | SHA-256(JWT) → auth:blocklist:<hash> in Redis with TTL matching remaining JWT expiry |
| Session record | clientType: 'web' in sessions table | clientType: 'mobile' |
JWT Payload
{
"sub": "user-uuid",
"app_role": "end_user",
"identity_type": "citizen",
"iat": 1714896000,
"exp": 1714903200
}Auth Middleware Flow (apps/api/src/middleware/auth.ts)
- Check if path is in
PUBLIC_PATHS→ skip auth - Check if path matches a
PROTECTED_PREFIXESentry → enforce auth; unknown paths pass through - Extract token: try
activation-sys-sessioncookie first, thenAuthorization: Bearerheader - Check Redis blocklist:
SHA-256(token)→auth:blocklist:<hash>(prevents token leakage in logs) - Verify JWT signature and expiry with
SUPABASE_JWT_SECRET(HS256) - Set context:
userId,appRole,identityType,rawToken
Roles Enforced
| Role | Access Scope |
|---|---|
end_user | Own data only (users, orders, activations, payments, profile) |
b2b_admin | Own data + company-scoped data |
activation_officer | Assigned activations + linked users |
support | Read-only access to users, orders, activations, payments, tickets |
admin | Platform admin scoped to assigned areas, each held as View or Manage |
super_admin | Full 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-Languageheader (defaults toen) - If value starts with
ar→ language isar; otherwise →en - Also detects
X-Client-Typeheader orUser-Agentpattern forclientType
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:
// Accept-Language: ar
{
"error": {
"code": "AUTH_REQUIRED",
"message": "مطلوب المصادقة"
}
}// Accept-Language: en
{
"error": {
"code": "AUTH_REQUIRED",
"message": "Authentication required"
}
}Health Check Response
// 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:
// 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:
// 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:
// 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
| Surface | Workspace | Runtime | Responsibility |
|---|---|---|---|
| API | apps/api | Hono on Node.js | Validated REST contracts, auth, ownership, activation, payment, compliance, webhooks |
| Admin/B2B | apps/admin | Next.js | Operational admin workflows and company portal flows |
| B2C Web | apps/web-b2c | Next.js | Browser customer activation funnel, package comparison, status entry points |
| Landing Site | apps/site | Vite + React | Public marketing, bilingual positioning, routing into B2C web/docs |
| Mobile | apps/mobile | Flutter | Primary native customer app once Dart sources are added |
| Docs | docs | VitePress | Internal 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.