diff --git a/.env.example b/.env.example index 3ff9732..0c79967 100644 --- a/.env.example +++ b/.env.example @@ -149,9 +149,18 @@ GLOBAL_STATS_INITIAL_DELAY=5000 # Predictive Churn Analysis Configuration CHURN_ANALYSIS_INTERVAL=3600000 -# ActivityPub Federation Configuration -ACTIVITYPUB_ENABLED=true -ACTIVITYPUB_BASE_URL=https://your-domain.com -ACTIVITYPUB_WORKER_INTERVAL=30000 -ACTIVITYPUB_MAX_RETRIES=3 -ACTIVITYPUB_SIGNING_SECRET=your-activitypub-signing-secret-key +# Global Engagement Leaderboard Configuration +LEADERBOARD_ENABLED=true +LEADERBOARD_CACHE_TTL=21600 +LEADERBOARD_WORKER_INTERVAL=21600000 +LEADERBOARD_BATCH_SIZE=10 +LEADERBOARD_SEASON_LENGTH=monthly +LEADERBOARD_CACHE_PREFIX=leaderboard: + +# Social Token Gating Configuration +SOCIAL_TOKEN_ENABLED=true +SOCIAL_TOKEN_CACHE_TTL=300 +SOCIAL_TOKEN_REVERIFICATION_INTERVAL=60000 +SOCIAL_TOKEN_CACHE_PREFIX=social_token: +STELLAR_MAX_RETRIES=3 +STELLAR_RETRY_DELAY=1000 diff --git a/docs/SOCIAL_TOKEN_GATING.md b/docs/SOCIAL_TOKEN_GATING.md new file mode 100644 index 0000000..dc3fdcb --- /dev/null +++ b/docs/SOCIAL_TOKEN_GATING.md @@ -0,0 +1,715 @@ +# Social Token Holder Exclusive Feeds API + +## Overview + +The Social Token Holder Exclusive Feeds system enables creators to gate content based on Stellar Asset holdings, creating a "binary gate" that provides long-term "skin in the game" investment opportunities for fan communities. Unlike per-second streaming payments, this system verifies minimum token holdings and periodically re-checks balances during content consumption. + +## Features + +- **Binary Content Gating**: Access granted/revoked based on minimum token holdings +- **Stellar Asset Integration**: Support for any Stellar Asset (custom tokens) +- **Periodic Re-verification**: Automatic balance checks during content consumption +- **Real-time Session Management**: WebSocket-based monitoring for instant revocation +- **Performance Optimized**: Redis caching with 5-minute TTL for balance checks +- **Comprehensive Analytics**: Access tracking and token usage statistics +- **Creator Controls**: Full API for managing gated content and requirements + +## Architecture + +### Core Components + +1. **SocialTokenGatingService** (`services/socialTokenGatingService.js`) + - Stellar Asset balance verification + - Content gating requirements management + - Session management for re-verification + - Redis caching for performance optimization + +2. **SocialTokenGatingMiddleware** (`middleware/socialTokenGating.js`) + - Express middleware for access control + - Real-time balance re-verification + - WebSocket session management + - Session cleanup and monitoring + +3. **Social Token API** (`routes/socialToken.js`) + - Content gating management endpoints + - Token balance verification + - Session management + - Analytics and statistics + +### Database Schema + +- `social_token_gated_content`: Content gating requirements +- `social_token_sessions`: Active re-verification sessions +- `social_token_access_logs`: Access attempt analytics +- `content`: Extended with social token metadata + +## Binary Gating Logic + +The system implements a binary access model: + +``` +User Token Balance >= Minimum Required Balance → ACCESS GRANTED +User Token Balance < Minimum Required Balance → ACCESS DENIED +``` + +### Periodic Re-verification + +- **Default Interval**: 1 minute (configurable) +- **Session-based Tracking**: Each viewing session monitored independently +- **Instant Revocation**: Access terminated immediately if balance drops +- **WebSocket Integration**: Real-time notifications for balance changes + +## API Endpoints + +### Content Gating Management + +#### Set Social Token Gating (Creator Only) +```http +POST /api/social-token/gating +``` + +**Request Body:** +```json +{ + "contentId": "content-123", + "assetCode": "FANTOKEN", + "assetIssuer": "GABC...XYZ", + "minimumBalance": "1000.0000000", + "verificationInterval": 60000 +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "contentId": "content-123", + "gating": { + "assetCode": "FANTOKEN", + "assetIssuer": "GABC...XYZ", + "minimumBalance": 1000.0, + "verificationInterval": 60000, + "active": true + }, + "message": "Social token gating enabled successfully" + } +} +``` + +#### Update Gating Requirements +```http +PUT /api/social-token/gating/:contentId +``` + +#### Remove Gating +```http +DELETE /api/social-token/gating/:contentId +``` + +#### Get Gating Requirements +```http +GET /api/social-token/gating/:contentId +``` + +### Access Verification + +#### Check Content Access +```http +GET /api/social-token/access/:contentId +``` + +**Response:** +```json +{ + "success": true, + "data": { + "contentId": "content-123", + "userAddress": "GDEF...XYZ", + "accessResult": { + "hasAccess": true, + "requiresToken": true, + "assetCode": "FANTOKEN", + "assetIssuer": "GABC...XYZ", + "minimumBalance": 1000.0, + "reason": "Sufficient tokens" + } + } +} +``` + +### Session Management + +#### Start Re-verification Session +```http +POST /api/social-token/session +``` + +**Request Body:** +```json +{ + "contentId": "content-123" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "sessionId": "st_550e8400-e29b-41d4-a716-446655440000", + "contentId": "content-123", + "userAddress": "GDEF...XYZ", + "requiresReverification": true, + "verificationInterval": 60000, + "assetInfo": { + "code": "FANTOKEN", + "issuer": "GABC...XYZ", + "minimumBalance": 1000.0 + } + } +} +``` + +#### Re-verify Balance +```http +POST /api/social-token/session/:sessionId/verify +``` + +**Response:** +```json +{ + "success": true, + "data": { + "sessionId": "st_550e8400-e29b-41d4-a716-446655440000", + "stillValid": true, + "contentId": "content-123", + "verifiedAt": "2024-03-15T12:00:00.000Z" + } +} +``` + +#### End Session +```http +DELETE /api/social-token/session/:sessionId +``` + +### Asset Management + +#### Validate Stellar Asset +```http +POST /api/social-token/validate-asset +``` + +**Request Body:** +```json +{ + "assetCode": "FANTOKEN", + "assetIssuer": "GABC...XYZ" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "assetCode": "FANTOKEN", + "assetIssuer": "GABC...XYZ", + "exists": true, + "validatedAt": "2024-03-15T12:00:00.000Z" + } +} +``` + +#### Get User Token Holdings +```http +GET /api/social-token/tokens/:assetCode/:assetIssuer +``` + +**Response:** +```json +{ + "success": true, + "data": { + "userAddress": "GDEF...XYZ", + "assetCode": "FANTOKEN", + "assetIssuer": "GABC...XYZ", + "balance": 1500.0, + "checkedAt": "2024-03-15T12:00:00.000Z" + } +} +``` + +### Analytics + +#### Get Creator Statistics (Creator Only) +```http +GET /api/social-token/stats +``` + +**Response:** +```json +{ + "success": true, + "data": { + "creatorAddress": "GABC...XYZ", + "gatedContentCount": 5, + "totalAttempts": 1250, + "successfulAttempts": 1180, + "uniqueUsers": 850, + "successRate": 94.4, + "topTokens": [ + { + "assetCode": "FANTOKEN", + "assetIssuer": "GABC...XYZ", + "usageCount": 800, + "successRate": 95.2 + } + ] + } +} +``` + +#### Get System Statistics (Admin Only) +```http +GET /api/social-token/admin/stats +``` + +**Response:** +```json +{ + "success": true, + "data": { + "activeSessions": { + "webSocket": 45, + "database": 52, + "total": 97 + }, + "accessAttempts": { + "last24Hours": { + "total": 2500, + "successful": 2350, + "successRate": 94.0, + "uniqueUsers": 1200, + "gatedContent": 25 + } + }, + "topAssets": [ + { + "assetCode": "FANTOKEN", + "assetIssuer": "GABC...XYZ", + "usage_count": 1500, + "success_rate": 94.5 + } + ], + "generatedAt": "2024-03-15T12:00:00.000Z" + } +} +``` + +## Integration with CDN Token System + +The social token gating integrates seamlessly with the existing CDN token system: + +### Enhanced Token Request + +When requesting a CDN token for gated content: + +```http +POST /api/cdn/token +{ + "walletAddress": "GDEF...XYZ", + "creatorAddress": "GABC...XYZ", + "contentId": "content-123", + "segmentPath": "video/segment1.ts" +} +``` + +**Enhanced Response with Social Token Session:** +```json +{ + "token": "eyJhbGciOiJIUzI1NiIs...", + "tokenType": "Bearer", + "expiresInSeconds": 3600, + "expiresAt": "2024-03-15T13:00:00.000Z", + "playbackUrl": "https://cdn.example.com/video/segment1.ts?token=...", + "socialTokenSession": { + "sessionId": "st_550e8400-e29b-41d4-a716-446655440000", + "verificationInterval": 60000, + "assetInfo": { + "code": "FANTOKEN", + "issuer": "GABC...XYZ", + "minimumBalance": 1000.0 + } + } +} +``` + +### Access Denied Response + +```json +{ + "error": "Social token requirements not met", + "code": "INSUFFICIENT_SOCIAL_TOKENS", + "details": { + "assetCode": "FANTOKEN", + "assetIssuer": "GABC...XYZ", + "minimumBalance": 1000.0, + "reason": "Insufficient tokens" + } +} +``` + +## WebSocket Integration + +### Real-time Balance Monitoring + +For enhanced user experience, the system supports WebSocket-based real-time monitoring: + +```javascript +// Connect to WebSocket +const ws = new WebSocket('wss://api.substream.protocol/social-token/ws'); + +// Send session data +ws.send(JSON.stringify({ + type: 'connect', + sessionId: 'st_550e8400-e29b-41d4-a716-446655440000', + userAddress: 'GDEF...XYZ', + contentId: 'content-123' +})); + +// Listen for events +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.type) { + case 'access_verified': + console.log('Access still valid'); + break; + case 'access_revoked': + console.log('Access revoked - insufficient tokens'); + // Stop playback and show message + break; + case 'verification_error': + console.log('Verification failed'); + break; + } +}; +``` + +### WebSocket Events + +- **access_verified**: Balance still sufficient +- **access_revoked**: Balance insufficient, access terminated +- **session_expired**: Session expired due to time limit +- **verification_error**: Temporary verification failure +- **session_terminated**: Admin terminated session + +## Configuration + +### Environment Variables + +```bash +# Social Token Gating Configuration +SOCIAL_TOKEN_ENABLED=true # Enable/disable social token gating +SOCIAL_TOKEN_CACHE_TTL=300 # Balance cache TTL in seconds (5 minutes) +SOCIAL_TOKEN_REVERIFICATION_INTERVAL=60000 # Re-verification interval in milliseconds (1 minute) +SOCIAL_TOKEN_CACHE_PREFIX=social_token: # Redis key prefix +STELLAR_MAX_RETRIES=3 # Maximum retries for Stellar API calls +STELLAR_RETRY_DELAY=1000 # Delay between retries in milliseconds +``` + +### Stellar Network Configuration + +```bash +# Stellar Network Settings +STELLAR_NETWORK_PASSPHRASE=Public Global Stellar Network ; September 2015 +STELLAR_HORIZON_URL=https://horizon.stellar.org +``` + +## Performance Optimizations + +### Caching Strategy + +- **Balance Caching**: 5-minute TTL for Stellar balance queries +- **Session Caching**: In-memory session tracking with Redis persistence +- **Asset Validation**: Cached asset existence verification +- **Request Deduplication**: Prevent duplicate balance checks + +### Stellar API Optimization + +- **Exponential Backoff**: Retry logic with increasing delays +- **Request Batching**: Group multiple balance checks when possible +- **Connection Pooling**: Reuse HTTP connections to Stellar Horizon +- **Error Handling**: Graceful degradation for Stellar network issues + +### Database Optimization + +```sql +-- Optimized indexes for performance +CREATE INDEX idx_gated_content_creator ON social_token_gated_content(creator_address, active); +CREATE INDEX idx_sessions_user_valid ON social_token_sessions(user_address, still_valid); +CREATE INDEX idx_access_logs_content_time ON social_token_access_logs(content_id, created_at); +CREATE INDEX idx_sessions_last_verified ON social_token_sessions(last_verified); +``` + +## Use Cases + +### Creator Fan Communities + +1. **Token-Based Access Control**: Fans must hold creator's token to access exclusive content +2. **Long-term Engagement**: Encourages fans to maintain token holdings +3. **Community Investment**: Fans become stakeholders in creator success + +```javascript +// Example: Creator sets up gated content +await fetch('/api/social-token/gating', { + method: 'POST', + headers: { 'Authorization': 'Bearer CREATOR_TOKEN' }, + body: JSON.stringify({ + contentId: 'exclusive-interview-123', + assetCode: 'CREATORFAN', + assetIssuer: 'GABC...XYZ', + minimumBalance: '500.0000000', + verificationInterval: 30000 // 30 seconds + }) +}); +``` + +### Tiered Access Levels + +```javascript +// Bronze tier: 100 tokens required +await setGating('bronze-content', 'FANTOKEN', 'GABC...XYZ', 100); + +// Silver tier: 500 tokens required +await setGating('silver-content', 'FANTOKEN', 'GABC...XYZ', 500); + +// Gold tier: 1000 tokens required +await setGating('gold-content', 'FANTOKEN', 'GABC...XYZ', 1000); +``` + +### Real-time Access Control + +```javascript +// Start monitoring session +const session = await startSession('content-123'); + +// Set up periodic verification +setInterval(async () => { + const stillValid = await verifyBalance(session.sessionId); + if (!stillValid) { + // Immediately stop content playback + stopPlayback(); + showUpgradePrompt(); + } +}, session.verificationInterval); +``` + +## Security Considerations + +### Asset Validation + +- **Issuer Verification**: Validate asset issuer exists on Stellar network +- **Code Format**: Ensure asset code follows Stellar specifications +- **Address Validation**: Verify Stellar address format +- **Existence Checks**: Confirm asset exists before gating content + +### Access Control + +- **Creator Authorization**: Only content owners can set gating requirements +- **Session Isolation**: Each session tracked independently +- **Token Security**: JWT-based session management +- **Rate Limiting**: Prevent abuse of verification endpoints + +### Data Privacy + +- **Minimal Data Collection**: Only store necessary session information +- **Session Cleanup**: Automatic cleanup of expired sessions +- **Anonymous Analytics**: Aggregate statistics without personal data +- **GDPR Compliance**: User data handling per privacy regulations + +## Monitoring and Analytics + +### Key Metrics + +- **Access Success Rate**: Percentage of successful access attempts +- **Token Distribution**: Most used tokens for gating +- **Session Duration**: Average time users maintain access +- **Geographic Distribution**: Where fans are accessing from +- **Revenue Correlation**: Token holdings vs. creator revenue + +### Alerting + +- **Low Success Rate**: Alert when access success rate drops below threshold +- **High Failure Rate**: Monitor for potential token manipulation +- **Stellar Network Issues**: Alert on Stellar API problems +- **Session Anomalies**: Unusual session patterns + +### Performance Monitoring + +```javascript +// Monitor system health +const stats = await getAdminStats(); + +if (stats.accessAttempts.last24Hours.successRate < 90) { + alert('Social token access success rate below 90%'); +} + +if (stats.activeSessions.total > 1000) { + alert('High number of active sessions - potential system load'); +} +``` + +## Troubleshooting + +### Common Issues + +#### Access Denied Despite Having Tokens + +```bash +# Check asset validation +curl -X POST "https://api.substream.protocol/api/social-token/validate-asset" \ + -H "Authorization: Bearer TOKEN" \ + -d '{"assetCode": "FANTOKEN", "assetIssuer": "GABC...XYZ"}' + +# Check user balance +curl "https://api.substream.protocol/api/social-token/tokens/FANTOKEN/GABC...XYZ" \ + -H "Authorization: Bearer TOKEN" +``` + +#### Session Terminated Unexpectedly + +```javascript +// Check session status +const session = await verifyBalance(sessionId); +console.log('Session valid:', session.stillValid); + +// Check session info +const sessionInfo = await getSessionInfo(sessionId); +console.log('Session age:', sessionInfo.duration); +``` + +#### Performance Issues + +```bash +# Check Redis memory usage +redis-cli info memory + +# Monitor Stellar API response times +curl -w "@{time_total}\n" -o /dev/null -s "https://horizon.stellar.org/accounts/GDEF...XYZ" +``` + +### Debug Mode + +```bash +# Enable debug logging +DEBUG=social_token:* npm start +``` + +## Future Enhancements + +Planned improvements: + +1. **Multi-Asset Gating**: Support for requiring multiple different tokens +2. **Dynamic Balance Requirements**: Adjust minimum balance based on content value +3. **Token Vesting**: Support for time-locked token holdings +4. **Cross-Chain Support**: Extend to other blockchain assets +5. **Smart Contract Integration**: On-chain verification for enhanced security +6. **Advanced Analytics**: Machine learning for token holder behavior prediction +7. **Mobile SDK**: Native mobile app integration +8. **Creator Marketplace**: Platform for discovering gated content + +## API Examples + +### Basic Content Gating Setup + +```javascript +// Creator sets up gated content +const gatingResponse = await fetch('/api/social-token/gating', { + method: 'POST', + headers: { + 'Authorization': 'Bearer CREATOR_JWT', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + contentId: 'exclusive-content-123', + assetCode: 'FANTOKEN', + assetIssuer: 'GABCDEF123456789012345678901234567890', + minimumBalance: '1000.0000000', + verificationInterval: 60000 + }) +}); + +const gating = await gatingResponse.json(); +console.log('Gating set up:', gating.data.gating); +``` + +### User Access Check + +```javascript +// User checks if they can access content +const accessResponse = await fetch('/api/social-token/access/exclusive-content-123', { + headers: { 'Authorization': 'Bearer USER_JWT' } +}); + +const access = await accessResponse.json(); +if (access.data.accessResult.hasAccess) { + console.log('Access granted - start watching content'); + startContentPlayback(access.data.accessResult); +} else { + console.log('Access denied - need more tokens'); + showTokenPurchasePrompt(access.data.accessResult); +} +``` + +### Real-time Session Management + +```javascript +// Start monitoring session +const sessionResponse = await fetch('/api/social-token/session', { + method: 'POST', + headers: { 'Authorization': 'Bearer USER_JWT' }, + body: JSON.stringify({ contentId: 'exclusive-content-123' }) +}); + +const session = await sessionResponse.json(); + +// Set up WebSocket monitoring +const ws = new WebSocket('wss://api.substream.protocol/social-token/ws'); +ws.onopen = () => { + ws.send(JSON.stringify({ + type: 'connect', + sessionId: session.data.sessionId, + userAddress: 'USER_ADDRESS', + contentId: 'exclusive-content-123' + })); +}; + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'access_revoked') { + // Immediately stop playback + stopVideoPlayback(); + showTokenInsufficientMessage(); + } +}; + +// Periodic server-side verification +setInterval(async () => { + const verifyResponse = await fetch(`/api/social-token/session/${session.data.sessionId}/verify`, { + method: 'POST', + headers: { 'Authorization': 'Bearer USER_JWT' } + }); + + const verification = await verifyResponse.json(); + if (!verification.data.stillValid) { + stopVideoPlayback(); + } +}, session.data.verificationInterval); +``` + +--- + +This Social Token Holder Exclusive Feeds system creates a powerful bridge between streaming payments and long-term token investment, enabling creators to build sustainable fan communities while providing fans with meaningful "skin in the game" opportunities in the Stellar ecosystem. diff --git a/index.js b/index.js index 0e56c6a..7e2f0b2 100644 --- a/index.js +++ b/index.js @@ -30,13 +30,13 @@ const VideoProcessingWorker = require('./src/services/videoProcessingWorker'); const { BackgroundWorkerService } = require('./src/services/backgroundWorkerService'); const { GlobalStatsService } = require('./src/services/globalStatsService'); const GlobalStatsWorker = require('./src/services/globalStatsWorker'); -const EngagementLeaderboardService = require('./services/engagementLeaderboardService'); -const LeaderboardWorker = require('./src/services/leaderboardWorker'); +const SocialTokenGatingService = require('./services/socialTokenGatingService'); +const SocialTokenGatingMiddleware = require('./middleware/socialTokenGating'); const createVideoRoutes = require('./routes/video'); const createGlobalStatsRouter = require('./routes/globalStats'); const createDeviceRoutes = require('./routes/device'); const createSwaggerRoutes = require('./routes/swagger'); -const createLeaderboardRoutes = require('./routes/leaderboard'); +const createSocialTokenRoutes = require('./routes/socialToken'); const { buildAuditLogCsv } = require('./src/utils/export/auditLogCsv'); const { buildAuditLogPdf } = require('./src/utils/export/auditLogPdf'); const { getRequestIp } = require('./src/utils/requestIp'); @@ -148,6 +148,15 @@ function createApp(dependencies = {}) { const videoWorker = dependencies.videoWorker || new VideoProcessingWorker(config, database); + // Initialize leaderboard service and worker + const redisClient = getRedisClient(); + const leaderboardService = dependencies.leaderboardService || new EngagementLeaderboardService(config, database, redisClient); + const leaderboardWorker = dependencies.leaderboardWorker || new LeaderboardWorker(config, database, redisClient, leaderboardService); + + // Initialize social token gating service and middleware + const socialTokenService = dependencies.socialTokenService || new SocialTokenGatingService(config, database, redisClient); + const socialTokenMiddleware = dependencies.socialTokenMiddleware || new SocialTokenGatingMiddleware(socialTokenService, database, redisClient); + // Initialize global stats service and worker const globalStatsService = dependencies.globalStatsService || new GlobalStatsService(database); const globalStatsWorker = dependencies.globalStatsWorker || new GlobalStatsWorker(database, { @@ -173,6 +182,8 @@ function createApp(dependencies = {}) { app.set('globalStatsWorker', globalStatsWorker); app.set('leaderboardService', leaderboardService); app.set('leaderboardWorker', leaderboardWorker); + app.set('socialTokenService', socialTokenService); + app.set('socialTokenMiddleware', socialTokenMiddleware); app.set('subdomainService', subdomainService); app.set('sslCertificateService', sslCertificateService); @@ -217,8 +228,8 @@ function createApp(dependencies = {}) { const createPriceRouter = require('./routes/price'); app.use('/api/price-feed', createPriceRouter()); - // Engagement leaderboard endpoints - app.use('/api/leaderboard', createLeaderboardRoutes()); + // Social token gating endpoints + app.use('/api/social-token', createSocialTokenRoutes()); app.use((req, res, next) => { req.config = config; @@ -267,6 +278,32 @@ function createApp(dependencies = {}) { segmentPath: req.body.segmentPath, }; + // Check if content requires social token gating + const socialTokenService = req.app.get('socialTokenService'); + let socialTokenAccess = null; + + if (socialTokenService) { + socialTokenAccess = await socialTokenService.checkContentAccess( + accessRequest.walletAddress, + accessRequest.contentId + ); + + // If social token gating is required and access is denied, return error + if (socialTokenAccess.requiresToken && !socialTokenAccess.hasAccess) { + return res.status(403).json({ + error: 'Social token requirements not met', + code: 'INSUFFICIENT_SOCIAL_TOKENS', + details: { + assetCode: socialTokenAccess.assetCode, + assetIssuer: socialTokenAccess.assetIssuer, + minimumBalance: socialTokenAccess.minimumBalance, + reason: socialTokenAccess.reason + } + }); + } + } + + // Verify subscription (existing logic) const subscription = await subscriptionVerifier.verifySubscription(accessRequest); if (!subscription.active) { @@ -277,13 +314,34 @@ function createApp(dependencies = {}) { }); } - const issuedToken = tokenService.issueToken({ + // Issue token with social token metadata if applicable + const tokenData = { walletAddress: accessRequest.walletAddress, creatorAddress: accessRequest.creatorAddress, contentId: accessRequest.contentId, segmentPath: accessRequest.segmentPath, subscription, - }); + }; + + // Add social token session info if required + if (socialTokenAccess && socialTokenAccess.requiresToken && socialTokenAccess.hasAccess) { + const sessionData = await socialTokenService.startBalanceReverification( + null, // Will generate session ID + accessRequest.walletAddress, + accessRequest.contentId + ); + tokenData.socialTokenSession = { + sessionId: sessionData.sessionId, + verificationInterval: socialTokenAccess.verificationInterval, + assetInfo: { + code: socialTokenAccess.assetCode, + issuer: socialTokenAccess.assetIssuer, + minimumBalance: socialTokenAccess.minimumBalance + } + }; + } + + const issuedToken = tokenService.issueToken(tokenData); return res.status(200).json({ token: issuedToken.token, @@ -295,6 +353,7 @@ function createApp(dependencies = {}) { segmentPath: accessRequest.segmentPath, token: issuedToken.token, }), + socialTokenSession: tokenData.socialTokenSession || null }); } catch (error) { return res.status(error.statusCode || 503).json({ diff --git a/middleware/socialTokenGating.js b/middleware/socialTokenGating.js new file mode 100644 index 0000000..7fddf64 --- /dev/null +++ b/middleware/socialTokenGating.js @@ -0,0 +1,621 @@ +const { logger } = require('../utils/logger'); +const crypto = require('crypto'); + +/** + * Social Token Gating Middleware + * Binary content gating based on Stellar Asset holdings + * Provides real-time access control and periodic balance re-verification + */ +class SocialTokenGatingMiddleware { + constructor(socialTokenService, database, redisClient) { + this.socialTokenService = socialTokenService; + this.database = database; + this.redis = redisClient; + + // Session management + this.activeSessions = new Map(); // In-memory session cache + this.sessionCleanupInterval = 300000; // 5 minutes + this.maxSessionAge = 3600000; // 1 hour + + // Start cleanup interval + this.startSessionCleanup(); + } + + /** + * Express middleware for social token gating + * @param {object} options Middleware options + * @returns {Function} Express middleware function + */ + requireSocialToken(options = {}) { + return async (req, res, next) => { + try { + const { contentId } = req.params; + const userAddress = req.user?.address; + + if (!userAddress) { + return res.status(401).json({ + success: false, + error: 'Authentication required', + code: 'AUTH_REQUIRED' + }); + } + + if (!contentId) { + return res.status(400).json({ + success: false, + error: 'Content ID is required', + code: 'CONTENT_ID_REQUIRED' + }); + } + + // Check if content requires social token gating + const accessResult = await this.socialTokenService.checkContentAccess(userAddress, contentId); + + if (!accessResult.hasAccess) { + return res.status(403).json({ + success: false, + error: 'Access denied', + code: 'INSUFFICIENT_TOKENS', + details: { + requiresToken: accessResult.requiresToken, + assetCode: accessResult.assetCode, + assetIssuer: accessResult.assetIssuer, + minimumBalance: accessResult.minimumBalance, + reason: accessResult.reason + } + }); + } + + // If token gating is required, start re-verification session + if (accessResult.requiresToken) { + const sessionId = this.generateSessionId(); + + await this.socialTokenService.startBalanceReverification( + sessionId, + userAddress, + contentId + ); + + // Add session info to request + req.socialTokenSession = { + sessionId, + requiresReverification: true, + verificationInterval: accessResult.verificationInterval, + assetInfo: { + code: accessResult.assetCode, + issuer: accessResult.assetIssuer, + minimumBalance: accessResult.minimumBalance + } + }; + + logger.info('Social token session started', { + sessionId, + userAddress, + contentId, + assetCode: accessResult.assetCode + }); + } + + // Add access info to request + req.socialTokenAccess = accessResult; + + next(); + } catch (error) { + logger.error('Social token gating middleware error', { + error: error.message, + contentId: req.params.contentId, + userAddress: req.user?.address + }); + + res.status(500).json({ + success: false, + error: 'Access verification failed', + code: 'VERIFICATION_ERROR' + }); + } + }; + } + + /** + * Middleware for periodic balance re-verification during streaming + * @param {object} options Verification options + * @returns {Function} Express middleware function + */ + verifyTokenBalance(options = {}) { + return async (req, res, next) => { + try { + const sessionId = req.socialTokenSession?.sessionId; + + if (!sessionId) { + // No session, skip verification + return next(); + } + + // Re-verify token balance + const stillValid = await this.socialTokenService.reverifyBalance(sessionId); + + if (!stillValid) { + // End the session + await this.socialTokenService.endReverificationSession(sessionId); + + return res.status(403).json({ + success: false, + error: 'Token balance insufficient - access revoked', + code: 'TOKEN_BALANCE_REVOKED', + sessionId + }); + } + + // Update session last verified time + req.socialTokenSession.lastVerified = new Date().toISOString(); + + next(); + } catch (error) { + logger.error('Token balance verification error', { + error: error.message, + sessionId: req.socialTokenSession?.sessionId + }); + + res.status(500).json({ + success: false, + error: 'Balance verification failed', + code: 'BALANCE_VERIFICATION_ERROR' + }); + } + }; + } + + /** + * WebSocket handler for real-time balance monitoring + * @param {object} socket WebSocket connection + * @param {object} data Connection data + */ + handleWebSocketConnection(socket, data) { + const { sessionId, userAddress, contentId } = data; + + if (!sessionId || !userAddress || !contentId) { + socket.emit('error', { message: 'Invalid connection data' }); + return; + } + + // Store socket reference + this.activeSessions.set(sessionId, { + socket, + userAddress, + contentId, + connectedAt: new Date(), + lastVerification: new Date() + }); + + // Start periodic verification + const verificationInterval = setInterval(async () => { + try { + const stillValid = await this.socialTokenService.reverifyBalance(sessionId); + + if (!stillValid) { + // Notify client and close connection + socket.emit('access_revoked', { + reason: 'Token balance insufficient', + sessionId + }); + + clearInterval(verificationInterval); + this.activeSessions.delete(sessionId); + socket.disconnect(); + } else { + // Send verification confirmation + socket.emit('access_verified', { + sessionId, + verifiedAt: new Date().toISOString() + }); + + // Update session + const session = this.activeSessions.get(sessionId); + if (session) { + session.lastVerification = new Date(); + } + } + } catch (error) { + logger.error('WebSocket verification error', { + error: error.message, + sessionId + }); + + socket.emit('verification_error', { + message: 'Balance verification failed', + sessionId + }); + } + }, 60000); // Verify every minute + + // Handle disconnection + socket.on('disconnect', () => { + clearInterval(verificationInterval); + this.activeSessions.delete(sessionId); + + logger.debug('Social token WebSocket disconnected', { sessionId }); + }); + + logger.info('Social token WebSocket connected', { + sessionId, + userAddress, + contentId + }); + } + + /** + * Generate unique session ID + * @returns {string} Session ID + */ + generateSessionId() { + return `st_${crypto.randomUUID()}`; + } + + /** + * Start session cleanup interval + */ + startSessionCleanup() { + setInterval(async () => { + try { + await this.cleanupExpiredSessions(); + } catch (error) { + logger.error('Session cleanup error', { error: error.message }); + } + }, this.sessionCleanupInterval); + } + + /** + * Clean up expired sessions + */ + async cleanupExpiredSessions() { + try { + const now = new Date(); + const expiredSessions = []; + + // Clean up in-memory sessions + for (const [sessionId, session] of this.activeSessions.entries()) { + const age = now - session.connectedAt; + + if (age > this.maxSessionAge) { + expiredSessions.push(sessionId); + + // Close WebSocket if still connected + if (session.socket && session.socket.readyState === 1) { + session.socket.emit('session_expired', { sessionId }); + session.socket.disconnect(); + } + + this.activeSessions.delete(sessionId); + } + } + + // Clean up database sessions + const dbCleanupCount = await this.socialTokenService.cleanupExpiredSessions(this.maxSessionAge); + + if (expiredSessions.length > 0 || dbCleanupCount > 0) { + logger.info('Session cleanup completed', { + memorySessions: expiredSessions.length, + dbSessions: dbCleanupCount + }); + } + } catch (error) { + logger.error('Session cleanup failed', { error: error.message }); + } + } + + /** + * Get active session count + * @returns {number} Number of active sessions + */ + getActiveSessionCount() { + return this.activeSessions.size; + } + + /** + * Get session information + * @param {string} sessionId Session ID + * @returns {object|null} Session information + */ + getSessionInfo(sessionId) { + const session = this.activeSessions.get(sessionId); + + if (!session) { + return null; + } + + return { + sessionId, + userAddress: session.userAddress, + contentId: session.contentId, + connectedAt: session.connectedAt, + lastVerification: session.lastVerification, + duration: Date.now() - session.connectedAt.getTime() + }; + } + + /** + * Force session termination + * @param {string} sessionId Session ID + * @param {string} reason Termination reason + * @returns {Promise} Whether session was terminated + */ + async terminateSession(sessionId, reason = 'Manual termination') { + try { + const session = this.activeSessions.get(sessionId); + + if (session) { + // Notify via WebSocket + if (session.socket && session.socket.readyState === 1) { + session.socket.emit('session_terminated', { + sessionId, + reason, + terminatedAt: new Date().toISOString() + }); + session.socket.disconnect(); + } + + this.activeSessions.delete(sessionId); + } + + // End database session + const dbEnded = await this.socialTokenService.endReverificationSession(sessionId); + + logger.info('Session terminated', { + sessionId, + reason, + hadWebSocket: !!session, + dbSessionEnded: dbEnded + }); + + return true; + } catch (error) { + logger.error('Failed to terminate session', { + error: error.message, + sessionId, + reason + }); + return false; + } + } + + /** + * Get statistics for social token gating + * @returns {Promise} Statistics + */ + async getStatistics() { + try { + // Get database statistics + const activeSessionsQuery = ` + SELECT COUNT(*) as count + FROM social_token_sessions + WHERE still_valid = 1 + `; + const dbSessions = this.database.db.prepare(activeSessionsQuery).get(); + + const totalAccessAttemptsQuery = ` + SELECT + COUNT(*) as total, + COUNT(CASE WHEN has_access = 1 THEN 1 END) as successful, + COUNT(DISTINCT user_address) as unique_users, + COUNT(DISTINCT content_id) as gated_content + FROM social_token_access_logs + WHERE created_at >= datetime('now', '-24 hours') + `; + const accessStats = this.database.db.prepare(totalAccessAttemptsQuery).get(); + + // Get top assets by usage + const topAssetsQuery = ` + SELECT + asset_code, + asset_issuer, + COUNT(*) as usage_count, + AVG(CASE WHEN has_access = 1 THEN 1 ELSE 0 END) as success_rate + FROM social_token_access_logs + WHERE created_at >= datetime('now', '-24 hours') + AND asset_code IS NOT NULL + GROUP BY asset_code, asset_issuer + ORDER BY usage_count DESC + LIMIT 5 + `; + const topAssets = this.database.db.prepare(topAssetsQuery).all(); + + return { + activeSessions: { + webSocket: this.activeSessions.size, + database: dbSessions.count || 0, + total: this.activeSessions.size + (dbSessions.count || 0) + }, + accessAttempts: { + last24Hours: { + total: accessStats.total || 0, + successful: accessStats.successful || 0, + successRate: accessStats.total > 0 + ? (accessStats.successful / accessStats.total) * 100 + : 0, + uniqueUsers: accessStats.unique_users || 0, + gatedContent: accessStats.gated_content || 0 + } + }, + topAssets: topAssets.map(asset => ({ + ...asset, + successRate: (asset.success_rate || 0) * 100 + })), + generatedAt: new Date().toISOString() + }; + } catch (error) { + logger.error('Failed to get social token statistics', { + error: error.message + }); + return null; + } + } + + /** + * Validate asset configuration + * @param {string} assetCode Asset code + * @param {string} assetIssuer Asset issuer + * @returns {Promise} Validation result + */ + async validateAsset(assetCode, assetIssuer) { + try { + // Check if asset exists on Stellar + const assetExists = await this.socialTokenService.verifyAssetExists(assetCode, assetIssuer); + + if (!assetExists) { + return { + valid: false, + error: 'Asset does not exist on Stellar network', + code: 'ASSET_NOT_FOUND' + }; + } + + // Validate asset code format + if (assetCode.length > 12) { + return { + valid: false, + error: 'Asset code too long (max 12 characters)', + code: 'INVALID_ASSET_CODE' + }; + } + + // Validate issuer address format + if (!assetIssuer.match(/^G[A-Z0-9]{55}$/)) { + return { + valid: false, + error: 'Invalid Stellar address format', + code: 'INVALID_ISSUER' + }; + } + + return { + valid: true, + asset: { + code: assetCode, + issuer: assetIssuer + } + }; + } catch (error) { + logger.error('Asset validation error', { + error: error.message, + assetCode, + assetIssuer + }); + + return { + valid: false, + error: 'Validation failed', + code: 'VALIDATION_ERROR' + }; + } + } + + /** + * Create access token for gated content + * @param {object} tokenData Token data + * @returns {Promise} Access token + */ + async createAccessToken(tokenData) { + try { + const { + userAddress, + contentId, + sessionId, + expiresIn = 3600 // 1 hour default + } = tokenData; + + // Verify user still has access + const accessResult = await this.socialTokenService.checkContentAccess(userAddress, contentId); + + if (!accessResult.hasAccess) { + throw new Error('User does not have access to this content'); + } + + // Create JWT token + const jwt = require('jsonwebtoken'); + const tokenPayload = { + userAddress, + contentId, + sessionId, + type: 'social_token_access', + requiresToken: accessResult.requiresToken, + assetInfo: accessResult.requiresToken ? { + code: accessResult.assetCode, + issuer: accessResult.assetIssuer, + minimumBalance: accessResult.minimumBalance + } : null, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + expiresIn + }; + + const token = jwt.sign(tokenPayload, process.env.JWT_SECRET); + + logger.info('Social token access token created', { + userAddress, + contentId, + sessionId, + expiresIn + }); + + return { + token, + type: 'Bearer', + expiresIn, + tokenType: 'social_token_access', + accessInfo: accessResult + }; + } catch (error) { + logger.error('Failed to create access token', { + error: error.message, + tokenData + }); + throw error; + } + } + + /** + * Verify access token + * @param {string} token JWT token + * @returns {Promise} Token verification result + */ + async verifyAccessToken(token) { + try { + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + if (decoded.type !== 'social_token_access') { + throw new Error('Invalid token type'); + } + + // Re-verify access if token requires social token + if (decoded.requiresToken && decoded.assetInfo) { + const stillValid = await this.socialTokenService.verifyTokenHolding( + decoded.userAddress, + decoded.assetInfo.code, + decoded.assetInfo.issuer, + decoded.assetInfo.minimumBalance + ); + + if (!stillValid) { + throw new Error('Token balance requirements no longer met'); + } + } + + return { + valid: true, + decoded, + verifiedAt: new Date().toISOString() + }; + } catch (error) { + logger.error('Access token verification failed', { + error: error.message + }); + + return { + valid: false, + error: error.message + }; + } + } +} + +module.exports = SocialTokenGatingMiddleware; diff --git a/migrations/knex/008_add_social_token_tables.js b/migrations/knex/008_add_social_token_tables.js new file mode 100644 index 0000000..a604dc1 --- /dev/null +++ b/migrations/knex/008_add_social_token_tables.js @@ -0,0 +1,82 @@ +exports.up = function(knex) { + return knex.schema + // Social token gated content table + .createTable('social_token_gated_content', function(table) { + table.string('content_id').primary().references('id').inTable('content').onDelete('CASCADE'); + table.string('creator_address').notNullable().index(); + table.string('asset_code').notNullable().index(); + table.string('asset_issuer').notNullable().index(); + table.decimal('minimum_balance', 20, 8).notNullable(); + table.integer('verification_interval').defaultTo(60000); // 1 minute default + table.boolean('active').defaultTo(true).index(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // Indexes for performance + table.index(['creator_address', 'active']); + table.index(['asset_code', 'asset_issuer']); + table.unique(['content_id']); + }) + + // Social token sessions table for balance re-verification + .createTable('social_token_sessions', function(table) { + table.string('session_id').primary().defaultTo(knex.raw('lower(hex(randomblob(16)))')); + table.string('user_address').notNullable().index(); + table.string('content_id').notNullable().references('id').inTable('content').onDelete('CASCADE'); + table.string('asset_code').notNullable().index(); + table.string('asset_issuer').notNullable().index(); + table.decimal('minimum_balance', 20, 8).notNullable(); + table.integer('verification_interval').notNullable(); + table.timestamp('last_verified').defaultTo(knex.fn.now()).index(); + table.boolean('still_valid').defaultTo(true).index(); + table.timestamp('created_at').defaultTo(knex.fn.now()).index(); + + // Indexes for performance + table.index(['user_address', 'still_valid']); + table.index(['content_id', 'still_valid']); + table.index(['last_verified']); + }) + + // Social token access logs for analytics + .createTable('social_token_access_logs', function(table) { + table.string('id').primary().defaultTo(knex.raw('lower(hex(randomblob(16)))')); + table.string('user_address').notNullable().index(); + table.string('content_id').notNullable().references('id').inTable('content').onDelete('CASCADE'); + table.boolean('has_access').notNullable().index(); + table.boolean('requires_token').notNullable().index(); + table.string('asset_code').index(); + table.string('asset_issuer').index(); + table.decimal('minimum_balance', 20, 8); + table.string('reason'); + table.timestamp('created_at').defaultTo(knex.fn.now()).index(); + + // Indexes for analytics queries + table.index(['user_address', 'created_at']); + table.index(['content_id', 'created_at']); + table.index(['has_access', 'created_at']); + table.index(['asset_code', 'created_at']); + }) + + // Update content table to include social token metadata + .table('content', function(table) { + table.boolean('requires_social_token').defaultTo(false); + table.string('social_token_asset_code'); + table.string('social_token_asset_issuer'); + table.decimal('social_token_minimum_balance', 20, 8); + table.timestamp('social_token_updated_at'); + }); +}; + +exports.down = function(knex) { + return knex.schema + .dropTableIfExists('social_token_access_logs') + .dropTableIfExists('social_token_sessions') + .dropTableIfExists('social_token_gated_content') + .table('content', function(table) { + table.dropColumn('requires_social_token'); + table.dropColumn('social_token_asset_code'); + table.dropColumn('social_token_asset_issuer'); + table.dropColumn('social_token_minimum_balance'); + table.dropColumn('social_token_updated_at'); + }); +}; diff --git a/routes/socialToken.js b/routes/socialToken.js new file mode 100644 index 0000000..e899ad6 --- /dev/null +++ b/routes/socialToken.js @@ -0,0 +1,793 @@ +const express = require('express'); +const router = express.Router(); +const { authenticateToken, requireCreator } = require('../middleware/auth'); +const { logger } = require('../utils/logger'); + +/** + * Social Token Gating API Routes + * Provides endpoints for managing Stellar Asset-based content gating + */ + +/** + * Set social token gating for content (creator only) + * POST /api/social-token/gating + */ +router.post('/gating', authenticateToken, requireCreator, async (req, res) => { + try { + const { + contentId, + assetCode, + assetIssuer, + minimumBalance, + verificationInterval = 60000 // 1 minute default + } = req.body; + + // Validate required fields + if (!contentId || !assetCode || !assetIssuer || !minimumBalance) { + return res.status(400).json({ + success: false, + error: 'Missing required fields: contentId, assetCode, assetIssuer, minimumBalance', + code: 'MISSING_FIELDS' + }); + } + + // Validate numeric fields + const minBalance = parseFloat(minimumBalance); + if (isNaN(minBalance) || minBalance <= 0) { + return res.status(400).json({ + success: false, + error: 'minimumBalance must be a positive number', + code: 'INVALID_BALANCE' + }); + } + + const verifyInterval = parseInt(verificationInterval); + if (isNaN(verifyInterval) || verifyInterval < 30000) { // Minimum 30 seconds + return res.status(400).json({ + success: false, + error: 'verificationInterval must be at least 30 seconds', + code: 'INVALID_INTERVAL' + }); + } + + const socialTokenService = req.app.get('socialTokenService'); + if (!socialTokenService) { + return res.status(503).json({ + success: false, + error: 'Social token service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Verify creator owns the content + const content = req.database.db.prepare(` + SELECT creator_address FROM content WHERE id = ? + `).get(contentId); + + if (!content || content.creator_address !== req.user.address) { + return res.status(403).json({ + success: false, + error: 'You can only set gating for your own content', + code: 'NOT_CONTENT_OWNER' + }); + } + + // Set up gating + const gating = await socialTokenService.setContentGating({ + contentId, + creatorAddress: req.user.address, + assetCode, + assetIssuer, + minimumBalance: minBalance, + verificationInterval: verifyInterval + }); + + res.status(201).json({ + success: true, + data: { + contentId, + gating, + message: 'Social token gating enabled successfully' + } + }); + + } catch (error) { + logger.error('Set social token gating error', { + error: error.message, + creatorAddress: req.user.address, + contentId: req.body.contentId + }); + + if (error.message.includes('does not exist')) { + return res.status(400).json({ + success: false, + error: error.message, + code: 'ASSET_NOT_FOUND' + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to set social token gating', + code: 'GATING_SETUP_FAILED' + }); + } +}); + +/** + * Update social token gating for content (creator only) + * PUT /api/social-token/gating/:contentId + */ +router.put('/gating/:contentId', authenticateToken, requireCreator, async (req, res) => { + try { + const { contentId } = req.params; + const { + assetCode, + assetIssuer, + minimumBalance, + verificationInterval, + active = true + } = req.body; + + // Verify creator owns the content + const content = req.database.db.prepare(` + SELECT creator_address FROM content WHERE id = ? + `).get(contentId); + + if (!content || content.creator_address !== req.user.address) { + return res.status(403).json({ + success: false, + error: 'You can only update gating for your own content', + code: 'NOT_CONTENT_OWNER' + }); + } + + const socialTokenService = req.app.get('socialTokenService'); + if (!socialTokenService) { + return res.status(503).json({ + success: false, + error: 'Social token service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Update gating + const gating = await socialTokenService.setContentGating({ + contentId, + creatorAddress: req.user.address, + assetCode, + assetIssuer, + minimumBalance, + verificationInterval, + active + }); + + res.json({ + success: true, + data: { + contentId, + gating, + message: 'Social token gating updated successfully' + } + }); + + } catch (error) { + logger.error('Update social token gating error', { + error: error.message, + creatorAddress: req.user.address, + contentId: req.params.contentId + }); + + res.status(500).json({ + success: false, + error: 'Failed to update social token gating', + code: 'GATING_UPDATE_FAILED' + }); + } +}); + +/** + * Remove social token gating for content (creator only) + * DELETE /api/social-token/gating/:contentId + */ +router.delete('/gating/:contentId', authenticateToken, requireCreator, async (req, res) => { + try { + const { contentId } = req.params; + + // Verify creator owns the content + const content = req.database.db.prepare(` + SELECT creator_address FROM content WHERE id = ? + `).get(contentId); + + if (!content || content.creator_address !== req.user.address) { + return res.status(403).json({ + success: false, + error: 'You can only remove gating for your own content', + code: 'NOT_CONTENT_OWNER' + }); + } + + // Deactivate gating + req.database.db.prepare(` + UPDATE social_token_gated_content + SET active = 0, updated_at = ? + WHERE content_id = ? AND creator_address = ? + `).run(new Date().toISOString(), contentId, req.user.address); + + res.json({ + success: true, + data: { + contentId, + message: 'Social token gating removed successfully' + } + }); + + } catch (error) { + logger.error('Remove social token gating error', { + error: error.message, + creatorAddress: req.user.address, + contentId: req.params.contentId + }); + + res.status(500).json({ + success: false, + error: 'Failed to remove social token gating', + code: 'GATING_REMOVAL_FAILED' + }); + } +}); + +/** + * Get gating requirements for content + * GET /api/social-token/gating/:contentId + */ +router.get('/gating/:contentId', authenticateToken, async (req, res) => { + try { + const { contentId } = req.params; + + const socialTokenService = req.app.get('socialTokenService'); + if (!socialTokenService) { + return res.status(503).json({ + success: false, + error: 'Social token service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + const gating = await socialTokenService.getContentGatingRequirements(contentId); + + if (!gating) { + return res.status(404).json({ + success: false, + error: 'No gating requirements found for this content', + code: 'NO_GATING_FOUND' + }); + } + + res.json({ + success: true, + data: { + contentId, + gating + } + }); + + } catch (error) { + logger.error('Get gating requirements error', { + error: error.message, + contentId: req.params.contentId + }); + + res.status(500).json({ + success: false, + error: 'Failed to get gating requirements', + code: 'GATING_FETCH_FAILED' + }); + } +}); + +/** + * Check user access to gated content + * GET /api/social-token/access/:contentId + */ +router.get('/access/:contentId', authenticateToken, async (req, res) => { + try { + const { contentId } = req.params; + const userAddress = req.user.address; + + const socialTokenService = req.app.get('socialTokenService'); + if (!socialTokenService) { + return res.status(503).json({ + success: false, + error: 'Social token service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + const accessResult = await socialTokenService.checkContentAccess(userAddress, contentId); + + res.json({ + success: true, + data: { + contentId, + userAddress, + accessResult + } + }); + + } catch (error) { + logger.error('Check content access error', { + error: error.message, + userAddress: req.user.address, + contentId: req.params.contentId + }); + + res.status(500).json({ + success: false, + error: 'Failed to check content access', + code: 'ACCESS_CHECK_FAILED' + }); + } +}); + +/** + * Start balance re-verification session + * POST /api/social-token/session + */ +router.post('/session', authenticateToken, async (req, res) => { + try { + const { contentId } = req.body; + const userAddress = req.user.address; + + if (!contentId) { + return res.status(400).json({ + success: false, + error: 'contentId is required', + code: 'CONTENT_ID_REQUIRED' + }); + } + + const socialTokenService = req.app.get('socialTokenService'); + if (!socialTokenService) { + return res.status(503).json({ + success: false, + error: 'Social token service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Check if content requires token gating + const accessResult = await socialTokenService.checkContentAccess(userAddress, contentId); + + if (!accessResult.hasAccess) { + return res.status(403).json({ + success: false, + error: 'Access denied', + code: 'ACCESS_DENIED', + details: accessResult + }); + } + + // Start re-verification session + const sessionData = await socialTokenService.startBalanceReverification( + null, // Will generate session ID + userAddress, + contentId + ); + + res.status(201).json({ + success: true, + data: { + sessionId: sessionData.sessionId, + contentId, + userAddress, + requiresReverification: sessionData.requiresReverification, + verificationInterval: sessionData.verificationInterval, + assetInfo: sessionData.requiresReverification ? { + code: sessionData.assetCode, + issuer: sessionData.assetIssuer, + minimumBalance: sessionData.minimumBalance + } : null + } + }); + + } catch (error) { + logger.error('Start re-verification session error', { + error: error.message, + userAddress: req.user.address, + contentId: req.body.contentId + }); + + res.status(500).json({ + success: false, + error: 'Failed to start re-verification session', + code: 'SESSION_START_FAILED' + }); + } +}); + +/** + * Re-verify token balance for session + * POST /api/social-token/session/:sessionId/verify + */ +router.post('/session/:sessionId/verify', authenticateToken, async (req, res) => { + try { + const { sessionId } = req.params; + const userAddress = req.user.address; + + const socialTokenService = req.app.get('socialTokenService'); + if (!socialTokenService) { + return res.status(503).json({ + success: false, + error: 'Social token service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Verify session belongs to user + const sessionQuery = ` + SELECT user_address, content_id FROM social_token_sessions + WHERE session_id = ? AND still_valid = 1 + `; + const session = req.database.db.prepare(sessionQuery).get(sessionId); + + if (!session || session.user_address !== userAddress) { + return res.status(404).json({ + success: false, + error: 'Session not found or access denied', + code: 'SESSION_NOT_FOUND' + }); + } + + // Re-verify balance + const stillValid = await socialTokenService.reverifyBalance(sessionId); + + res.json({ + success: true, + data: { + sessionId, + stillValid, + contentId: session.content_id, + verifiedAt: new Date().toISOString() + } + }); + + } catch (error) { + logger.error('Re-verify balance error', { + error: error.message, + sessionId: req.params.sessionId, + userAddress: req.user.address + }); + + res.status(500).json({ + success: false, + error: 'Failed to re-verify balance', + code: 'BALANCE_VERIFICATION_FAILED' + }); + } +}); + +/** + * End re-verification session + * DELETE /api/social-token/session/:sessionId + */ +router.delete('/session/:sessionId', authenticateToken, async (req, res) => { + try { + const { sessionId } = req.params; + const userAddress = req.user.address; + + const socialTokenService = req.app.get('socialTokenService'); + if (!socialTokenService) { + return res.status(503).json({ + success: false, + error: 'Social token service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Verify session belongs to user + const sessionQuery = ` + SELECT user_address FROM social_token_sessions + WHERE session_id = ? + `; + const session = req.database.db.prepare(sessionQuery).get(sessionId); + + if (!session || session.user_address !== userAddress) { + return res.status(404).json({ + success: false, + error: 'Session not found or access denied', + code: 'SESSION_NOT_FOUND' + }); + } + + // End session + const ended = await socialTokenService.endReverificationSession(sessionId); + + res.json({ + success: true, + data: { + sessionId, + ended, + message: ended ? 'Session ended successfully' : 'Session was not found' + } + }); + + } catch (error) { + logger.error('End session error', { + error: error.message, + sessionId: req.params.sessionId, + userAddress: req.user.address + }); + + res.status(500).json({ + success: false, + error: 'Failed to end session', + code: 'SESSION_END_FAILED' + }); + } +}); + +/** + * Validate Stellar Asset + * POST /api/social-token/validate-asset + */ +router.post('/validate-asset', authenticateToken, async (req, res) => { + try { + const { assetCode, assetIssuer } = req.body; + + if (!assetCode || !assetIssuer) { + return res.status(400).json({ + success: false, + error: 'assetCode and assetIssuer are required', + code: 'MISSING_ASSET_DATA' + }); + } + + const socialTokenService = req.app.get('socialTokenService'); + if (!socialTokenService) { + return res.status(503).json({ + success: false, + error: 'Social token service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + const validation = await socialTokenService.verifyAssetExists(assetCode, assetIssuer); + + res.json({ + success: true, + data: { + assetCode, + assetIssuer, + exists: validation, + validatedAt: new Date().toISOString() + } + }); + + } catch (error) { + logger.error('Asset validation error', { + error: error.message, + assetCode: req.body.assetCode, + assetIssuer: req.body.assetIssuer + }); + + res.status(500).json({ + success: false, + error: 'Failed to validate asset', + code: 'ASSET_VALIDATION_FAILED' + }); + } +}); + +/** + * Get creator's social token statistics (creator only) + * GET /api/social-token/stats + */ +router.get('/stats', authenticateToken, requireCreator, async (req, res) => { + try { + const creatorAddress = req.user.address; + + const socialTokenService = req.app.get('socialTokenService'); + if (!socialTokenService) { + return res.status(503).json({ + success: false, + error: 'Social token service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + const stats = await socialTokenService.getCreatorTokenStats(creatorAddress); + + if (!stats) { + return res.status(404).json({ + success: false, + error: 'No statistics available', + code: 'NO_STATS_FOUND' + }); + } + + res.json({ + success: true, + data: stats + }); + + } catch (error) { + logger.error('Get creator stats error', { + error: error.message, + creatorAddress: req.user.address + }); + + res.status(500).json({ + success: false, + error: 'Failed to get creator statistics', + code: 'STATS_FETCH_FAILED' + }); + } +}); + +/** + * Get user's token holdings + * GET /api/social-token/tokens/:assetCode/:assetIssuer + */ +router.get('/tokens/:assetCode/:assetIssuer', authenticateToken, async (req, res) => { + try { + const { assetCode, assetIssuer } = req.params; + const userAddress = req.user.address; + + const socialTokenService = req.app.get('socialTokenService'); + if (!socialTokenService) { + return res.status(503).json({ + success: false, + error: 'Social token service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + const balance = await socialTokenService.fetchTokenBalance(userAddress, assetCode, assetIssuer); + + res.json({ + success: true, + data: { + userAddress, + assetCode, + assetIssuer, + balance, + checkedAt: new Date().toISOString() + } + }); + + } catch (error) { + logger.error('Get token holdings error', { + error: error.message, + userAddress: req.user.address, + assetCode: req.params.assetCode, + assetIssuer: req.params.assetIssuer + }); + + res.status(500).json({ + success: false, + error: 'Failed to get token holdings', + code: 'TOKEN_HOLDINGS_FAILED' + }); + } +}); + +/** + * Get social token gating statistics (admin only) + * GET /api/social-token/admin/stats + */ +router.get('/admin/stats', authenticateToken, async (req, res) => { + try { + // This should be restricted to admins in production + const socialTokenMiddleware = req.app.get('socialTokenMiddleware'); + if (!socialTokenMiddleware) { + return res.status(503).json({ + success: false, + error: 'Social token middleware not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + const stats = await socialTokenMiddleware.getStatistics(); + + res.json({ + success: true, + data: stats + }); + + } catch (error) { + logger.error('Get admin stats error', { error: error.message }); + + res.status(500).json({ + success: false, + error: 'Failed to get admin statistics', + code: 'ADMIN_STATS_FAILED' + }); + } +}); + +/** + * Force terminate session (admin only) + * POST /api/social-token/admin/session/:sessionId/terminate + */ +router.post('/admin/session/:sessionId/terminate', authenticateToken, async (req, res) => { + try { + const { sessionId } = req.params; + const { reason } = req.body; + + // This should be restricted to admins in production + const socialTokenMiddleware = req.app.get('socialTokenMiddleware'); + if (!socialTokenMiddleware) { + return res.status(503).json({ + success: false, + error: 'Social token middleware not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + const terminated = await socialTokenMiddleware.terminateSession(sessionId, reason); + + res.json({ + success: true, + data: { + sessionId, + terminated, + reason: reason || 'Admin termination', + terminatedAt: new Date().toISOString() + } + }); + + } catch (error) { + logger.error('Force terminate session error', { + error: error.message, + sessionId: req.params.sessionId + }); + + res.status(500).json({ + success: false, + error: 'Failed to terminate session', + code: 'SESSION_TERMINATION_FAILED' + }); + } +}); + +/** + * Clean up expired sessions (admin only) + * POST /api/social-token/admin/cleanup + */ +router.post('/admin/cleanup', authenticateToken, async (req, res) => { + try { + const { maxAge = 3600000 } = req.body; // 1 hour default + + const socialTokenService = req.app.get('socialTokenService'); + if (!socialTokenService) { + return res.status(503).json({ + success: false, + error: 'Social token service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + const cleanedCount = await socialTokenService.cleanupExpiredSessions(maxAge); + + res.json({ + success: true, + data: { + cleanedCount, + maxAge, + cleanedAt: new Date().toISOString() + } + }); + + } catch (error) { + logger.error('Cleanup sessions error', { error: error.message }); + + res.status(500).json({ + success: false, + error: 'Failed to cleanup sessions', + code: 'CLEANUP_FAILED' + }); + } +}); + +module.exports = router; diff --git a/services/socialTokenGatingService.js b/services/socialTokenGatingService.js new file mode 100644 index 0000000..eccfbf6 --- /dev/null +++ b/services/socialTokenGatingService.js @@ -0,0 +1,683 @@ +const { Server, Api } = require('@stellar/stellar-sdk'); +const { logger } = require('../utils/logger'); + +/** + * Social Token Gating Service + * Verifies Stellar Asset holdings for content access control + * Provides binary gating based on minimum token requirements + */ +class SocialTokenGatingService { + constructor(config, database, redisClient) { + this.config = config; + this.database = database; + this.redis = redisClient; + + // Stellar server connection + this.server = new Server({ + hostname: config.stellar?.horizonUrl || 'https://horizon-testnet.stellar.org', + protocol: 'https', + port: 443, + userAgent: 'SubStream-SocialTokenGating/1.0' + }); + + // Caching configuration + this.cacheTTL = config.socialToken?.cacheTTL || 300; // 5 minutes + this.reverificationInterval = config.socialToken?.reverificationInterval || 60000; // 1 minute + this.prefix = config.socialToken?.cachePrefix || 'social_token:'; + + // Retry configuration + this.maxRetries = config.stellar?.maxRetries || 3; + this.retryDelay = config.stellar?.retryDelay || 1000; + } + + /** + * Verify if a user holds sufficient tokens for content access + * @param {string} userAddress User's Stellar wallet address + * @param {string} assetCode Asset code to check + * @param {string} assetIssuer Asset issuer address + * @param {number} minimumBalance Minimum required balance + * @returns {Promise} Whether user has sufficient tokens + */ + async verifyTokenHolding(userAddress, assetCode, assetIssuer, minimumBalance) { + try { + const cacheKey = this.getBalanceCacheKey(userAddress, assetCode, assetIssuer); + + // Try to get from cache first + const cached = await this.getCachedBalance(cacheKey); + if (cached !== null) { + logger.debug('Token balance found in cache', { + userAddress, + assetCode, + assetIssuer, + cachedBalance: cached, + minimumBalance + }); + return cached >= minimumBalance; + } + + // Fetch from Stellar network + const balance = await this.fetchTokenBalance(userAddress, assetCode, assetIssuer); + + // Cache the result + await this.cacheBalance(cacheKey, balance); + + logger.info('Token balance verified', { + userAddress, + assetCode, + assetIssuer, + balance, + minimumBalance, + hasSufficient: balance >= minimumBalance + }); + + return balance >= minimumBalance; + } catch (error) { + logger.error('Failed to verify token holding', { + error: error.message, + userAddress, + assetCode, + assetIssuer, + minimumBalance + }); + + // Fail safe: deny access if verification fails + return false; + } + } + + /** + * Fetch token balance from Stellar network + * @param {string} userAddress User's Stellar wallet address + * @param {string} assetCode Asset code + * @param {string} assetIssuer Asset issuer address + * @returns {Promise} Token balance + */ + async fetchTokenBalance(userAddress, assetCode, assetIssuer) { + let lastError; + + for (let attempt = 1; attempt <= this.maxRetries; attempt++) { + try { + logger.debug(`Fetching token balance (attempt ${attempt})`, { + userAddress, + assetCode, + assetIssuer + }); + + const account = await this.server.accounts().accountId(userAddress).call(); + + // Find the specific asset balance + const assetString = `${assetCode}:${assetIssuer}`; + const balance = account.balances.find(b => + b.asset_type === 'credit_alphanum4' || + b.asset_type === 'credit_alphanum12' + ) && account.balances.find(b => b.asset_code === assetCode && b.asset_issuer === assetIssuer); + + if (!balance) { + logger.debug('Asset not found in account balances', { + userAddress, + assetCode, + assetIssuer, + availableAssets: account.balances.map(b => ({ + type: b.asset_type, + code: b.asset_code, + issuer: b.asset_issuer + })) + }); + return 0; + } + + const numericBalance = parseFloat(balance.balance); + + logger.debug('Token balance fetched successfully', { + userAddress, + assetCode, + assetIssuer, + balance: numericBalance + }); + + return numericBalance; + } catch (error) { + lastError = error; + logger.warn(`Token balance fetch attempt ${attempt} failed`, { + error: error.message, + userAddress, + assetCode, + assetIssuer + }); + + // Don't retry on certain errors + if (error.response?.status === 404) { + // Account not found + return 0; + } + + if (attempt < this.maxRetries) { + // Exponential backoff + const delay = this.retryDelay * Math.pow(2, attempt - 1); + await this.sleep(delay); + } + } + } + + throw lastError; + } + + /** + * Check if content requires social token gating + * @param {string} contentId Content ID + * @returns {Promise} Gating requirements or null + */ + async getContentGatingRequirements(contentId) { + try { + const query = ` + SELECT + asset_code, + asset_issuer, + minimum_balance, + verification_interval, + created_at + FROM social_token_gated_content + WHERE content_id = ? AND active = 1 + `; + + const result = this.database.db.prepare(query).get(contentId); + + if (!result) { + return null; + } + + return { + assetCode: result.asset_code, + assetIssuer: result.asset_issuer, + minimumBalance: parseFloat(result.minimum_balance), + verificationInterval: result.verification_interval || this.reverificationInterval, + createdAt: result.created_at + }; + } catch (error) { + logger.error('Failed to get content gating requirements', { + error: error.message, + contentId + }); + return null; + } + } + + /** + * Create or update social token gating for content + * @param {object} gatingData Gating configuration + * @returns {Promise} Created/updated gating record + */ + async setContentGating(gatingData) { + try { + const { + contentId, + creatorAddress, + assetCode, + assetIssuer, + minimumBalance, + verificationInterval = this.reverificationInterval, + active = true + } = gatingData; + + // Validate asset exists on Stellar network + const assetExists = await this.verifyAssetExists(assetCode, assetIssuer); + if (!assetExists) { + throw new Error(`Asset ${assetCode}:${assetIssuer} does not exist on Stellar network`); + } + + // Upsert gating record + const upsertQuery = ` + INSERT OR REPLACE INTO social_token_gated_content ( + content_id, creator_address, asset_code, asset_issuer, + minimum_balance, verification_interval, active, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `; + + this.database.db.prepare(upsertQuery).run( + contentId, + creatorAddress, + assetCode, + assetIssuer, + minimumBalance.toString(), + verificationInterval, + active ? 1 : 0, + new Date().toISOString() + ); + + logger.info('Social token gating set for content', { + contentId, + creatorAddress, + assetCode, + assetIssuer, + minimumBalance + }); + + return await this.getContentGatingRequirements(contentId); + } catch (error) { + logger.error('Failed to set content gating', { + error: error.message, + gatingData + }); + throw error; + } + } + + /** + * Verify if asset exists on Stellar network + * @param {string} assetCode Asset code + * @param {string} assetIssuer Asset issuer address + * @returns {Promise} Whether asset exists + */ + async verifyAssetExists(assetCode, assetIssuer) { + try { + // Try to get asset details + const assets = await this.server.assets() + .forCode(assetCode) + .forIssuer(assetIssuer) + .call(); + + return assets.records.length > 0; + } catch (error) { + logger.debug('Asset verification failed', { + error: error.message, + assetCode, + assetIssuer + }); + return false; + } + } + + /** + * Check user access to gated content + * @param {string} userAddress User's wallet address + * @param {string} contentId Content ID + * @returns {Promise} Access result with details + */ + async checkContentAccess(userAddress, contentId) { + try { + const gating = await this.getContentGatingRequirements(contentId); + + if (!gating) { + // No gating requirements, content is publicly accessible + return { + hasAccess: true, + requiresToken: false, + reason: 'No token requirements' + }; + } + + // Verify token holding + const hasTokens = await this.verifyTokenHolding( + userAddress, + gating.assetCode, + gating.assetIssuer, + gating.minimumBalance + ); + + const result = { + hasAccess: hasTokens, + requiresToken: true, + assetCode: gating.assetCode, + assetIssuer: gating.assetIssuer, + minimumBalance: gating.minimumBalance, + verificationInterval: gating.verificationInterval, + reason: hasTokens ? 'Sufficient tokens' : 'Insufficient tokens' + }; + + // Log access attempt + await this.logAccessAttempt(userAddress, contentId, result); + + return result; + } catch (error) { + logger.error('Failed to check content access', { + error: error.message, + userAddress, + contentId + }); + + return { + hasAccess: false, + requiresToken: true, + reason: 'Verification failed' + }; + } + } + + /** + * Start periodic balance re-verification for active sessions + * @param {string} sessionId Session ID + * @param {string} userAddress User's wallet address + * @param {string} contentId Content ID + * @returns {Promise} Session management data + */ + async startBalanceReverification(sessionId, userAddress, contentId) { + try { + const gating = await this.getContentGatingRequirements(contentId); + + if (!gating) { + return { requiresReverification: false }; + } + + // Create session record + const sessionData = { + sessionId, + userAddress, + contentId, + assetCode: gating.assetCode, + assetIssuer: gating.assetIssuer, + minimumBalance: gating.minimumBalance, + verificationInterval: gating.verificationInterval, + lastVerified: new Date().toISOString(), + stillValid: true, + createdAt: new Date().toISOString() + }; + + const insertQuery = ` + INSERT INTO social_token_sessions ( + session_id, user_address, content_id, asset_code, asset_issuer, + minimum_balance, verification_interval, last_verified, still_valid, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + + this.database.db.prepare(insertQuery).run( + sessionId, + userAddress, + contentId, + gating.assetCode, + gating.assetIssuer, + gating.minimumBalance.toString(), + gating.verificationInterval, + sessionData.lastVerified, + 1, + sessionData.createdAt + ); + + logger.info('Balance reverification session started', { + sessionId, + userAddress, + contentId, + verificationInterval: gating.verificationInterval + }); + + return { + requiresReverification: true, + ...sessionData + }; + } catch (error) { + logger.error('Failed to start balance reverification', { + error: error.message, + sessionId, + userAddress, + contentId + }); + throw error; + } + } + + /** + * Re-verify token balance for active session + * @param {string} sessionId Session ID + * @returns {Promise} Whether balance is still sufficient + */ + async reverifyBalance(sessionId) { + try { + const query = ` + SELECT user_address, content_id, asset_code, asset_issuer, minimum_balance + FROM social_token_sessions + WHERE session_id = ? AND still_valid = 1 + `; + + const session = this.database.db.prepare(query).get(sessionId); + + if (!session) { + logger.debug('Session not found or already invalidated', { sessionId }); + return false; + } + + // Re-verify token holding + const stillValid = await this.verifyTokenHolding( + session.user_address, + session.asset_code, + session.asset_issuer, + parseFloat(session.minimum_balance) + ); + + // Update session status + this.database.db.prepare(` + UPDATE social_token_sessions + SET still_valid = ?, last_verified = ? + WHERE session_id = ? + `).run( + stillValid ? 1 : 0, + new Date().toISOString(), + sessionId + ); + + if (!stillValid) { + logger.info('Balance reverification failed - session invalidated', { + sessionId, + userAddress: session.user_address, + contentId: session.content_id + }); + } + + return stillValid; + } catch (error) { + logger.error('Failed to reverify balance', { + error: error.message, + sessionId + }); + return false; + } + } + + /** + * End balance re-verification session + * @param {string} sessionId Session ID + * @returns {Promise} Whether session was ended + */ + async endReverificationSession(sessionId) { + try { + const result = this.database.db.prepare(` + DELETE FROM social_token_sessions WHERE session_id = ? + `).run(sessionId); + + const ended = result.changes > 0; + + if (ended) { + logger.info('Balance reverification session ended', { sessionId }); + } + + return ended; + } catch (error) { + logger.error('Failed to end reverification session', { + error: error.message, + sessionId + }); + return false; + } + } + + /** + * Get cached balance + * @param {string} cacheKey Cache key + * @returns {Promise} Cached balance or null + */ + async getCachedBalance(cacheKey) { + try { + const cached = await this.redis.get(cacheKey); + return cached !== null ? parseFloat(cached) : null; + } catch (error) { + logger.error('Failed to get cached balance', { + error: error.message, + cacheKey + }); + return null; + } + } + + /** + * Cache balance result + * @param {string} cacheKey Cache key + * @param {number} balance Balance to cache + */ + async cacheBalance(cacheKey, balance) { + try { + await this.redis.setex(cacheKey, this.cacheTTL, balance.toString()); + } catch (error) { + logger.error('Failed to cache balance', { + error: error.message, + cacheKey, + balance + }); + } + } + + /** + * Get balance cache key + * @param {string} userAddress User address + * @param {string} assetCode Asset code + * @param {string} assetIssuer Asset issuer + * @returns {string} Cache key + */ + getBalanceCacheKey(userAddress, assetCode, assetIssuer) { + return `${this.prefix}balance:${userAddress}:${assetCode}:${assetIssuer}`; + } + + /** + * Log access attempt for analytics + * @param {string} userAddress User address + * @param {string} contentId Content ID + * @param {object} result Access result + */ + async logAccessAttempt(userAddress, contentId, result) { + try { + const query = ` + INSERT INTO social_token_access_logs ( + user_address, content_id, has_access, requires_token, + asset_code, asset_issuer, minimum_balance, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `; + + this.database.db.prepare(query).run( + userAddress, + contentId, + result.hasAccess ? 1 : 0, + result.requiresToken ? 1 : 0, + result.assetCode || null, + result.assetIssuer || null, + result.minimumBalance || null, + new Date().toISOString() + ); + } catch (error) { + logger.error('Failed to log access attempt', { + error: error.message, + userAddress, + contentId + }); + } + } + + /** + * Get social token statistics for a creator + * @param {string} creatorAddress Creator's wallet address + * @returns {Promise} Statistics + */ + async getCreatorTokenStats(creatorAddress) { + try { + // Get gated content count + const gatedContentQuery = ` + SELECT COUNT(*) as count FROM social_token_gated_content + WHERE creator_address = ? AND active = 1 + `; + const gatedContent = this.database.db.prepare(gatedContentQuery).get(creatorAddress); + + // Get access attempts in last 30 days + const accessQuery = ` + SELECT + COUNT(*) as total_attempts, + COUNT(CASE WHEN has_access = 1 THEN 1 END) as successful_attempts, + COUNT(DISTINCT user_address) as unique_users + FROM social_token_access_logs atl + JOIN social_token_gated_content stgc ON atl.content_id = stgc.content_id + WHERE stgc.creator_address = ? + AND atl.created_at >= datetime('now', '-30 days') + `; + const accessStats = this.database.db.prepare(accessQuery).get(creatorAddress); + + // Get most used tokens + const tokenQuery = ` + SELECT + asset_code, + asset_issuer, + COUNT(*) as usage_count, + AVG(CASE WHEN has_access = 1 THEN 1 ELSE 0 END) as success_rate + FROM social_token_access_logs atl + JOIN social_token_gated_content stgc ON atl.content_id = stgc.content_id + WHERE stgc.creator_address = ? + AND atl.created_at >= datetime('now', '-30 days') + GROUP BY asset_code, asset_issuer + ORDER BY usage_count DESC + LIMIT 5 + `; + const tokenUsage = this.database.db.prepare(tokenQuery).all(creatorAddress); + + return { + creatorAddress, + gatedContentCount: gatedContent.count || 0, + totalAttempts: accessStats.total_attempts || 0, + successfulAttempts: accessStats.successful_attempts || 0, + uniqueUsers: accessStats.unique_users || 0, + successRate: accessStats.total_attempts > 0 + ? (accessStats.successful_attempts / accessStats.total_attempts) * 100 + : 0, + topTokens: tokenUsage + }; + } catch (error) { + logger.error('Failed to get creator token stats', { + error: error.message, + creatorAddress + }); + return null; + } + } + + /** + * Clean up expired sessions + * @param {number} maxAge Maximum age in milliseconds + * @returns {Promise} Number of sessions cleaned up + */ + async cleanupExpiredSessions(maxAge = 3600000) { // 1 hour default + try { + const cutoffTime = new Date(Date.now() - maxAge).toISOString(); + + const result = this.database.db.prepare(` + DELETE FROM social_token_sessions + WHERE created_at < ? OR (still_valid = 0 AND last_verified < ?) + `).run(cutoffTime, cutoffTime); + + logger.info('Expired sessions cleaned up', { + deletedCount: result.changes, + cutoffTime + }); + + return result.changes; + } catch (error) { + logger.error('Failed to cleanup expired sessions', { + error: error.message, + maxAge + }); + return 0; + } + } + + /** + * Sleep utility for delays + * @param {number} ms Milliseconds to sleep + */ + sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +module.exports = SocialTokenGatingService;