commit 1b1801fc933fd7214a5ba7e69b0b5b94d65071dd Author: tuankiet2s Date: Thu Oct 23 20:40:17 2025 +0700 first commit diff --git a/concept.MDX b/concept.MDX new file mode 100644 index 0000000..aaf6232 --- /dev/null +++ b/concept.MDX @@ -0,0 +1,1659 @@ +# RevoLand SuperApp - Runtime Modularization Architecture with Security + +## 1. Project Overview + +**RevoLand** is a real estate SuperApp built on Flutter (iOS & Android) that dynamically loads and runs SubApps remotely through WebView at runtime. Each SubApp resides in its own repository, deployed as Flutter Web, and hosted on a server or CDN. This allows updates without republishing the main SuperApp, with robust security mechanisms to prevent unauthorized access. + +## 2. Architecture Components + +### 2.1 SuperApp (Flutter Native) + +The main RevoLand app contains the core interface, navigation, and shared services. + +**Key Responsibilities:** +- User authentication +- Fetch available SubApps from Metadata API +- Display dynamic menu based on user permissions +- Launch SubApps in WebView with secure context +- Establish bidirectional communication bridge + +**Example Implementation:** + +```dart +// lib/models/sub_app.dart +class SubApp { + final String id; + final String displayName; + final String description; + final String iconUrl; + final String launchUrl; + final String signedToken; + final List permissions; + + SubApp({ + required this.id, + required this.displayName, + required this.description, + required this.iconUrl, + required this.launchUrl, + required this.signedToken, + required this.permissions, + }); + + factory SubApp.fromJson(Map json) { + return SubApp( + id: json['id'] as String, + displayName: json['displayName'] as String, + description: json['description'] as String, + iconUrl: json['iconUrl'] as String, + launchUrl: json['launchUrl'] as String, + signedToken: json['signedToken'] as String, + permissions: List.from(json['permissions'] ?? []), + ); + } +} +``` + +```dart +// lib/services/metadata_service.dart +import 'package:dio/dio.dart'; +import '../models/sub_app.dart'; + +class MetadataService { + final Dio _dio; + final String _baseUrl; + + MetadataService({ + required String baseUrl, + required String authToken, + }) : _baseUrl = baseUrl, + _dio = Dio(BaseOptions( + baseUrl: baseUrl, + headers: { + 'Authorization': 'Bearer $authToken', + 'Content-Type': 'application/json', + }, + )); + + Future> fetchAvailableSubApps() async { + try { + final response = await _dio.get('/api/v1/subapps/available'); + + if (response.statusCode == 200) { + final List data = response.data['subApps']; + return data.map((json) => SubApp.fromJson(json)).toList(); + } else { + throw Exception('Failed to fetch SubApps'); + } + } catch (e) { + throw Exception('Error fetching SubApps: $e'); + } + } + + Future refreshSubAppToken(String subAppId) async { + try { + final response = await _dio.post( + '/api/v1/subapps/$subAppId/token/refresh', + ); + + if (response.statusCode == 200) { + return response.data['signedToken'] as String; + } else { + throw Exception('Failed to refresh token'); + } + } catch (e) { + throw Exception('Error refreshing token: $e'); + } + } +} +``` + +```dart +// lib/screens/sub_app_launcher.dart +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import '../models/sub_app.dart'; + +class SubAppLauncher extends StatefulWidget { + final SubApp subApp; + final String userId; + final String userRole; + + const SubAppLauncher({ + Key? key, + required this.subApp, + required this.userId, + required this.userRole, + }) : super(key: key); + + @override + State createState() => _SubAppLauncherState(); +} + +class _SubAppLauncherState extends State { + late WebViewController _controller; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _initializeWebView(); + } + + void _initializeWebView() { + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(Colors.white) + ..addJavaScriptChannel( + 'RevoLandNativeBridge', + onMessageReceived: _handleNativeMessage, + ) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (url) { + setState(() => _isLoading = true); + }, + onPageFinished: (url) { + _injectSuperAppContext(); + setState(() => _isLoading = false); + }, + onWebResourceError: (error) { + debugPrint('WebView error: ${error.description}'); + }, + ), + ) + ..loadRequest(Uri.parse( + '${widget.subApp.launchUrl}?token=${widget.subApp.signedToken}&hostAppId=revoland-mobile', + )); + } + + void _injectSuperAppContext() { + final context = ''' + window.__REVOLAND_CONTEXT__ = { + token: "${widget.subApp.signedToken}", + hostAppId: "revoland-mobile", + platform: "${Theme.of(context).platform.name}", + userId: "${widget.userId}", + userRole: "${widget.userRole}", + subAppId: "${widget.subApp.id}", + theme: { + primaryColor: "#2563eb", + isDarkMode: ${Theme.of(context).brightness == Brightness.dark} + }, + locale: "${Localizations.localeOf(context).languageCode}" + }; + + if (window.onRevoLandContextReady) { + window.onRevoLandContextReady(window.__REVOLAND_CONTEXT__); + } + + console.log("RevoLand context injected:", window.__REVOLAND_CONTEXT__); + '''; + + _controller.runJavaScript(context); + } + + void _handleNativeMessage(JavaScriptMessage message) { + debugPrint('Message from SubApp: ${message.message}'); + + try { + final data = jsonDecode(message.message); + final action = data['action'] as String?; + + switch (action) { + case 'subappReady': + debugPrint('SubApp ${widget.subApp.id} is ready'); + break; + case 'requestLocation': + _handleLocationRequest(data); + break; + case 'shareProperty': + _handleShareProperty(data); + break; + case 'openMap': + _handleOpenMap(data); + break; + case 'navigateBack': + Navigator.of(context).pop(); + break; + default: + debugPrint('Unknown action: $action'); + } + } catch (e) { + debugPrint('Error handling message: $e'); + } + } + + void _handleLocationRequest(Map data) { + // Request device location and send back to SubApp + // Implementation depends on location service + } + + void _handleShareProperty(Map data) { + final propertyId = data['propertyId'] as String?; + final propertyUrl = data['propertyUrl'] as String?; + // Trigger native share dialog + } + + void _handleOpenMap(Map data) { + final lat = data['latitude'] as double?; + final lng = data['longitude'] as double?; + // Open native maps app + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.subApp.displayName), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => _controller.reload(), + ), + ], + ), + body: Stack( + children: [ + WebViewWidget(controller: _controller), + if (_isLoading) + const Center( + child: CircularProgressIndicator(), + ), + ], + ), + ); + } +} +``` + +```dart +// lib/screens/home_screen.dart +import 'package:flutter/material.dart'; +import '../models/sub_app.dart'; +import '../services/metadata_service.dart'; +import 'sub_app_launcher.dart'; + +class HomeScreen extends StatefulWidget { + final String userId; + final String userRole; + final String authToken; + + const HomeScreen({ + Key? key, + required this.userId, + required this.userRole, + required this.authToken, + }) : super(key: key); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + late MetadataService _metadataService; + List _subApps = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _metadataService = MetadataService( + baseUrl: 'https://api.revoland.com', + authToken: widget.authToken, + ); + _loadSubApps(); + } + + Future _loadSubApps() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final subApps = await _metadataService.fetchAvailableSubApps(); + setState(() { + _subApps = subApps; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + void _launchSubApp(SubApp subApp) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SubAppLauncher( + subApp: subApp, + userId: widget.userId, + userRole: widget.userRole, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('RevoLand'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadSubApps, + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center(child: Text('Error: $_error')) + : GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 1.2, + ), + itemCount: _subApps.length, + itemBuilder: (context, index) { + final subApp = _subApps[index]; + return _SubAppCard( + subApp: subApp, + onTap: () => _launchSubApp(subApp), + ); + }, + ), + ); + } +} + +class _SubAppCard extends StatelessWidget { + final SubApp subApp; + final VoidCallback onTap; + + const _SubAppCard({ + required this.subApp, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.network( + subApp.iconUrl, + width: 48, + height: 48, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.apps, size: 48), + ), + const SizedBox(height: 12), + Text( + subApp.displayName, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + subApp.description, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ); + } +} +``` + +### 2.2 Metadata API (Backend) + +Backend service that manages SubApp metadata and authentication. + +**Example Implementation (Node.js + Express):** + +```typescript +// src/types/subapp.types.ts +export interface SubAppMetadata { + id: string; + displayName: string; + description: string; + iconUrl: string; + launchUrl: string; + signedToken: string; + permissions: string[]; + version: string; + minHostVersion: string; +} + +export interface JWTPayload { + userId: string; + hostAppId: string; + subAppId: string; + userRole: string; + permissions: string[]; + iat: number; + exp: number; +} +``` + +```typescript +// src/middleware/auth.middleware.ts +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +export interface AuthRequest extends Request { + user?: { + userId: string; + role: string; + email: string; + }; +} + +export const authenticateUser = ( + req: AuthRequest, + res: Response, + next: NextFunction +): void => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + const token = authHeader.substring(7); + const decoded = jwt.verify(token, JWT_SECRET) as { + userId: string; + role: string; + email: string; + }; + + req.user = decoded; + next(); + } catch (error) { + res.status(401).json({ error: 'Invalid token' }); + } +}; +``` + +```typescript +// src/services/subapp.service.ts +import jwt from 'jsonwebtoken'; +import { SubAppMetadata, JWTPayload } from '../types/subapp.types'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; +const TOKEN_EXPIRY = '5m'; // 5 minutes + +export class SubAppService { + private subAppRegistry: Map> = new Map([ + [ + 'property-listings', + { + id: 'property-listings', + displayName: 'Property Listings', + description: 'Browse available properties', + iconUrl: 'https://cdn.revoland.com/icons/listings.png', + launchUrl: 'https://cdn.revoland.com/subapps/property-listings/index.html', + permissions: ['read:properties', 'search:properties'], + version: '1.2.0', + minHostVersion: '1.0.0', + }, + ], + [ + 'virtual-tours', + { + id: 'virtual-tours', + displayName: 'Virtual Tours', + description: '360° property tours', + iconUrl: 'https://cdn.revoland.com/icons/virtual-tour.png', + launchUrl: 'https://cdn.revoland.com/subapps/virtual-tours/index.html', + permissions: ['read:properties', 'view:tours'], + version: '2.0.1', + minHostVersion: '1.0.0', + }, + ], + [ + 'mortgage-calculator', + { + id: 'mortgage-calculator', + displayName: 'Mortgage Calculator', + description: 'Calculate mortgage payments', + iconUrl: 'https://cdn.revoland.com/icons/calculator.png', + launchUrl: 'https://cdn.revoland.com/subapps/mortgage-calculator/index.html', + permissions: ['calculate:mortgage'], + version: '1.5.2', + minHostVersion: '1.0.0', + }, + ], + [ + 'agent-dashboard', + { + id: 'agent-dashboard', + displayName: 'Agent Dashboard', + description: 'Manage your properties and leads', + iconUrl: 'https://cdn.revoland.com/icons/dashboard.png', + launchUrl: 'https://cdn.revoland.com/subapps/agent-dashboard/index.html', + permissions: ['manage:properties', 'manage:leads', 'view:analytics'], + version: '3.1.0', + minHostVersion: '1.0.0', + }, + ], + ]); + + private rolePermissions: Record = { + buyer: ['read:properties', 'search:properties', 'view:tours', 'calculate:mortgage'], + seller: ['read:properties', 'search:properties', 'view:tours'], + agent: ['read:properties', 'search:properties', 'view:tours', 'calculate:mortgage', 'manage:properties', 'manage:leads', 'view:analytics'], + admin: ['*'], + }; + + generateSubAppToken( + userId: string, + subAppId: string, + userRole: string, + permissions: string[] + ): string { + const payload: JWTPayload = { + userId, + hostAppId: 'revoland-mobile', + subAppId, + userRole, + permissions, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 300, // 5 minutes + }; + + return jwt.sign(payload, JWT_SECRET); + } + + getUserPermissions(userRole: string): string[] { + return this.rolePermissions[userRole] || []; + } + + hasPermission(userPermissions: string[], requiredPermission: string): boolean { + if (userPermissions.includes('*')) return true; + return userPermissions.includes(requiredPermission); + } + + getAvailableSubApps(userId: string, userRole: string): SubAppMetadata[] { + const userPermissions = this.getUserPermissions(userRole); + const availableSubApps: SubAppMetadata[] = []; + + for (const [id, subApp] of this.subAppRegistry) { + // Check if user has at least one required permission + const hasAccess = subApp.permissions.some((perm) => + this.hasPermission(userPermissions, perm) + ); + + if (hasAccess) { + const signedToken = this.generateSubAppToken( + userId, + id, + userRole, + userPermissions.filter((p) => subApp.permissions.includes(p) || p === '*') + ); + + availableSubApps.push({ + ...subApp, + signedToken, + }); + } + } + + return availableSubApps; + } + + refreshToken(userId: string, subAppId: string, userRole: string): string | null { + const subApp = this.subAppRegistry.get(subAppId); + if (!subApp) return null; + + const userPermissions = this.getUserPermissions(userRole); + const hasAccess = subApp.permissions.some((perm) => + this.hasPermission(userPermissions, perm) + ); + + if (!hasAccess) return null; + + return this.generateSubAppToken( + userId, + subAppId, + userRole, + userPermissions.filter((p) => subApp.permissions.includes(p) || p === '*') + ); + } +} +``` + +```typescript +// src/routes/subapp.routes.ts +import { Router } from 'express'; +import { AuthRequest, authenticateUser } from '../middleware/auth.middleware'; +import { SubAppService } from '../services/subapp.service'; + +const router = Router(); +const subAppService = new SubAppService(); + +// Get available SubApps for current user +router.get('/available', authenticateUser, (req: AuthRequest, res) => { + try { + if (!req.user) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const subApps = subAppService.getAvailableSubApps( + req.user.userId, + req.user.role + ); + + res.json({ + success: true, + subApps, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error('Error fetching SubApps:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Refresh token for specific SubApp +router.post('/:subAppId/token/refresh', authenticateUser, (req: AuthRequest, res) => { + try { + if (!req.user) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const { subAppId } = req.params; + const newToken = subAppService.refreshToken( + req.user.userId, + subAppId, + req.user.role + ); + + if (!newToken) { + res.status(403).json({ error: 'Access denied to this SubApp' }); + return; + } + + res.json({ + success: true, + signedToken: newToken, + expiresIn: 300, + }); + } catch (error) { + console.error('Error refreshing token:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; +``` + +```typescript +// src/app.ts +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import subAppRoutes from './routes/subapp.routes'; + +const app = express(); + +// Security middleware +app.use(helmet()); +app.use(cors({ + origin: ['https://revoland.com', 'https://app.revoland.com'], + credentials: true, +})); +app.use(express.json()); + +// Routes +app.use('/api/v1/subapps', subAppRoutes); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Metadata API server running on port ${PORT}`); +}); + +export default app; +``` + +### 2.3 SubApp (Flutter Web) + +Independent SubApp module that runs inside WebView. + +**Example: Property Listings SubApp** + +```dart +// property_listings/lib/main.dart +import 'package:flutter/material.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'services/revoland_bridge.dart'; +import 'services/api_service.dart'; +import 'screens/listings_screen.dart'; + +void main() { + setUrlStrategy(PathUrlStrategy()); + runApp(const PropertyListingsApp()); +} + +class PropertyListingsApp extends StatefulWidget { + const PropertyListingsApp({Key? key}) : super(key: key); + + @override + State createState() => _PropertyListingsAppState(); +} + +class _PropertyListingsAppState extends State { + late RevoLandBridge _bridge; + late ApiService _apiService; + bool _isReady = false; + + @override + void initState() { + super.initState(); + _initializeSubApp(); + } + + Future _initializeSubApp() async { + _bridge = RevoLandBridge(); + await _bridge.initialize(); + + final context = _bridge.getSuperAppContext(); + if (context == null) { + debugPrint('Error: SuperApp context not available'); + return; + } + + _apiService = ApiService( + baseUrl: 'https://api.revoland.com', + token: context.token, + ); + + setState(() => _isReady = true); + + // Notify SuperApp that SubApp is ready + _bridge.sendMessage({ + 'action': 'subappReady', + 'subAppId': context.subAppId, + 'timestamp': DateTime.now().toIso8601String(), + }); + } + + @override + Widget build(BuildContext context) { + if (!_isReady) { + return const MaterialApp( + home: Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ), + ); + } + + return MaterialApp( + title: 'Property Listings', + theme: ThemeData( + primaryColor: Color( + int.parse(_bridge.getSuperAppContext()?.theme['primaryColor'] + ?.replaceFirst('#', '0xFF') ?? '0xFF2563eb'), + ), + brightness: _bridge.getSuperAppContext()?.theme['isDarkMode'] == true + ? Brightness.dark + : Brightness.light, + ), + home: ListingsScreen( + apiService: _apiService, + bridge: _bridge, + ), + ); + } +} +``` + +```dart +// property_listings/lib/services/revoland_bridge.dart +import 'dart:convert'; +import 'dart:html' as html; +import 'package:flutter/foundation.dart'; + +class SuperAppContext { + final String token; + final String hostAppId; + final String platform; + final String userId; + final String userRole; + final String subAppId; + final Map theme; + final String locale; + + SuperAppContext({ + required this.token, + required this.hostAppId, + required this.platform, + required this.userId, + required this.userRole, + required this.subAppId, + required this.theme, + required this.locale, + }); + + factory SuperAppContext.fromJson(Map json) { + return SuperAppContext( + token: json['token'] as String, + hostAppId: json['hostAppId'] as String, + platform: json['platform'] as String, + userId: json['userId'] as String, + userRole: json['userRole'] as String, + subAppId: json['subAppId'] as String, + theme: json['theme'] as Map, + locale: json['locale'] as String, + ); + } +} + +class RevoLandBridge { + SuperAppContext? _context; + final List)> _messageListeners = []; + + Future initialize() async { + // Try to get context from window object + _context = _getContextFromWindow(); + + if (_context == null) { + // Try to get from URL params + _context = _getContextFromUrl(); + } + + // Set up callback for when context is injected + _setupContextCallback(); + + debugPrint('RevoLand Bridge initialized with context: $_context'); + } + + SuperAppContext? _getContextFromWindow() { + try { + final contextObj = html.window['__REVOLAND_CONTEXT__']; + if (contextObj != null) { + final Map contextMap = {}; + final keys = js_util.getProperty(contextObj, 'keys'); + // Convert JS object to Dart Map + // This is a simplified version - actual implementation may vary + return SuperAppContext.fromJson(contextMap); + } + } catch (e) { + debugPrint('Error getting context from window: $e'); + } + return null; + } + + SuperAppContext? _getContextFromUrl() { + try { + final uri = Uri.parse(html.window.location.href); + final token = uri.queryParameters['token']; + final hostAppId = uri.queryParameters['hostAppId']; + + if (token != null && hostAppId != null) { + // Create minimal context from URL params + return SuperAppContext( + token: token, + hostAppId: hostAppId, + platform: 'web', + userId: '', + userRole: '', + subAppId: '', + theme: {'primaryColor': '#2563eb', 'isDarkMode': false}, + locale: 'en', + ); + } + } catch (e) { + debugPrint('Error getting context from URL: $e'); + } + return null; + } + + void _setupContextCallback() { + html.window['onRevoLandContextReady'] = (dynamic context) { + try { + // Parse context and update + debugPrint('Context received from SuperApp'); + } catch (e) { + debugPrint('Error in context callback: $e'); + } + }; + } + + SuperAppContext? getSuperAppContext() => _context; + + void sendMessage(Map message) { + try { + final nativeBridge = html.window['RevoLandNativeBridge']; + if (nativeBridge != null) { + final messageStr = jsonEncode(message); + // Call native bridge + // nativeBridge.postMessage(messageStr); + debugPrint('Sending message to SuperApp: $messageStr'); + } else { + debugPrint('Native bridge not available'); + } + } catch (e) { + debugPrint('Error sending message: $e'); + } + } + + void requestLocation() { + sendMessage({ + 'action': 'requestLocation', + 'timestamp': DateTime.now().toIso8601String(), + }); + } + + void shareProperty(String propertyId, String propertyUrl) { + sendMessage({ + 'action': 'shareProperty', + 'propertyId': propertyId, + 'propertyUrl': propertyUrl, + 'timestamp': DateTime.now().toIso8601String(), + }); + } + + void openMap(double latitude, double longitude) { + sendMessage({ + 'action': 'openMap', + 'latitude': latitude, + 'longitude': longitude, + 'timestamp': DateTime.now().toIso8601String(), + }); + } + + void navigateBack() { + sendMessage({ + 'action': 'navigateBack', + 'timestamp': DateTime.now().toIso8601String(), + }); + } +} +``` + +```dart +// property_listings/lib/services/api_service.dart +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class Property { + final String id; + final String title; + final String address; + final double price; + final int bedrooms; + final int bathrooms; + final double sqft; + final String imageUrl; + final double latitude; + final double longitude; + + Property({ + required this.id, + required this.title, + required this.address, + required this.price, + required this.bedrooms, + required this.bathrooms, + required this.sqft, + required this.imageUrl, + required this.latitude, + required this.longitude, + }); + + factory Property.fromJson(Map json) { + return Property( + id: json['id'] as String, + title: json['title'] as String, + address: json['address'] as String, + price: (json['price'] as num).toDouble(), + bedrooms: json['bedrooms'] as int, + bathrooms: json['bathrooms'] as int, + sqft: (json['sqft'] as num).toDouble(), + imageUrl: json['imageUrl'] as String, + latitude: (json['latitude'] as num).toDouble(), + longitude: (json['longitude'] as num).toDouble(), + ); + } +} + +class ApiService { + final String baseUrl; + final String token; + + ApiService({ + required this.baseUrl, + required this.token, + }); + + Map get _headers => { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }; + + Future> fetchProperties({ + String? city, + double? minPrice, + double? maxPrice, + int? minBedrooms, + }) async { + final queryParams = {}; + if (city != null) queryParams['city'] = city; + if (minPrice != null) queryParams['minPrice'] = minPrice.toString(); + if (maxPrice != null) queryParams['maxPrice'] = maxPrice.toString(); + if (minBedrooms != null) queryParams['minBedrooms'] = minBedrooms.toString(); + + final uri = Uri.parse('$baseUrl/api/v1/properties').replace( + queryParameters: queryParams.isEmpty ? null : queryParams, + ); + + final response = await http.get(uri, headers: _headers); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final List propertiesJson = data['properties']; + return propertiesJson.map((json) => Property.fromJson(json)).toList(); + } else if (response.statusCode == 401) { + throw Exception('Unauthorized - token expired'); + } else { + throw Exception('Failed to load properties'); + } + } + + Future getPropertyDetails(String propertyId) async { + final uri = Uri.parse('$baseUrl/api/v1/properties/$propertyId'); + final response = await http.get(uri, headers: _headers); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return Property.fromJson(data['property']); + } else if (response.statusCode == 401) { + throw Exception('Unauthorized - token expired'); + } else { + throw Exception('Failed to load property details'); + } + } +} +``` + +```dart +// property_listings/lib/screens/listings_screen.dart +import 'package:flutter/material.dart'; +import '../services/api_service.dart'; +import '../services/revoland_bridge.dart'; + +class ListingsScreen extends StatefulWidget { + final ApiService apiService; + final RevoLandBridge bridge; + + const ListingsScreen({ + Key? key, + required this.apiService, + required this.bridge, + }) : super(key: key); + + @override + State createState() => _ListingsScreenState(); +} + +class _ListingsScreenState extends State { + List _properties = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadProperties(); + } + + Future _loadProperties() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final properties = await widget.apiService.fetchProperties(); + setState(() { + _properties = properties; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Property Listings'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => widget.bridge.navigateBack(), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center(child: Text('Error: $_error')) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _properties.length, + itemBuilder: (context, index) { + final property = _properties[index]; + return _PropertyCard( + property: property, + onTap: () => _showPropertyDetails(property), + onShare: () => widget.bridge.shareProperty( + property.id, + 'https://revoland.com/properties/${property.id}', + ), + onShowMap: () => widget.bridge.openMap( + property.latitude, + property.longitude, + ), + ); + }, + ), + ); + } + + void _showPropertyDetails(Property property) { + // Navigate to property details screen + } +} + +class _PropertyCard extends StatelessWidget { + final Property property; + final VoidCallback onTap; + final VoidCallback onShare; + final VoidCallback onShowMap; + + const _PropertyCard({ + required this.property, + required this.onTap, + required this.onShare, + required this.onShowMap, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: InkWell( + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.network( + property.imageUrl, + height: 200, + width: double.infinity, + fit: BoxFit.cover, + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + property.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + property.address, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + '\$${property.price.toStringAsFixed(0)}', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + _PropertyFeature( + icon: Icons.bed, + label: '${property.bedrooms} beds', + ), + const SizedBox(width: 16), + _PropertyFeature( + icon: Icons.bathroom, + label: '${property.bathrooms} baths', + ), + const SizedBox(width: 16), + _PropertyFeature( + icon: Icons.square_foot, + label: '${property.sqft.toStringAsFixed(0)} sqft', + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: const Icon(Icons.map), + onPressed: onShowMap, + ), + IconButton( + icon: const Icon(Icons.share), + onPressed: onShare, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _PropertyFeature extends StatelessWidget { + final IconData icon; + final String label; + + const _PropertyFeature({ + required this.icon, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 16), + const SizedBox(width: 4), + Text(label), + ], + ); + } +} +``` + +### 2.4 SubApp Backend API + +Backend service for SubApp data. + +```typescript +// src/middleware/subapp-auth.middleware.ts +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { JWTPayload } from '../types/subapp.types'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +export interface SubAppAuthRequest extends Request { + subAppAuth?: JWTPayload; +} + +export const authenticateSubApp = ( + req: SubAppAuthRequest, + res: Response, + next: NextFunction +): void => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + const token = authHeader.substring(7); + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; + + // Verify token hasn't expired + const now = Math.floor(Date.now() / 1000); + if (decoded.exp < now) { + res.status(401).json({ error: 'Token expired' }); + return; + } + + // Verify hostAppId + if (decoded.hostAppId !== 'revoland-mobile') { + res.status(403).json({ error: 'Invalid host app' }); + return; + } + + req.subAppAuth = decoded; + next(); + } catch (error) { + console.error('Token verification error:', error); + res.status(401).json({ error: 'Invalid token' }); + } +}; + +export const requirePermission = (permission: string) => { + return (req: SubAppAuthRequest, res: Response, next: NextFunction): void => { + if (!req.subAppAuth) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const { permissions } = req.subAppAuth; + + if (!permissions.includes(permission) && !permissions.includes('*')) { + res.status(403).json({ error: `Missing required permission: ${permission}` }); + return; + } + + next(); + }; +}; +``` + +```typescript +// src/routes/properties.routes.ts +import { Router } from 'express'; +import { SubAppAuthRequest, authenticateSubApp, requirePermission } from '../middleware/subapp-auth.middleware'; + +const router = Router(); + +// Mock property data +const properties = [ + { + id: 'prop-001', + title: 'Modern Downtown Condo', + address: '123 Main St, Downtown', + price: 450000, + bedrooms: 2, + bathrooms: 2, + sqft: 1200, + imageUrl: 'https://cdn.revoland.com/properties/prop-001.jpg', + latitude: 40.7128, + longitude: -74.0060, + description: 'Beautiful modern condo in the heart of downtown', + }, + { + id: 'prop-002', + title: 'Spacious Family Home', + address: '456 Oak Ave, Suburbia', + price: 650000, + bedrooms: 4, + bathrooms: 3, + sqft: 2500, + imageUrl: 'https://cdn.revoland.com/properties/prop-002.jpg', + latitude: 40.7589, + longitude: -73.9851, + description: 'Perfect family home with large backyard', + }, +]; + +// Get all properties +router.get( + '/', + authenticateSubApp, + requirePermission('read:properties'), + (req: SubAppAuthRequest, res) => { + try { + const { city, minPrice, maxPrice, minBedrooms } = req.query; + + let filtered = [...properties]; + + if (city) { + filtered = filtered.filter((p) => + p.address.toLowerCase().includes((city as string).toLowerCase()) + ); + } + + if (minPrice) { + filtered = filtered.filter((p) => p.price >= Number(minPrice)); + } + + if (maxPrice) { + filtered = filtered.filter((p) => p.price <= Number(maxPrice)); + } + + if (minBedrooms) { + filtered = filtered.filter((p) => p.bedrooms >= Number(minBedrooms)); + } + + res.json({ + success: true, + properties: filtered, + count: filtered.length, + userId: req.subAppAuth?.userId, + }); + } catch (error) { + console.error('Error fetching properties:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +// Get property by ID +router.get( + '/:propertyId', + authenticateSubApp, + requirePermission('read:properties'), + (req: SubAppAuthRequest, res) => { + try { + const { propertyId } = req.params; + const property = properties.find((p) => p.id === propertyId); + + if (!property) { + res.status(404).json({ error: 'Property not found' }); + return; + } + + res.json({ + success: true, + property, + }); + } catch (error) { + console.error('Error fetching property:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +export default router; +``` + +## 3. Workflow + +1. User logs into RevoLand SuperApp +2. SuperApp calls Metadata API to get available SubApps and launch tokens +3. User selects a SubApp (e.g., "Property Listings") +4. SuperApp initializes WebView with SubApp URL and injects context with short-lived JWT +5. SubApp reads token and calls backend API to fetch property data +6. Backend validates JWT and returns data if valid +7. SubApp and SuperApp exchange messages via JS Bridge for native features + +## 4. Security Mechanisms + +### 4.1 Short-lived JWT Tokens +- Each SubApp launch generates a new JWT with 5-minute expiration +- Token contains: userId, hostAppId, subAppId, userRole, permissions, exp +- Tokens can be refreshed through Metadata API + +### 4.2 Backend Validation +- All SubApp API calls require valid JWT +- Backend verifies signature, expiration, and hostAppId +- Rejects requests with expired or invalid tokens + +### 4.3 Access Control +- CORS restricted to CDN domains only +- User-agent validation to confirm WebView origin +- Permission-based access to SubApps and features + +### 4.4 Secure Communication +- HTTPS enforced for all communications +- No sensitive data embedded in SubApp source code +- Minimal data exposure through JS Bridge + +## 5. Benefits + +- **Fast Updates**: Deploy SubApp updates without republishing SuperApp +- **Independent Development**: Teams work on SubApps in isolation +- **Security**: Dynamic tokens and server-side validation +- **Flexibility**: Add/remove SubApps via metadata without code changes +- **Scalability**: CDN hosting for SubApps ensures global performance + +## 6. Architecture Diagram + +``` +[User] + ↓ Login +[RevoLand SuperApp - Flutter Native] + ↓ HTTPS + Bearer Token +[Metadata API] → Returns SubApps + JWT tokens + ↓ +Display SubApp Menu (Property Listings, Virtual Tours, etc.) + ↓ User selects SubApp +[WebView] ↔ JS Bridge ↔ [SubApp - Flutter Web] + ↓ API calls with Bearer JWT +[SubApp Backend API] + ↓ Validate JWT → Return data +[User sees property listings, tours, calculators, etc.] +``` + +## 7. Example API Response + +**Metadata API Response:** + +```json +{ + "success": true, + "subApps": [ + { + "id": "property-listings", + "displayName": "Property Listings", + "description": "Browse available properties", + "iconUrl": "https://cdn.revoland.com/icons/listings.png", + "launchUrl": "https://cdn.revoland.com/subapps/property-listings/index.html", + "signedToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "permissions": ["read:properties", "search:properties"], + "version": "1.2.0", + "minHostVersion": "1.0.0" + }, + { + "id": "virtual-tours", + "displayName": "Virtual Tours", + "description": "360° property tours", + "iconUrl": "https://cdn.revoland.com/icons/virtual-tour.png", + "launchUrl": "https://cdn.revoland.com/subapps/virtual-tours/index.html", + "signedToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "permissions": ["read:properties", "view:tours"], + "version": "2.0.1", + "minHostVersion": "1.0.0" + }, + { + "id": "mortgage-calculator", + "displayName": "Mortgage Calculator", + "description": "Calculate mortgage payments", + "iconUrl": "https://cdn.revoland.com/icons/calculator.png", + "launchUrl": "https://cdn.revoland.com/subapps/mortgage-calculator/index.html", + "signedToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "permissions": ["calculate:mortgage"], + "version": "1.5.2", + "minHostVersion": "1.0.0" + }, + { + "id": "agent-dashboard", + "displayName": "Agent Dashboard", + "description": "Manage your properties and leads", + "iconUrl": "https://cdn.revoland.com/icons/dashboard.png", + "launchUrl": "https://cdn.revoland.com/subapps/agent-dashboard/index.html", + "signedToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "permissions": ["manage:properties", "manage:leads", "view:analytics"], + "version": "3.1.0", + "minHostVersion": "1.0.0" + } + ], + "timestamp": "2025-10-23T10:30:00Z" +} +``` + +**SubApp API Response (Properties):** + +```json +{ + "success": true, + "properties": [ + { + "id": "prop-001", + "title": "Modern Downtown Condo", + "address": "123 Main St, Downtown", + "price": 450000, + "bedrooms": 2, + "bathrooms": 2, + "sqft": 1200, + "imageUrl": "https://cdn.revoland.com/properties/prop-001.jpg", + "latitude": 40.7128, + "longitude": -74.0060, + "description": "Beautiful modern condo in the heart of downtown" + }, + { + "id": "prop-002", + "title": "Spacious Family Home", + "address": "456 Oak Ave, Suburbia", + "price": 650000, + "bedrooms": 4, + "bathrooms": 3, + "sqft": 2500, + "imageUrl": "https://cdn.revoland.com/properties/prop-002.jpg", + "latitude": 40.7589, + "longitude": -73.9851, + "description": "Perfect family home with large backyard" + } + ], + "count": 2, + "userId": "user-123" +} +``` + +--- + +**Document Version:** 1.0 +**Last Updated:** October 23, 2025 +**Project:** RevoLand - Real Estate SuperApp Platform + diff --git a/concept.txt b/concept.txt new file mode 100644 index 0000000..0b9ee87 --- /dev/null +++ b/concept.txt @@ -0,0 +1,136 @@ +Tài liệu khái niệm: Kiến trúc SuperApp Runtime Modularization với Cơ chế Bảo Mật + +1. Mục tiêu +Xây dựng một ứng dụng SuperApp trên nền tảng Flutter (iOS và Android) có khả năng tải và chạy các SubApp từ xa thông qua WebView tại thời điểm runtime. Mỗi SubApp nằm trong một repository riêng, được triển khai dạng Flutter Web và lưu trữ trên máy chủ hoặc CDN, cho phép cập nhật mà không cần phát hành lại SuperApp. Hệ thống phải có cơ chế bảo mật để ngăn chặn việc truy cập trái phép và đảm bảo SubApp chỉ chạy đúng trong môi trường được phép. + +2. Thành phần kiến trúc + +2.1 SuperApp (Flutter Native) +- Chứa giao diện chính, điều hướng và dịch vụ dùng chung. +- Sau khi đăng nhập, gọi API Metadata để lấy danh sách SubApp khả dụng. +- Hiển thị menu động dựa trên dữ liệu trả về từ server. +- Khi người dùng chọn SubApp, mở WebView: + + Nạp URL SubApp từ metadata. + + Truyền token JWT ngắn hạn và các cấu hình qua query param hoặc inject vào WebView bằng JavascriptChannel. +- Thiết lập kênh liên lạc hai chiều giữa Native và SubApp. + +2.2 Metadata API (Backend) +- Xác thực người dùng gửi từ SuperApp. +- Trả về danh sách SubApp khả dụng gồm: + + id + + displayName + + iconUrl + + launchUrl + + signedToken (JWT ngắn hạn với thông tin userId, hostAppId, subAppId, exp) +- API bảo mật bằng HTTPS và yêu cầu header Authorization từ SuperApp. + +2.3 SubApp (Flutter Web hoặc Web Bundle) +- Phát triển và triển khai độc lập từ repository riêng, build dạng Flutter Web. +- Đọc token và cấu hình từ biến window.__SUPERAPP_CONTEXT__ hoặc query param. +- Gọi API backend với Authorization: Bearer token từ SuperApp. +- Gửi sự kiện hoặc dữ liệu về Native qua JS Bridge. + +2.4 Backend SubApp API +- Xác thực token JWT cho mọi request. +- Kiểm tra các claim: userId, hostAppId, subAppId, exp. +- Từ chối truy cập nếu token không hợp lệ hoặc hết hạn. +- Áp dụng CORS, giới hạn origin và/hoặc user-agent. + +3. Quy trình hoạt động +1. Người dùng đăng nhập vào SuperApp. +2. SuperApp gọi Metadata API nhận danh sách SubApp và token launch. +3. Người dùng chọn SubApp từ menu. +4. SuperApp khởi tạo WebView với URL = launchUrl + token ngắn hạn, đồng thời inject biến JS chứa context (token, hostAppId, theme). +5. SubApp đọc token và gọi API backend lấy dữ liệu. +6. Backend xác thực token, trả dữ liệu nếu hợp lệ. +7. Trạng thái và sự kiện trao đổi hai chiều qua JS Bridge giữa Native và SubApp. + +4. Cơ chế bảo mật + +4.1 JWT ngắn hạn +- Mỗi lần mở SubApp, backend tạo token JWT mới với thời hạn ngắn, ví dụ 5 phút. +- Token chứa các claim: userId, hostAppId, subAppId, exp. + +4.2 Xác thực Backend +- Bắt buộc mọi API gọi từ SubApp phải có token hợp lệ. +- Backend kiểm tra chữ ký và claim. +- Reject request nếu token hết hạn hoặc sai hostAppId. + +4.3 Hạn chế nguồn truy cập +- CORS chỉ cho phép origin từ domain CDN của SubApp. +- Kiểm tra user-agent để xác nhận request xuất phát từ WebView SuperApp. + +4.4 Giao tiếp an toàn +- Luôn dùng HTTPS. +- Không nhúng dữ liệu nhạy cảm vào mã nguồn SubApp. +- Chỉ truyền những thông tin cần thiết vào JS Bridge. + +5. Lợi ích +- Cập nhật SubApp nhanh, không cần phát hành lại SuperApp. +- Các nhóm phát triển SubApp làm việc độc lập. +- Bảo mật kiểm soát qua token động và xác thực server. +- Linh hoạt, dễ thêm/bớt SubApp qua metadata mà không thay đổi code SuperApp. + +6. Sơ đồ kiến trúc mô tả dạng chữ +[Người dùng] +↓ Đăng nhập +[SuperApp Flutter] +↓ Fetch Metadata API (HTTPS + Auth) +[Metadata API Backend] → Danh sách SubApp + JWT ngắn hạn +↓ +Hiển thị menu SubApp +↓ Người dùng chọn SubApp +[WebView] ↔ JS Bridge ↔ [SubApp Flutter Web] +↓ +Gọi API Backend (Bearer JWT) +[SubApp Backend API] ← Xác thực JWT → Trả dữ liệu + +7. Ví dụ code + +Ví dụ Metadata API trả JSON: +[ + { + "id": "chat", + "displayName": "Chat", + "iconUrl": "https://cdn.domain.com/icons/chat.png", + "launchUrl": "https://cdn.domain.com/chat/index.html", + "signedToken": "JWT_STRING" + }, + { + "id": "payments", + "displayName": "Payments", + "iconUrl": "https://cdn.domain.com/icons/payments.png", + "launchUrl": "https://cdn.domain.com/payments/index.html", + "signedToken": "JWT_STRING" + } +] + +Ví dụ trong SuperApp Flutter tạo WebView: +WebViewController controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..addJavaScriptChannel('NativeBridge', onMessageReceived: (msg) { + print("Nhận từ SubApp: ${msg.message}"); + }) + ..setNavigationDelegate(NavigationDelegate(onPageFinished: (url) { + controller.runJavaScript(""" + window.__SUPERAPP_CONTEXT__ = { + token: "${signedToken}", + hostAppId: "superapp-ios-android", + theme: "light" + }; + if(window.onSuperAppContext) window.onSuperAppContext(window.__SUPERAPP_CONTEXT__); + """); + })) + ..loadRequest(Uri.parse("${launchUrl}?token=${signedToken}&hostAppId=superapp-ios-android")); + +Ví dụ trong SubApp (JS/Flutter Web): +let token = (window.__SUPERAPP_CONTEXT__ && window.__SUPERAPP_CONTEXT__.token) + || new URL(window.location.href).searchParams.get('token'); + +fetch('https://api.domain.com/chat/messages', { + headers: { 'Authorization': 'Bearer ' + token } +}).then(r => r.json()).then(data => { + console.log("Chat messages:", data); +}); + +window.NativeBridge.sendMessage(JSON.stringify({action: 'miniappLoaded'})); \ No newline at end of file diff --git a/reference.txt b/reference.txt new file mode 100644 index 0000000..6868af1 --- /dev/null +++ b/reference.txt @@ -0,0 +1,4 @@ +https://developer.apple.com/app-store/review/guidelines/#third-party-software - Apple's regulations on mini apps and sub apps +https://miniapp.zaloplatforms.com/community/1472418623687562947/nhung-app-flutter-vao-miniapp - Zalo's guide on how to embed mini apps (for reference only) +https://www.facebook.com/groups/fluttervn/posts/1666571620545936/ - Flutter sub app discussion topic on Facebook +https://medium.com/@vishalkusinha7301/micro-apps-in-flutter-a-scalable-architecture-for-super-apps-and-modular-teams-822e30b2557d - Micro-Apps in Flutter \ No newline at end of file