1660 lines
44 KiB
Plaintext
1660 lines
44 KiB
Plaintext
# 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<String> 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<String, dynamic> 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<String>.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<List<SubApp>> fetchAvailableSubApps() async {
|
|
try {
|
|
final response = await _dio.get('/api/v1/subapps/available');
|
|
|
|
if (response.statusCode == 200) {
|
|
final List<dynamic> 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<String> 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<SubAppLauncher> createState() => _SubAppLauncherState();
|
|
}
|
|
|
|
class _SubAppLauncherState extends State<SubAppLauncher> {
|
|
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<String, dynamic> data) {
|
|
// Request device location and send back to SubApp
|
|
// Implementation depends on location service
|
|
}
|
|
|
|
void _handleShareProperty(Map<String, dynamic> data) {
|
|
final propertyId = data['propertyId'] as String?;
|
|
final propertyUrl = data['propertyUrl'] as String?;
|
|
// Trigger native share dialog
|
|
}
|
|
|
|
void _handleOpenMap(Map<String, dynamic> 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<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen> {
|
|
late MetadataService _metadataService;
|
|
List<SubApp> _subApps = [];
|
|
bool _isLoading = true;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_metadataService = MetadataService(
|
|
baseUrl: 'https://api.revoland.com',
|
|
authToken: widget.authToken,
|
|
);
|
|
_loadSubApps();
|
|
}
|
|
|
|
Future<void> _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<string, Omit<SubAppMetadata, 'signedToken'>> = 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<string, string[]> = {
|
|
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<PropertyListingsApp> createState() => _PropertyListingsAppState();
|
|
}
|
|
|
|
class _PropertyListingsAppState extends State<PropertyListingsApp> {
|
|
late RevoLandBridge _bridge;
|
|
late ApiService _apiService;
|
|
bool _isReady = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initializeSubApp();
|
|
}
|
|
|
|
Future<void> _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<String, dynamic> 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<String, dynamic> 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<String, dynamic>,
|
|
locale: json['locale'] as String,
|
|
);
|
|
}
|
|
}
|
|
|
|
class RevoLandBridge {
|
|
SuperAppContext? _context;
|
|
final List<Function(Map<String, dynamic>)> _messageListeners = [];
|
|
|
|
Future<void> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, String> get _headers => {
|
|
'Authorization': 'Bearer $token',
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
Future<List<Property>> fetchProperties({
|
|
String? city,
|
|
double? minPrice,
|
|
double? maxPrice,
|
|
int? minBedrooms,
|
|
}) async {
|
|
final queryParams = <String, String>{};
|
|
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<dynamic> 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<Property> 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<ListingsScreen> createState() => _ListingsScreenState();
|
|
}
|
|
|
|
class _ListingsScreenState extends State<ListingsScreen> {
|
|
List<Property> _properties = [];
|
|
bool _isLoading = true;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadProperties();
|
|
}
|
|
|
|
Future<void> _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
|
|
|