mcp-twin 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Authentication Helpers
3
+ * MCP Twin Cloud
4
+ */
5
+
6
+ import { Request, Response, NextFunction } from 'express';
7
+ import bcrypt from 'bcryptjs';
8
+ import jwt from 'jsonwebtoken';
9
+ import { v4 as uuidv4 } from 'uuid';
10
+ import { query, queryOne } from './db';
11
+
12
+ const JWT_SECRET = process.env.JWT_SECRET || 'mcp-twin-dev-secret-change-in-prod';
13
+ const JWT_EXPIRES_IN = '7d';
14
+ const SALT_ROUNDS = 10;
15
+
16
+ // Extend Express Request type
17
+ declare global {
18
+ namespace Express {
19
+ interface Request {
20
+ user?: {
21
+ id: string;
22
+ email: string;
23
+ tier: string;
24
+ };
25
+ apiKey?: {
26
+ id: string;
27
+ userId: string;
28
+ };
29
+ }
30
+ }
31
+ }
32
+
33
+ export interface User {
34
+ id: string;
35
+ email: string;
36
+ password_hash: string;
37
+ tier: string;
38
+ stripe_customer_id?: string;
39
+ stripe_subscription_id?: string;
40
+ created_at: Date;
41
+ }
42
+
43
+ export interface ApiKey {
44
+ id: string;
45
+ user_id: string;
46
+ key_hash: string;
47
+ key_prefix: string;
48
+ name?: string;
49
+ last_used_at?: Date;
50
+ created_at: Date;
51
+ revoked_at?: Date;
52
+ }
53
+
54
+ /**
55
+ * Hash a password
56
+ */
57
+ export async function hashPassword(password: string): Promise<string> {
58
+ return bcrypt.hash(password, SALT_ROUNDS);
59
+ }
60
+
61
+ /**
62
+ * Verify a password against a hash
63
+ */
64
+ export async function verifyPassword(password: string, hash: string): Promise<boolean> {
65
+ return bcrypt.compare(password, hash);
66
+ }
67
+
68
+ /**
69
+ * Generate a JWT session token
70
+ */
71
+ export function generateSessionToken(user: { id: string; email: string; tier: string }): string {
72
+ return jwt.sign(
73
+ { userId: user.id, email: user.email, tier: user.tier },
74
+ JWT_SECRET,
75
+ { expiresIn: JWT_EXPIRES_IN }
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Verify a JWT session token
81
+ */
82
+ export function verifySessionToken(token: string): { userId: string; email: string; tier: string } | null {
83
+ try {
84
+ const decoded = jwt.verify(token, JWT_SECRET) as any;
85
+ return { userId: decoded.userId, email: decoded.email, tier: decoded.tier };
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Generate an API key
93
+ * Returns: { key: 'mtc_live_xxxxx', hash: 'hashed_value', prefix: 'mtc_live_abc' }
94
+ */
95
+ export async function generateApiKey(): Promise<{ key: string; hash: string; prefix: string }> {
96
+ const keyId = uuidv4().replace(/-/g, '');
97
+ const key = `mtc_live_${keyId}`;
98
+ const hash = await bcrypt.hash(key, SALT_ROUNDS);
99
+ const prefix = `mtc_live_${keyId.substring(0, 8)}`;
100
+
101
+ return { key, hash, prefix };
102
+ }
103
+
104
+ /**
105
+ * Verify an API key and return the associated user
106
+ */
107
+ export async function verifyApiKey(key: string): Promise<{ apiKeyId: string; user: User } | null> {
108
+ // Get all non-revoked API keys (in production, use a better lookup strategy)
109
+ const apiKeys = await query<ApiKey>(
110
+ 'SELECT * FROM api_keys WHERE revoked_at IS NULL'
111
+ );
112
+
113
+ for (const apiKey of apiKeys) {
114
+ const valid = await bcrypt.compare(key, apiKey.key_hash);
115
+ if (valid) {
116
+ // Update last_used_at
117
+ await query(
118
+ 'UPDATE api_keys SET last_used_at = NOW() WHERE id = $1',
119
+ [apiKey.id]
120
+ );
121
+
122
+ // Get user
123
+ const user = await queryOne<User>(
124
+ 'SELECT * FROM users WHERE id = $1',
125
+ [apiKey.user_id]
126
+ );
127
+
128
+ if (user) {
129
+ return { apiKeyId: apiKey.id, user };
130
+ }
131
+ }
132
+ }
133
+
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Middleware: Authenticate via API key
139
+ */
140
+ export async function authenticateApiKey(
141
+ req: Request,
142
+ res: Response,
143
+ next: NextFunction
144
+ ): Promise<void> {
145
+ const authHeader = req.headers.authorization;
146
+
147
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
148
+ res.status(401).json({
149
+ error: {
150
+ code: 'UNAUTHORIZED',
151
+ message: 'Missing or invalid Authorization header. Use: Bearer <api_key>',
152
+ },
153
+ });
154
+ return;
155
+ }
156
+
157
+ const token = authHeader.substring(7);
158
+
159
+ // Check if it's an API key (starts with mtc_)
160
+ if (token.startsWith('mtc_')) {
161
+ const result = await verifyApiKey(token);
162
+ if (!result) {
163
+ res.status(401).json({
164
+ error: {
165
+ code: 'UNAUTHORIZED',
166
+ message: 'Invalid API key',
167
+ },
168
+ });
169
+ return;
170
+ }
171
+
172
+ req.user = {
173
+ id: result.user.id,
174
+ email: result.user.email,
175
+ tier: result.user.tier,
176
+ };
177
+ req.apiKey = {
178
+ id: result.apiKeyId,
179
+ userId: result.user.id,
180
+ };
181
+ next();
182
+ return;
183
+ }
184
+
185
+ // Otherwise, treat as JWT session token
186
+ const decoded = verifySessionToken(token);
187
+ if (!decoded) {
188
+ res.status(401).json({
189
+ error: {
190
+ code: 'UNAUTHORIZED',
191
+ message: 'Invalid or expired session token',
192
+ },
193
+ });
194
+ return;
195
+ }
196
+
197
+ req.user = {
198
+ id: decoded.userId,
199
+ email: decoded.email,
200
+ tier: decoded.tier,
201
+ };
202
+ next();
203
+ }
204
+
205
+ /**
206
+ * Middleware: Optional authentication (doesn't fail if not authenticated)
207
+ */
208
+ export async function optionalAuth(
209
+ req: Request,
210
+ res: Response,
211
+ next: NextFunction
212
+ ): Promise<void> {
213
+ const authHeader = req.headers.authorization;
214
+
215
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
216
+ next();
217
+ return;
218
+ }
219
+
220
+ const token = authHeader.substring(7);
221
+
222
+ if (token.startsWith('mtc_')) {
223
+ const result = await verifyApiKey(token);
224
+ if (result) {
225
+ req.user = {
226
+ id: result.user.id,
227
+ email: result.user.email,
228
+ tier: result.user.tier,
229
+ };
230
+ }
231
+ } else {
232
+ const decoded = verifySessionToken(token);
233
+ if (decoded) {
234
+ req.user = {
235
+ id: decoded.userId,
236
+ email: decoded.email,
237
+ tier: decoded.tier,
238
+ };
239
+ }
240
+ }
241
+
242
+ next();
243
+ }
244
+
245
+ /**
246
+ * Get tier limits
247
+ */
248
+ export function getTierLimits(tier: string): { twins: number; requestsPerMonth: number; logRetentionDays: number } {
249
+ const limits: Record<string, { twins: number; requestsPerMonth: number; logRetentionDays: number }> = {
250
+ free: { twins: 1, requestsPerMonth: 10_000, logRetentionDays: 1 },
251
+ starter: { twins: 5, requestsPerMonth: 100_000, logRetentionDays: 7 },
252
+ pro: { twins: -1, requestsPerMonth: 1_000_000, logRetentionDays: 30 }, // -1 = unlimited
253
+ enterprise: { twins: -1, requestsPerMonth: -1, logRetentionDays: 90 },
254
+ };
255
+
256
+ return limits[tier] || limits.free;
257
+ }
258
+
259
+ export default {
260
+ hashPassword,
261
+ verifyPassword,
262
+ generateSessionToken,
263
+ verifySessionToken,
264
+ generateApiKey,
265
+ verifyApiKey,
266
+ authenticateApiKey,
267
+ optionalAuth,
268
+ getTierLimits,
269
+ };
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Database Connection - PostgreSQL
3
+ * MCP Twin Cloud
4
+ */
5
+
6
+ import { Pool, PoolClient } from 'pg';
7
+
8
+ let pool: Pool | null = null;
9
+
10
+ export function getPool(): Pool {
11
+ if (!pool) {
12
+ const connectionString = process.env.DATABASE_URL;
13
+
14
+ if (!connectionString) {
15
+ throw new Error('DATABASE_URL environment variable is required');
16
+ }
17
+
18
+ pool = new Pool({
19
+ connectionString,
20
+ ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
21
+ max: 20,
22
+ idleTimeoutMillis: 30000,
23
+ connectionTimeoutMillis: 2000,
24
+ });
25
+
26
+ pool.on('error', (err) => {
27
+ console.error('[DB] Unexpected error on idle client', err);
28
+ });
29
+ }
30
+
31
+ return pool;
32
+ }
33
+
34
+ export async function query<T = any>(text: string, params?: any[]): Promise<T[]> {
35
+ const pool = getPool();
36
+ const start = Date.now();
37
+ const result = await pool.query(text, params);
38
+ const duration = Date.now() - start;
39
+
40
+ if (process.env.DEBUG_SQL) {
41
+ console.log('[DB] Query executed', { text, duration, rows: result.rowCount });
42
+ }
43
+
44
+ return result.rows as T[];
45
+ }
46
+
47
+ export async function queryOne<T = any>(text: string, params?: any[]): Promise<T | null> {
48
+ const rows = await query<T>(text, params);
49
+ return rows[0] || null;
50
+ }
51
+
52
+ export async function transaction<T>(
53
+ callback: (client: PoolClient) => Promise<T>
54
+ ): Promise<T> {
55
+ const pool = getPool();
56
+ const client = await pool.connect();
57
+
58
+ try {
59
+ await client.query('BEGIN');
60
+ const result = await callback(client);
61
+ await client.query('COMMIT');
62
+ return result;
63
+ } catch (error) {
64
+ await client.query('ROLLBACK');
65
+ throw error;
66
+ } finally {
67
+ client.release();
68
+ }
69
+ }
70
+
71
+ export async function initializeDatabase(): Promise<void> {
72
+ console.log('[DB] Initializing database schema...');
73
+
74
+ const schema = `
75
+ -- Users table
76
+ CREATE TABLE IF NOT EXISTS users (
77
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
78
+ email VARCHAR(255) UNIQUE NOT NULL,
79
+ password_hash VARCHAR(255) NOT NULL,
80
+ tier VARCHAR(20) DEFAULT 'free' CHECK (tier IN ('free', 'starter', 'pro', 'enterprise')),
81
+ stripe_customer_id VARCHAR(255),
82
+ stripe_subscription_id VARCHAR(255),
83
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
84
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
85
+ );
86
+
87
+ -- API Keys table
88
+ CREATE TABLE IF NOT EXISTS api_keys (
89
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
90
+ user_id UUID REFERENCES users(id) ON DELETE CASCADE,
91
+ key_hash VARCHAR(255) NOT NULL,
92
+ key_prefix VARCHAR(20) NOT NULL,
93
+ name VARCHAR(100),
94
+ last_used_at TIMESTAMP WITH TIME ZONE,
95
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
96
+ revoked_at TIMESTAMP WITH TIME ZONE
97
+ );
98
+
99
+ -- Twins table
100
+ CREATE TABLE IF NOT EXISTS twins (
101
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
102
+ user_id UUID REFERENCES users(id) ON DELETE CASCADE,
103
+ name VARCHAR(100) NOT NULL,
104
+ config_json JSONB NOT NULL,
105
+ status VARCHAR(20) DEFAULT 'stopped' CHECK (status IN ('stopped', 'starting', 'running', 'error')),
106
+ active_server VARCHAR(1) DEFAULT 'a' CHECK (active_server IN ('a', 'b')),
107
+ port_a INTEGER,
108
+ port_b INTEGER,
109
+ pid_a INTEGER,
110
+ pid_b INTEGER,
111
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
112
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
113
+ );
114
+
115
+ -- Logs table
116
+ CREATE TABLE IF NOT EXISTS logs (
117
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
118
+ user_id UUID REFERENCES users(id) ON DELETE CASCADE,
119
+ twin_id UUID REFERENCES twins(id) ON DELETE CASCADE,
120
+ tool_name VARCHAR(100) NOT NULL,
121
+ args JSONB,
122
+ result JSONB,
123
+ error TEXT,
124
+ status VARCHAR(20) NOT NULL CHECK (status IN ('success', 'error', 'timeout')),
125
+ duration_ms INTEGER NOT NULL,
126
+ server VARCHAR(1) NOT NULL CHECK (server IN ('a', 'b')),
127
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
128
+ );
129
+
130
+ -- Usage tracking (aggregated daily)
131
+ CREATE TABLE IF NOT EXISTS usage_daily (
132
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
133
+ user_id UUID REFERENCES users(id) ON DELETE CASCADE,
134
+ date DATE NOT NULL,
135
+ request_count INTEGER DEFAULT 0,
136
+ error_count INTEGER DEFAULT 0,
137
+ total_duration_ms BIGINT DEFAULT 0,
138
+ UNIQUE(user_id, date)
139
+ );
140
+
141
+ -- Indexes
142
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash) WHERE revoked_at IS NULL;
143
+ CREATE INDEX IF NOT EXISTS idx_twins_user ON twins(user_id);
144
+ CREATE INDEX IF NOT EXISTS idx_logs_twin_created ON logs(twin_id, created_at DESC);
145
+ CREATE INDEX IF NOT EXISTS idx_logs_user_created ON logs(user_id, created_at DESC);
146
+ CREATE INDEX IF NOT EXISTS idx_usage_user_date ON usage_daily(user_id, date DESC);
147
+ `;
148
+
149
+ await query(schema);
150
+ console.log('[DB] Database schema initialized');
151
+ }
152
+
153
+ export async function closePool(): Promise<void> {
154
+ if (pool) {
155
+ await pool.end();
156
+ pool = null;
157
+ }
158
+ }
159
+
160
+ export default {
161
+ query,
162
+ queryOne,
163
+ transaction,
164
+ getPool,
165
+ initializeDatabase,
166
+ closePool,
167
+ };