Add database migration scripts and configuration files

- Add verify-migration.js script for testing database migrations
- Add database config module for centralized configuration
- Add chutes.txt prompt for system responses
- Update database implementation and testing documentation
- Add database migration and setup scripts
- Update session system and LLM tool configuration
- Update deployment checklist and environment example
- Update Dockerfile and docker-compose configuration
This commit is contained in:
southseact-3d
2026-02-20 12:38:43 +00:00
parent a92797d3a7
commit cb95a916ae
19 changed files with 1104 additions and 143 deletions

View File

@@ -5,7 +5,7 @@ This implementation adds database encryption at rest and secure session manageme
## Features
### Phase 1.2: Database with Encryption at Rest
- ✅ SQLite database with better-sqlite3
- SQLCipher-encrypted SQLite database with better-sqlite3
- ✅ Field-level AES-256-GCM encryption for sensitive data
- ✅ PBKDF2 key derivation (100,000 iterations)
- ✅ WAL mode for better concurrency
@@ -24,16 +24,17 @@ This implementation adds database encryption at rest and secure session manageme
## Architecture
### Database Schema
- **users**: User accounts with encrypted email, name, 2FA secrets
- **users**: User accounts with encrypted email, name, 2FA secrets + JSON data column
- **sessions**: Active sessions for revocation
- **refresh_tokens**: Refresh tokens with device fingerprinting
- **token_blacklist**: Immediate token revocation
- **affiliates**, **withdrawals**, **feature_requests**, **contact_messages**
- **affiliate_accounts** (current app), **affiliates** (legacy), **withdrawals**, **feature_requests**, **contact_messages**
- **audit_log**: Comprehensive security event logging
- **payment_sessions**: DoDo payment tracking
### Encryption
- **Algorithm**: AES-256-GCM with authenticated encryption
- **Database**: SQLCipher encryption at rest
- **Algorithm**: AES-256-GCM with authenticated encryption (field-level)
- **Key Derivation**: PBKDF2 with 100,000 iterations
- **Per-field**: Sensitive fields encrypted individually
- **Token Storage**: PBKDF2 hashed (not encrypted) for secure comparison
@@ -65,8 +66,12 @@ Optional:
```bash
USE_JSON_DATABASE=1 # Use JSON files instead of database (for rollback)
DATABASE_PATH=./.data/shopify_ai.db
DATABASE_KEY_FILE=./.data/.encryption_key
DATABASE_BACKUP_ENABLED=1
DATABASE_WAL_MODE=1
DATABASE_USE_SQLCIPHER=1
DATABASE_CIPHER_COMPAT=4
DATABASE_KDF_ITER=64000
JWT_ACCESS_TOKEN_TTL=900 # 15 minutes in seconds
JWT_REFRESH_TOKEN_TTL=604800 # 7 days in seconds
```
@@ -135,14 +140,14 @@ export USE_JSON_DATABASE=1
### Verify Database Setup
```bash
# Check database exists and tables are created
sqlite3 ./.data/shopify_ai.db ".tables"
# Check database exists and tables are created (SQLCipher)
sqlcipher ./.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; .tables"
# Should output:
# affiliates payment_sessions token_blacklist
# audit_log refresh_tokens users
# contact_messages sessions withdrawals
# feature_requests
# affiliate_accounts payment_sessions token_blacklist
# affiliates refresh_tokens users
# audit_log sessions withdrawals
# contact_messages feature_requests
```
### Test Encryption
@@ -161,8 +166,8 @@ node scripts/migrate-to-database.js
### Database Health
- Check file size: `ls -lh ./.data/shopify_ai.db`
- Check WAL mode: `sqlite3 ./.data/shopify_ai.db "PRAGMA journal_mode;"`
- Check tables: `sqlite3 ./.data/shopify_ai.db ".tables"`
- Check WAL mode: `sqlcipher ./.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; PRAGMA journal_mode;"`
- Check tables: `sqlcipher ./.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; .tables"`
### Audit Logs
Audit logs are stored in the `audit_log` table and include:

View File

@@ -235,15 +235,15 @@ docker exec shopify-ai-test node /opt/webchat/scripts/migrate-to-database.js
### 7. Verify Tables
```bash
docker exec shopify-ai-test sqlite3 /home/web/data/.data/shopify_ai.db ".tables"
docker exec shopify-ai-test sqlcipher /home/web/data/.data/shopify_ai.db "PRAGMA key = '$DATABASE_ENCRYPTION_KEY'; .tables"
```
Expected output:
```
affiliates payment_sessions token_blacklist
audit_log refresh_tokens users
contact_messages sessions withdrawals
feature_requests
affiliate_accounts payment_sessions token_blacklist
affiliates refresh_tokens users
audit_log sessions withdrawals
contact_messages feature_requests
```
### 8. Check Encryption Keys Persisted

10
chat/package-lock.json generated
View File

@@ -15,7 +15,6 @@
"better-sqlite3": "^11.8.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"nodemailer": "^7.0.7",
"pdfkit": "^0.17.2",
"sharp": "^0.33.5"
}
@@ -1263,15 +1262,6 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",

View File

@@ -7,9 +7,9 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { DATA_ROOT, DB_PATH, KEY_FILE } = require('../src/database/config');
const DATA_ROOT = process.env.CHAT_DATA_ROOT || '/home/web/data/.data';
const DATABASE_PATH = process.env.DATABASE_PATH || path.join(DATA_ROOT, 'shopify_ai.db');
const DATABASE_PATH = DB_PATH;
const USE_JSON_DATABASE = process.env.USE_JSON_DATABASE === '1' || process.env.USE_JSON_DATABASE === 'true';
async function initializeDatabase() {
@@ -33,7 +33,15 @@ async function initializeDatabase() {
if (dbExists) {
console.log('✅ Database already exists:', DATABASE_PATH);
if (!process.env.DATABASE_ENCRYPTION_KEY && KEY_FILE && fs.existsSync(KEY_FILE)) {
const persistedKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
if (persistedKey) {
process.env.DATABASE_ENCRYPTION_KEY = persistedKey;
console.log('✅ Loaded encryption key from file');
}
}
// Verify encryption key is set
if (!process.env.DATABASE_ENCRYPTION_KEY) {
console.error('❌ DATABASE_ENCRYPTION_KEY not set!');
@@ -48,6 +56,14 @@ async function initializeDatabase() {
console.log('🔧 Database not found, setting up new database...');
// Generate encryption key if not provided
if (!process.env.DATABASE_ENCRYPTION_KEY && KEY_FILE && fs.existsSync(KEY_FILE)) {
const persistedKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
if (persistedKey) {
process.env.DATABASE_ENCRYPTION_KEY = persistedKey;
console.log('✅ Loaded encryption key from file');
}
}
if (!process.env.DATABASE_ENCRYPTION_KEY) {
const generatedKey = crypto.randomBytes(32).toString('hex');
process.env.DATABASE_ENCRYPTION_KEY = generatedKey;
@@ -57,7 +73,7 @@ async function initializeDatabase() {
console.log('⚠️ Add this to your environment configuration to persist it!');
// Save to a file for persistence
const keyFile = path.join(dataDir, '.encryption_key');
const keyFile = KEY_FILE || path.join(dataDir, '.encryption_key');
fs.writeFileSync(keyFile, generatedKey, { mode: 0o600 });
console.log('⚠️ Saved to:', keyFile);
}
@@ -86,8 +102,8 @@ async function initializeDatabase() {
}
// Check if there are JSON files to migrate
const usersFile = path.join(DATA_ROOT, 'users.json');
const sessionsFile = path.join(DATA_ROOT, 'user-sessions.json');
const usersFile = path.join(path.join(DATA_ROOT, '.opencode-chat'), 'users.json');
const sessionsFile = path.join(path.join(DATA_ROOT, '.opencode-chat'), 'user-sessions.json');
const hasJsonData = fs.existsSync(usersFile) || fs.existsSync(sessionsFile);

View File

@@ -6,16 +6,16 @@
const fs = require('fs');
const path = require('path');
const { initDatabase, getDatabase, closeDatabase } = require('../src/database/connection');
const { initEncryption } = require('../src/utils/encryption');
const userRepo = require('../src/repositories/userRepository');
const { initEncryption, encrypt } = require('../src/utils/encryption');
const { STATE_DIR, DB_PATH, KEY_FILE } = require('../src/database/config');
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(__dirname, '..', '.data');
const DATABASE_PATH = process.env.DATABASE_PATH || path.join(DATA_ROOT, 'shopify_ai.db');
const DATABASE_PATH = DB_PATH;
const DATABASE_ENCRYPTION_KEY = process.env.DATABASE_ENCRYPTION_KEY;
const DATABASE_USE_SQLCIPHER = process.env.DATABASE_USE_SQLCIPHER !== '0' && process.env.DATABASE_USE_SQLCIPHER !== 'false';
const USERS_FILE = path.join(DATA_ROOT, 'users.json');
const SESSIONS_FILE = path.join(DATA_ROOT, 'user-sessions.json');
const AFFILIATES_FILE = path.join(DATA_ROOT, 'affiliates.json');
const USERS_FILE = path.join(STATE_DIR, 'users.json');
const SESSIONS_FILE = path.join(STATE_DIR, 'user-sessions.json');
const AFFILIATES_FILE = path.join(STATE_DIR, 'affiliates.json');
async function loadJsonFile(filePath, defaultValue = []) {
try {
@@ -44,30 +44,88 @@ async function migrateUsers() {
let success = 0;
let failed = 0;
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO users (
id, email, email_encrypted, name, name_encrypted, password_hash,
providers, email_verified, verification_token, verification_expires_at,
reset_token, reset_expires_at, plan, billing_status, billing_email,
payment_method_last4, subscription_renews_at, referred_by_affiliate_code,
affiliate_attribution_at, affiliate_payouts, two_factor_secret,
two_factor_enabled, created_at, updated_at, last_login_at, data
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
email = excluded.email,
email_encrypted = excluded.email_encrypted,
name = excluded.name,
name_encrypted = excluded.name_encrypted,
password_hash = excluded.password_hash,
providers = excluded.providers,
email_verified = excluded.email_verified,
verification_token = excluded.verification_token,
verification_expires_at = excluded.verification_expires_at,
reset_token = excluded.reset_token,
reset_expires_at = excluded.reset_expires_at,
plan = excluded.plan,
billing_status = excluded.billing_status,
billing_email = excluded.billing_email,
payment_method_last4 = excluded.payment_method_last4,
subscription_renews_at = excluded.subscription_renews_at,
referred_by_affiliate_code = excluded.referred_by_affiliate_code,
affiliate_attribution_at = excluded.affiliate_attribution_at,
affiliate_payouts = excluded.affiliate_payouts,
two_factor_secret = excluded.two_factor_secret,
two_factor_enabled = excluded.two_factor_enabled,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
last_login_at = excluded.last_login_at,
data = excluded.data
`);
for (const user of users) {
try {
// Check if user already exists
const existing = userRepo.getUserById(user.id);
if (existing) {
console.log(` Skipping existing user: ${user.email}`);
success++;
const normalizedEmail = (user.email || '').trim().toLowerCase();
if (!normalizedEmail) {
console.log(' Skipping user without email');
failed++;
continue;
}
// Create user in database
userRepo.createUser({
id: user.id,
email: user.email,
name: user.name || null,
passwordHash: user.passwordHash || user.password_hash,
providers: user.providers || [],
emailVerified: user.emailVerified,
verificationToken: user.verificationToken || null,
verificationExpiresAt: user.verificationExpiresAt || null,
plan: user.plan || 'hobby',
billingStatus: user.billingStatus || 'active',
billingEmail: user.billingEmail || user.email
});
const passwordHash = user.passwordHash || user.password_hash || user.password || '';
const providers = Array.isArray(user.providers) ? user.providers : [];
const affiliatePayouts = Array.isArray(user.affiliatePayouts) ? user.affiliatePayouts : [];
const dataPayload = JSON.stringify({ ...user, email: normalizedEmail || user.email || '' });
const emailEncrypted = normalizedEmail ? encrypt(normalizedEmail) : '';
const nameEncrypted = user.name ? encrypt(user.name) : null;
const twoFactorEncrypted = user.twoFactorSecret ? encrypt(user.twoFactorSecret) : null;
stmt.run(
user.id,
normalizedEmail,
emailEncrypted,
user.name || null,
nameEncrypted,
passwordHash,
JSON.stringify(providers),
user.emailVerified ? 1 : 0,
user.verificationToken || null,
user.verificationExpiresAt || null,
user.resetToken || null,
user.resetExpiresAt || null,
user.plan || 'hobby',
user.billingStatus || 'active',
user.billingEmail || user.email || '',
user.paymentMethodLast4 || '',
user.subscriptionRenewsAt || null,
user.referredByAffiliateCode || null,
user.affiliateAttributionAt || null,
JSON.stringify(affiliatePayouts),
twoFactorEncrypted,
user.twoFactorEnabled ? 1 : 0,
user.createdAt || Date.now(),
user.updatedAt || user.createdAt || Date.now(),
user.lastLoginAt || null,
dataPayload
);
console.log(` ✓ Migrated user: ${user.email}`);
success++;
@@ -99,8 +157,19 @@ async function migrateSessions() {
let expired = 0;
const db = getDatabase();
const sessionRepo = require('../src/repositories/sessionRepository');
const userExistsStmt = db.prepare('SELECT id FROM users WHERE id = ?');
const sessionStmt = db.prepare(`
INSERT INTO sessions (
id, user_id, token, refresh_token_hash, device_fingerprint,
ip_address, user_agent, expires_at, created_at, last_accessed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
token = excluded.token,
expires_at = excluded.expires_at,
last_accessed_at = excluded.last_accessed_at
`);
for (const [token, session] of Object.entries(sessions)) {
try {
// Skip expired sessions
@@ -110,25 +179,25 @@ async function migrateSessions() {
}
// Check if user exists
const user = userRepo.getUserById(session.userId);
const user = userExistsStmt.get(session.userId);
if (!user) {
console.log(` Skipping session for non-existent user: ${session.userId}`);
failed++;
continue;
}
// Create session in database
sessionRepo.createSession({
id: session.id || require('crypto').randomUUID(),
userId: session.userId,
token: token,
deviceFingerprint: session.deviceFingerprint || null,
ipAddress: session.ipAddress || null,
userAgent: session.userAgent || null,
expiresAt: session.expiresAt,
createdAt: session.createdAt || now,
lastAccessedAt: session.lastAccessedAt || now
});
sessionStmt.run(
token,
session.userId,
token,
null,
session.deviceFingerprint || null,
session.ipAddress || null,
session.userAgent || null,
session.expiresAt,
session.createdAt || now,
session.lastAccessedAt || now
);
success++;
} catch (error) {
@@ -156,38 +225,53 @@ async function migrateAffiliates() {
let failed = 0;
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO affiliate_accounts (
id, email, name, password_hash, codes, earnings, commission_rate,
created_at, updated_at, last_login_at, last_payout_at,
email_verified, verification_token, verification_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
email = excluded.email,
name = excluded.name,
password_hash = excluded.password_hash,
codes = excluded.codes,
earnings = excluded.earnings,
commission_rate = excluded.commission_rate,
updated_at = excluded.updated_at,
last_login_at = excluded.last_login_at,
last_payout_at = excluded.last_payout_at,
email_verified = excluded.email_verified,
verification_token = excluded.verification_token,
verification_expires_at = excluded.verification_expires_at
`);
for (const affiliate of affiliates) {
try {
// Check if user exists
const user = userRepo.getUserById(affiliate.userId);
if (!user) {
console.log(` Skipping affiliate for non-existent user: ${affiliate.userId}`);
if (!affiliate.email) {
console.log(' Skipping affiliate without email');
failed++;
continue;
}
// Insert affiliate
const stmt = db.prepare(`
INSERT INTO affiliates (
id, user_id, codes, earnings, commission_rate,
total_referrals, total_earnings_cents, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const affiliateEmail = (affiliate.email || '').trim().toLowerCase();
stmt.run(
affiliate.id || require('crypto').randomUUID(),
affiliate.userId,
affiliateEmail,
affiliate.name || '',
affiliate.password || affiliate.passwordHash || '',
JSON.stringify(affiliate.codes || []),
JSON.stringify(affiliate.earnings || []),
affiliate.commissionRate || 0.15,
affiliate.totalReferrals || 0,
affiliate.totalEarningsCents || 0,
affiliate.createdAt || Date.now(),
affiliate.updatedAt || Date.now()
affiliate.createdAt || new Date().toISOString(),
affiliate.updatedAt || affiliate.createdAt || new Date().toISOString(),
affiliate.lastLoginAt || null,
affiliate.lastPayoutAt || null,
affiliate.emailVerified ? 1 : 0,
affiliate.verificationToken || null,
affiliate.verificationExpiresAt || null
);
console.log(` ✓ Migrated affiliate for user: ${user.email}`);
console.log(` ✓ Migrated affiliate: ${affiliate.email}`);
success++;
} catch (error) {
console.error(` ✗ Failed to migrate affiliate:`, error.message);
@@ -229,7 +313,7 @@ async function createBackup() {
async function runMigration() {
console.log('🔄 Starting database migration...');
console.log(' Source: JSON files in', DATA_ROOT);
console.log(' Source: JSON files in', STATE_DIR);
console.log(' Target: Database at', DATABASE_PATH);
// Check if database exists
@@ -239,13 +323,21 @@ async function runMigration() {
}
// Initialize encryption
if (!DATABASE_ENCRYPTION_KEY) {
let encryptionKey = DATABASE_ENCRYPTION_KEY;
if (!encryptionKey && KEY_FILE && fs.existsSync(KEY_FILE)) {
encryptionKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
if (encryptionKey) {
process.env.DATABASE_ENCRYPTION_KEY = encryptionKey;
console.log('✅ Loaded encryption key from file');
}
}
if (!encryptionKey) {
console.error('❌ DATABASE_ENCRYPTION_KEY not set');
process.exit(1);
}
try {
initEncryption(DATABASE_ENCRYPTION_KEY);
initEncryption(encryptionKey);
console.log('✅ Encryption initialized');
} catch (error) {
console.error('❌ Failed to initialize encryption:', error.message);
@@ -254,7 +346,12 @@ async function runMigration() {
// Initialize database
try {
initDatabase(DATABASE_PATH, { verbose: false });
initDatabase(DATABASE_PATH, {
verbose: false,
sqlcipherKey: DATABASE_USE_SQLCIPHER ? encryptionKey : null,
cipherCompatibility: process.env.DATABASE_CIPHER_COMPAT || 4,
kdfIter: process.env.DATABASE_KDF_ITER || 64000
});
console.log('✅ Database connected');
} catch (error) {
console.error('❌ Failed to connect to database:', error.message);

View File

@@ -8,12 +8,13 @@ const fs = require('fs');
const path = require('path');
const { initDatabase, getDatabase, closeDatabase } = require('../src/database/connection');
const { initEncryption } = require('../src/utils/encryption');
const { DB_PATH, KEY_FILE } = require('../src/database/config');
const crypto = require('crypto');
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(__dirname, '..', '.data');
const DATABASE_PATH = process.env.DATABASE_PATH || path.join(DATA_ROOT, 'shopify_ai.db');
const DATABASE_PATH = DB_PATH;
const DATABASE_ENCRYPTION_KEY = process.env.DATABASE_ENCRYPTION_KEY;
const WAL_MODE = process.env.DATABASE_WAL_MODE !== '0' && process.env.DATABASE_WAL_MODE !== 'false';
const DATABASE_USE_SQLCIPHER = process.env.DATABASE_USE_SQLCIPHER !== '0' && process.env.DATABASE_USE_SQLCIPHER !== 'false';
async function setupDatabase() {
console.log('🔧 Setting up database...');
@@ -26,14 +27,35 @@ async function setupDatabase() {
console.log(' Created data directory:', dataDir);
}
let encryptionKey = DATABASE_ENCRYPTION_KEY;
// Check if encryption key is provided
if (!DATABASE_ENCRYPTION_KEY) {
if (!encryptionKey) {
if (fs.existsSync(KEY_FILE)) {
encryptionKey = fs.readFileSync(KEY_FILE, 'utf8').trim();
if (encryptionKey) {
process.env.DATABASE_ENCRYPTION_KEY = encryptionKey;
console.log('✅ Loaded encryption key from file');
}
}
}
if (!encryptionKey) {
console.warn('⚠️ WARNING: No DATABASE_ENCRYPTION_KEY found!');
console.warn('⚠️ Generating a random key for this session (not persistent).');
console.warn('⚠️ For production, set DATABASE_ENCRYPTION_KEY environment variable.');
console.warn('⚠️ Generate one with: openssl rand -hex 32');
const generatedKey = crypto.randomBytes(32).toString('hex');
process.env.DATABASE_ENCRYPTION_KEY = generatedKey;
encryptionKey = generatedKey;
try {
const keyDir = path.dirname(KEY_FILE);
if (!fs.existsSync(keyDir)) fs.mkdirSync(keyDir, { recursive: true });
fs.writeFileSync(KEY_FILE, generatedKey, { mode: 0o600 });
console.log('⚠️ Saved generated key to:', KEY_FILE);
} catch (err) {
console.warn('⚠️ Failed to persist encryption key:', err.message);
}
console.log('✅ Generated temporary encryption key');
} else {
console.log('✅ Using encryption key from environment');
@@ -41,7 +63,7 @@ async function setupDatabase() {
// Initialize encryption
try {
initEncryption(process.env.DATABASE_ENCRYPTION_KEY);
initEncryption(encryptionKey);
console.log('✅ Encryption initialized');
} catch (error) {
console.error('❌ Failed to initialize encryption:', error.message);
@@ -52,7 +74,10 @@ async function setupDatabase() {
try {
initDatabase(DATABASE_PATH, {
verbose: false,
walMode: WAL_MODE
walMode: WAL_MODE,
sqlcipherKey: DATABASE_USE_SQLCIPHER ? encryptionKey : null,
cipherCompatibility: process.env.DATABASE_CIPHER_COMPAT || 4,
kdfIter: process.env.DATABASE_KDF_ITER || 64000
});
console.log('✅ Database initialized');
} catch (error) {
@@ -70,7 +95,15 @@ async function setupDatabase() {
// Execute the entire schema as one block
// SQLite can handle multiple statements with exec()
db.exec(schema);
// Add missing columns if this is an upgraded database
const userColumns = db.prepare('PRAGMA table_info(users)').all();
const userColumnNames = new Set(userColumns.map((c) => c.name));
if (!userColumnNames.has('data')) {
db.exec('ALTER TABLE users ADD COLUMN data TEXT');
console.log('✅ Added users.data column');
}
console.log('✅ Database schema created');
} catch (error) {
console.error('❌ Failed to create schema:', error.message);

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* Verify JSON -> Database migration counts
*/
const fs = require('fs');
const path = require('path');
const { initDatabase, getDatabase, closeDatabase } = require('../src/database/connection');
const { initEncryption } = require('../src/utils/encryption');
const { STATE_DIR, DB_PATH, KEY_FILE } = require('../src/database/config');
function loadJson(filePath, fallback) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw || JSON.stringify(fallback));
} catch (_) {
return fallback;
}
}
function resolveKey() {
if (process.env.DATABASE_ENCRYPTION_KEY) return process.env.DATABASE_ENCRYPTION_KEY.trim();
if (KEY_FILE && fs.existsSync(KEY_FILE)) {
const key = fs.readFileSync(KEY_FILE, 'utf8').trim();
if (key) {
process.env.DATABASE_ENCRYPTION_KEY = key;
return key;
}
}
return '';
}
function main() {
const key = resolveKey();
if (!key) {
console.error('❌ DATABASE_ENCRYPTION_KEY not set (and no key file found).');
process.exit(1);
}
initEncryption(key);
const useSqlcipher = process.env.DATABASE_USE_SQLCIPHER !== '0' && process.env.DATABASE_USE_SQLCIPHER !== 'false';
initDatabase(DB_PATH, {
sqlcipherKey: useSqlcipher ? key : null,
cipherCompatibility: process.env.DATABASE_CIPHER_COMPAT || 4,
kdfIter: process.env.DATABASE_KDF_ITER || 64000
});
const db = getDatabase();
const now = Date.now();
const usersJson = loadJson(path.join(STATE_DIR, 'users.json'), []);
const sessionsJson = loadJson(path.join(STATE_DIR, 'user-sessions.json'), {});
const affiliatesJson = loadJson(path.join(STATE_DIR, 'affiliates.json'), []);
const usersDb = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const sessionsDb = db.prepare('SELECT COUNT(*) as count FROM sessions WHERE expires_at > ?').get(now).count;
const affiliatesDb = db.prepare('SELECT COUNT(*) as count FROM affiliate_accounts').get().count;
const activeJsonSessions = Object.values(sessionsJson || {}).filter((session) => session?.expiresAt && session.expiresAt > now).length;
console.log('Migration verification:');
console.log(` Users: JSON=${Array.isArray(usersJson) ? usersJson.length : 0} DB=${usersDb}`);
console.log(` Sessions (active): JSON=${activeJsonSessions} DB=${sessionsDb}`);
console.log(` Affiliates: JSON=${Array.isArray(affiliatesJson) ? affiliatesJson.length : 0} DB=${affiliatesDb}`);
closeDatabase();
}
main();

View File

@@ -18,6 +18,9 @@ const security = require('./security');
const { createExternalWpTester, getExternalTestingConfig } = require('./external-wp-testing');
const blogSystem = require('./blog-system');
const versionManager = require('./src/utils/versionManager');
const { DATA_ROOT, STATE_DIR, DB_PATH, KEY_FILE } = require('./src/database/config');
const { initDatabase, getDatabase, closeDatabase } = require('./src/database/connection');
const { initEncryption, encrypt } = require('./src/utils/encryption');
let sharp = null;
try {
@@ -100,8 +103,11 @@ function detectDockerLimits(baseMemoryBytes, baseCpuCores, repoRoot) {
const PORT = Number(process.env.CHAT_PORT || 4000);
const HOST = process.env.CHAT_HOST || '0.0.0.0';
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(process.cwd(), '.data');
const STATE_DIR = path.join(DATA_ROOT, '.opencode-chat');
const USE_JSON_DATABASE = process.env.USE_JSON_DATABASE === '1' || process.env.USE_JSON_DATABASE === 'true';
const DATABASE_WAL_MODE = process.env.DATABASE_WAL_MODE !== '0' && process.env.DATABASE_WAL_MODE !== 'false';
const DATABASE_USE_SQLCIPHER = process.env.DATABASE_USE_SQLCIPHER !== '0' && process.env.DATABASE_USE_SQLCIPHER !== 'false';
const DATABASE_CIPHER_COMPAT = process.env.DATABASE_CIPHER_COMPAT || 4;
const DATABASE_KDF_ITER = process.env.DATABASE_KDF_ITER || 64000;
const STATE_FILE = path.join(STATE_DIR, 'sessions.json');
const WORKSPACES_ROOT = path.join(DATA_ROOT, 'apps');
const STATIC_ROOT = path.join(__dirname, 'public');
@@ -1574,6 +1580,8 @@ const apiRateLimit = new Map(); // { userId: { requests, windowStart } }
const csrfTokens = new Map(); // { token: { userId, expiresAt } }
let usersDb = []; // In-memory user database cache
let invoicesDb = []; // In-memory invoice database cache
let databaseEnabled = false;
let databaseFallbackReason = '';
let mailTransport = null;
function summarizeMailConfig() {
@@ -2154,9 +2162,197 @@ function requireAdminAuth(req, res) {
return session;
}
async function loadDatabaseKey() {
if (process.env.DATABASE_ENCRYPTION_KEY) {
return process.env.DATABASE_ENCRYPTION_KEY.trim();
}
try {
if (KEY_FILE && fsSync.existsSync(KEY_FILE)) {
const key = fsSync.readFileSync(KEY_FILE, 'utf8').trim();
if (key) {
process.env.DATABASE_ENCRYPTION_KEY = key;
return key;
}
}
} catch (_) { }
return '';
}
async function initializeDatabaseLayer() {
if (USE_JSON_DATABASE) {
databaseEnabled = false;
databaseFallbackReason = 'USE_JSON_DATABASE';
log('Database disabled via USE_JSON_DATABASE, using JSON storage');
return;
}
const dbExists = fsSync.existsSync(DB_PATH);
const key = await loadDatabaseKey();
if (!key) {
databaseEnabled = false;
databaseFallbackReason = dbExists ? 'missing_key' : 'no_key';
if (dbExists) {
log('DATABASE_ENCRYPTION_KEY missing; falling back to JSON mode', { DB_PATH, KEY_FILE });
} else {
log('No database key and no database found; using JSON mode', { DB_PATH });
}
return;
}
try {
initEncryption(key);
} catch (error) {
databaseEnabled = false;
databaseFallbackReason = 'encryption_init_failed';
log('Failed to initialize encryption; falling back to JSON mode', { error: String(error) });
return;
}
try {
initDatabase(DB_PATH, {
walMode: DATABASE_WAL_MODE,
sqlcipherKey: DATABASE_USE_SQLCIPHER ? key : null,
cipherCompatibility: DATABASE_CIPHER_COMPAT,
kdfIter: DATABASE_KDF_ITER
});
ensureDatabaseSchema();
databaseEnabled = true;
databaseFallbackReason = '';
log('Database mode enabled', { dbPath: DB_PATH, sqlcipher: DATABASE_USE_SQLCIPHER });
} catch (error) {
closeDatabase();
databaseEnabled = false;
databaseFallbackReason = 'db_init_failed';
log('Failed to initialize database; falling back to JSON mode', { error: String(error) });
}
}
function ensureDatabaseSchema() {
const db = getDatabase();
if (!db) return;
try {
const userColumns = db.prepare('PRAGMA table_info(users)').all();
const userColumnNames = new Set(userColumns.map((c) => c.name));
if (!userColumnNames.has('data')) {
db.exec('ALTER TABLE users ADD COLUMN data TEXT');
log('Added users.data column');
}
db.exec(`
CREATE TABLE IF NOT EXISTS affiliate_accounts (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT,
password_hash TEXT NOT NULL,
codes TEXT NOT NULL DEFAULT '[]',
earnings TEXT NOT NULL DEFAULT '[]',
commission_rate REAL NOT NULL DEFAULT 0.15,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT,
last_payout_at TEXT,
email_verified INTEGER DEFAULT 0,
verification_token TEXT,
verification_expires_at TEXT
);
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_affiliate_accounts_email ON affiliate_accounts(email);');
} catch (error) {
log('Failed to ensure database schema', { error: String(error) });
}
}
// User authentication functions
async function loadUsersDb() {
let shouldPersist = false;
if (databaseEnabled) {
try {
const db = getDatabase();
if (!db) throw new Error('Database not initialized');
const rows = db.prepare('SELECT * FROM users').all();
usersDb = rows.map((row) => {
let payload = {};
if (row?.data) {
try {
payload = JSON.parse(row.data);
} catch (_) {
payload = {};
}
}
const user = { ...payload };
user.id = row.id;
user.email = row.email || user.email || '';
user.password = row.password_hash || user.password || '';
if (row.providers) {
try {
user.providers = JSON.parse(row.providers);
} catch (_) {
user.providers = Array.isArray(user.providers) ? user.providers : [];
}
} else {
user.providers = Array.isArray(user.providers) ? user.providers : [];
}
user.emailVerified = typeof user.emailVerified === 'boolean' ? user.emailVerified : Boolean(row.email_verified);
user.verificationToken = row.verification_token || user.verificationToken || '';
user.verificationExpiresAt = row.verification_expires_at || user.verificationExpiresAt || null;
user.resetToken = row.reset_token || user.resetToken || '';
user.resetExpiresAt = row.reset_expires_at || user.resetExpiresAt || null;
user.plan = row.plan || user.plan || null;
user.billingStatus = row.billing_status || user.billingStatus || DEFAULT_BILLING_STATUS;
user.billingEmail = row.billing_email || user.billingEmail || user.email || '';
user.paymentMethodLast4 = row.payment_method_last4 || user.paymentMethodLast4 || '';
user.subscriptionRenewsAt = row.subscription_renews_at || user.subscriptionRenewsAt || null;
user.referredByAffiliateCode = sanitizeAffiliateCode(row.referred_by_affiliate_code || user.referredByAffiliateCode);
user.affiliateAttributionAt = row.affiliate_attribution_at || user.affiliateAttributionAt || null;
if (row.affiliate_payouts) {
try {
user.affiliatePayouts = JSON.parse(row.affiliate_payouts);
} catch (_) {
user.affiliatePayouts = Array.isArray(user.affiliatePayouts) ? user.affiliatePayouts : [];
}
} else {
user.affiliatePayouts = Array.isArray(user.affiliatePayouts) ? user.affiliatePayouts : [];
}
user.twoFactorSecret = row.two_factor_secret || user.twoFactorSecret || null;
user.twoFactorEnabled = typeof user.twoFactorEnabled === 'boolean' ? user.twoFactorEnabled : Boolean(row.two_factor_enabled);
user.createdAt = row.created_at || user.createdAt || null;
user.updatedAt = row.updated_at || user.updatedAt || null;
user.lastLoginAt = row.last_login_at || user.lastLoginAt || null;
const verification = normalizeVerificationState(user);
if (verification.shouldPersist) shouldPersist = true;
const normalizedPlan = normalizePlanSelection(user?.plan) || DEFAULT_PLAN;
return {
...user,
emailVerified: verification.verified,
verificationToken: verification.verificationToken,
verificationExpiresAt: verification.verificationExpiresAt,
plan: normalizedPlan,
billingStatus: user?.billingStatus || DEFAULT_BILLING_STATUS,
billingEmail: user?.billingEmail || user?.email || '',
paymentMethodLast4: user?.paymentMethodLast4 || '',
subscriptionRenewsAt: user?.subscriptionRenewsAt || null,
referredByAffiliateCode: sanitizeAffiliateCode(user?.referredByAffiliateCode),
affiliateAttributionAt: user?.affiliateAttributionAt || null,
affiliatePayouts: Array.isArray(user?.affiliatePayouts)
? user.affiliatePayouts.map((p) => normalizePlanSelection(p)).filter(Boolean)
: [],
};
});
if (shouldPersist) {
await persistUsersDb();
}
log('Loaded users database', { count: usersDb.length, mode: 'database' });
return;
} catch (error) {
log('Failed to load users database from DB, falling back to JSON', { error: String(error) });
closeDatabase();
databaseEnabled = false;
databaseFallbackReason = 'load_failed';
}
}
try {
await ensureStateFile();
await fs.access(USERS_DB_FILE);
@@ -2198,12 +2394,136 @@ async function loadUsersDb() {
}
async function persistUsersDb() {
if (databaseEnabled) {
try {
const db = getDatabase();
if (!db) throw new Error('Database not initialized');
const now = new Date().toISOString();
const stmt = db.prepare(`
INSERT INTO users (
id, email, email_encrypted, name, name_encrypted, password_hash,
providers, email_verified, verification_token, verification_expires_at,
reset_token, reset_expires_at, plan, billing_status, billing_email,
payment_method_last4, subscription_renews_at, referred_by_affiliate_code,
affiliate_attribution_at, affiliate_payouts, two_factor_secret,
two_factor_enabled, created_at, updated_at, last_login_at, data
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
email = excluded.email,
email_encrypted = excluded.email_encrypted,
name = excluded.name,
name_encrypted = excluded.name_encrypted,
password_hash = excluded.password_hash,
providers = excluded.providers,
email_verified = excluded.email_verified,
verification_token = excluded.verification_token,
verification_expires_at = excluded.verification_expires_at,
reset_token = excluded.reset_token,
reset_expires_at = excluded.reset_expires_at,
plan = excluded.plan,
billing_status = excluded.billing_status,
billing_email = excluded.billing_email,
payment_method_last4 = excluded.payment_method_last4,
subscription_renews_at = excluded.subscription_renews_at,
referred_by_affiliate_code = excluded.referred_by_affiliate_code,
affiliate_attribution_at = excluded.affiliate_attribution_at,
affiliate_payouts = excluded.affiliate_payouts,
two_factor_secret = excluded.two_factor_secret,
two_factor_enabled = excluded.two_factor_enabled,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
last_login_at = excluded.last_login_at,
data = excluded.data
`);
const ids = [];
db.transaction(() => {
for (const user of usersDb) {
if (!user || !user.id) continue;
ids.push(user.id);
const normalizedEmail = (user.email || '').trim().toLowerCase();
const passwordHash = user.passwordHash || user.password || '';
const referredCode = sanitizeAffiliateCode(user.referredByAffiliateCode);
const providers = Array.isArray(user.providers) ? user.providers : [];
const affiliatePayouts = Array.isArray(user.affiliatePayouts) ? user.affiliatePayouts : [];
const dataPayload = JSON.stringify({ ...user, email: normalizedEmail || user.email || '' });
const emailEncrypted = normalizedEmail ? encrypt(normalizedEmail) : '';
const nameEncrypted = user.name ? encrypt(user.name) : null;
const twoFactorEncrypted = user.twoFactorSecret ? encrypt(user.twoFactorSecret) : null;
stmt.run(
user.id,
normalizedEmail,
emailEncrypted,
user.name || null,
nameEncrypted,
passwordHash,
JSON.stringify(providers),
user.emailVerified ? 1 : 0,
user.verificationToken || null,
user.verificationExpiresAt || null,
user.resetToken || null,
user.resetExpiresAt || null,
user.plan || 'hobby',
user.billingStatus || DEFAULT_BILLING_STATUS,
user.billingEmail || user.email || '',
user.paymentMethodLast4 || '',
user.subscriptionRenewsAt || null,
referredCode || null,
user.affiliateAttributionAt || null,
JSON.stringify(affiliatePayouts),
twoFactorEncrypted,
user.twoFactorEnabled ? 1 : 0,
user.createdAt || now,
user.updatedAt || user.createdAt || now,
user.lastLoginAt || null,
dataPayload
);
}
if (ids.length === 0) {
db.prepare('DELETE FROM users').run();
} else if (ids.length <= 900) {
const placeholders = ids.map(() => '?').join(',');
db.prepare(`DELETE FROM users WHERE id NOT IN (${placeholders})`).run(...ids);
} else {
log('Skipping users cleanup (too many rows for NOT IN)', { count: ids.length });
}
})();
} catch (error) {
log('Failed to persist users database (DB)', { error: String(error) });
}
return;
}
await ensureStateFile();
const payload = JSON.stringify(usersDb, null, 2);
await safeWriteFile(USERS_DB_FILE, payload);
}
async function loadUserSessions() {
userSessions.clear();
if (databaseEnabled) {
try {
const db = getDatabase();
if (!db) throw new Error('Database not initialized');
const now = Date.now();
const rows = db.prepare('SELECT token, user_id, expires_at FROM sessions WHERE expires_at > ?').all(now);
for (const row of rows) {
if (row?.token && row?.user_id) {
userSessions.set(row.token, { userId: row.user_id, expiresAt: row.expires_at });
}
}
log('Loaded user sessions', { count: userSessions.size, mode: 'database' });
return;
} catch (error) {
log('Failed to load user sessions from DB, falling back to JSON', { error: String(error) });
closeDatabase();
databaseEnabled = false;
databaseFallbackReason = 'load_failed';
}
}
try {
await ensureStateFile();
const raw = await fs.readFile(USER_SESSIONS_FILE, 'utf8').catch(() => null);
@@ -2223,6 +2543,57 @@ async function loadUserSessions() {
}
async function persistUserSessions() {
if (databaseEnabled) {
try {
const db = getDatabase();
if (!db) throw new Error('Database not initialized');
const now = Date.now();
const tokens = [];
const stmt = db.prepare(`
INSERT INTO sessions (
id, user_id, token, refresh_token_hash, device_fingerprint,
ip_address, user_agent, expires_at, created_at, last_accessed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
token = excluded.token,
expires_at = excluded.expires_at,
last_accessed_at = excluded.last_accessed_at
`);
db.transaction(() => {
db.prepare('DELETE FROM sessions WHERE expires_at <= ?').run(now);
for (const [token, session] of userSessions.entries()) {
if (!session.expiresAt || session.expiresAt <= now) continue;
tokens.push(token);
stmt.run(
token,
session.userId,
token,
null,
null,
null,
null,
session.expiresAt,
now,
now
);
}
if (tokens.length === 0) {
db.prepare('DELETE FROM sessions').run();
} else if (tokens.length <= 900) {
const placeholders = tokens.map(() => '?').join(',');
db.prepare(`DELETE FROM sessions WHERE token NOT IN (${placeholders})`).run(...tokens);
} else {
log('Skipping session cleanup (too many rows for NOT IN)', { count: tokens.length });
}
})();
} catch (error) {
log('Failed to persist user sessions (DB)', { error: String(error) });
}
return;
}
await ensureStateFile();
const now = Date.now();
const sessions = {};
@@ -2240,6 +2611,72 @@ function sanitizeAffiliateCode(code) {
}
async function loadAffiliatesDb() {
if (databaseEnabled) {
try {
const db = getDatabase();
if (!db) throw new Error('Database not initialized');
const rows = db.prepare('SELECT * FROM affiliate_accounts').all();
affiliatesDb = rows.map((row) => ({
id: row.id,
email: (row.email || '').trim().toLowerCase(),
name: row.name || row.email,
password: row.password_hash || '',
createdAt: row.created_at || new Date().toISOString(),
lastLoginAt: row.last_login_at || null,
commissionRate: Number.isFinite(row.commission_rate) ? row.commission_rate : AFFILIATE_COMMISSION_RATE,
codes: (() => {
try {
const parsed = JSON.parse(row.codes || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
})(),
earnings: (() => {
try {
const parsed = JSON.parse(row.earnings || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
})(),
lastPayoutAt: row.last_payout_at || null,
emailVerified: Boolean(row.email_verified),
verificationToken: row.verification_token || '',
verificationExpiresAt: row.verification_expires_at || null,
updatedAt: row.updated_at || row.created_at || new Date().toISOString(),
}));
let changed = false;
for (const affiliate of affiliatesDb) {
affiliate.codes = Array.isArray(affiliate?.codes) && affiliate.codes.length
? affiliate.codes.map((c) => ({
code: sanitizeAffiliateCode(c.code || c.id || c.slug || ''),
label: c.label || 'Tracking link',
createdAt: c.createdAt || affiliate.createdAt || new Date().toISOString(),
})).filter((c) => c.code)
: [];
affiliate.earnings = Array.isArray(affiliate?.earnings) ? affiliate.earnings : [];
if (!affiliate.codes || !affiliate.codes.length) {
const fallbackCode = generateTrackingCode();
affiliate.codes = [{
code: fallbackCode,
label: 'Default link',
createdAt: affiliate.createdAt || new Date().toISOString(),
}];
changed = true;
}
}
if (changed) await persistAffiliatesDb();
log('Loaded affiliates database', { count: affiliatesDb.length, mode: 'database' });
return;
} catch (error) {
log('Failed to load affiliates database from DB, falling back to JSON', { error: String(error) });
closeDatabase();
databaseEnabled = false;
databaseFallbackReason = 'load_failed';
}
}
try {
await ensureStateFile();
let raw = '[]';
@@ -2278,6 +2715,69 @@ async function loadAffiliatesDb() {
}
async function persistAffiliatesDb() {
if (databaseEnabled) {
try {
const db = getDatabase();
if (!db) throw new Error('Database not initialized');
const now = new Date().toISOString();
const stmt = db.prepare(`
INSERT INTO affiliate_accounts (
id, email, name, password_hash, codes, earnings, commission_rate,
created_at, updated_at, last_login_at, last_payout_at,
email_verified, verification_token, verification_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
email = excluded.email,
name = excluded.name,
password_hash = excluded.password_hash,
codes = excluded.codes,
earnings = excluded.earnings,
commission_rate = excluded.commission_rate,
updated_at = excluded.updated_at,
last_login_at = excluded.last_login_at,
last_payout_at = excluded.last_payout_at,
email_verified = excluded.email_verified,
verification_token = excluded.verification_token,
verification_expires_at = excluded.verification_expires_at
`);
const ids = [];
db.transaction(() => {
for (const affiliate of affiliatesDb) {
if (!affiliate || !affiliate.id) continue;
ids.push(affiliate.id);
stmt.run(
affiliate.id,
(affiliate.email || '').trim().toLowerCase(),
affiliate.name || '',
affiliate.password || affiliate.passwordHash || '',
JSON.stringify(Array.isArray(affiliate.codes) ? affiliate.codes : []),
JSON.stringify(Array.isArray(affiliate.earnings) ? affiliate.earnings : []),
Number.isFinite(affiliate.commissionRate) ? affiliate.commissionRate : AFFILIATE_COMMISSION_RATE,
affiliate.createdAt || now,
affiliate.updatedAt || affiliate.createdAt || now,
affiliate.lastLoginAt || null,
affiliate.lastPayoutAt || null,
affiliate.emailVerified ? 1 : 0,
affiliate.verificationToken || null,
affiliate.verificationExpiresAt || null
);
}
if (ids.length === 0) {
db.prepare('DELETE FROM affiliate_accounts').run();
} else if (ids.length <= 900) {
const placeholders = ids.map(() => '?').join(',');
db.prepare(`DELETE FROM affiliate_accounts WHERE id NOT IN (${placeholders})`).run(...ids);
} else {
log('Skipping affiliates cleanup (too many rows for NOT IN)', { count: ids.length });
}
})();
} catch (error) {
log('Failed to persist affiliates database (DB)', { error: String(error) });
}
return;
}
await ensureStateFile();
const payload = JSON.stringify(affiliatesDb, null, 2);
await safeWriteFile(AFFILIATES_FILE, payload);
@@ -19421,6 +19921,7 @@ async function bootstrap() {
await loadSubscriptionSessions();
await loadPendingSubscriptions();
await loadInvoicesDb();
await initializeDatabaseLayer();
await loadUsersDb(); // Load user authentication database
await loadUserSessions(); // Load user sessions
await loadAffiliatesDb();

View File

@@ -0,0 +1,21 @@
const path = require('path');
function resolvePath(root, value, fallback) {
if (!value) return fallback;
if (path.isAbsolute(value)) return value;
return path.join(root, value);
}
const DATA_ROOT = process.env.CHAT_DATA_ROOT || path.join(process.cwd(), '.data');
const STATE_DIR = path.join(DATA_ROOT, '.opencode-chat');
const DEFAULT_DB_PATH = path.join(DATA_ROOT, '.data', 'shopify_ai.db');
const DB_PATH = resolvePath(DATA_ROOT, process.env.DATABASE_PATH, DEFAULT_DB_PATH);
const DEFAULT_KEY_FILE = path.join(path.dirname(DB_PATH), '.encryption_key');
const KEY_FILE = resolvePath(DATA_ROOT, process.env.DATABASE_KEY_FILE, DEFAULT_KEY_FILE);
module.exports = {
DATA_ROOT,
STATE_DIR,
DB_PATH,
KEY_FILE
};

View File

@@ -1,7 +1,7 @@
/**
* Database connection module with SQLite support
* Uses better-sqlite3 for synchronous operations
* Note: SQLCipher support requires special compilation, using AES-256-GCM encryption at field level instead
* Note: SQLCipher support requires special compilation and is enabled via configuration
*/
const Database = require('better-sqlite3');
@@ -11,6 +11,10 @@ const fs = require('fs');
let db = null;
let dbPath = null;
function escapeSqliteString(value) {
return String(value || '').replace(/'/g, "''");
}
/**
* Initialize database connection
* @param {string} databasePath - Path to the database file
@@ -43,6 +47,18 @@ function initDatabase(databasePath, options = {}) {
db = new Database(databasePath, dbOptions);
// SQLCipher support (optional)
if (options.sqlcipherKey) {
const escapedKey = escapeSqliteString(options.sqlcipherKey);
db.pragma(`key = '${escapedKey}'`);
if (options.cipherCompatibility) {
db.pragma(`cipher_compatibility = ${Number(options.cipherCompatibility)}`);
}
if (options.kdfIter) {
db.pragma(`kdf_iter = ${Number(options.kdfIter)}`);
}
}
// Enable WAL mode for better concurrency
if (options.walMode !== false) {
db.pragma('journal_mode = WAL');

View File

@@ -31,7 +31,8 @@ CREATE TABLE IF NOT EXISTS users (
two_factor_enabled INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_login_at INTEGER
last_login_at INTEGER,
data TEXT -- Full user payload (JSON)
);
-- Sessions table for active user sessions
@@ -92,6 +93,24 @@ CREATE TABLE IF NOT EXISTS affiliates (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Affiliate accounts (used by current app)
CREATE TABLE IF NOT EXISTS affiliate_accounts (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT,
password_hash TEXT NOT NULL,
codes TEXT NOT NULL DEFAULT '[]', -- JSON array of tracking codes
earnings TEXT NOT NULL DEFAULT '[]', -- JSON array of earnings
commission_rate REAL NOT NULL DEFAULT 0.15,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT,
last_payout_at TEXT,
email_verified INTEGER DEFAULT 0,
verification_token TEXT,
verification_expires_at TEXT
);
-- Withdrawals table
CREATE TABLE IF NOT EXISTS withdrawals (
id TEXT PRIMARY KEY,
@@ -172,6 +191,7 @@ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token
CREATE INDEX IF NOT EXISTS idx_token_blacklist_token_jti ON token_blacklist(token_jti);
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires_at ON token_blacklist(expires_at);
CREATE INDEX IF NOT EXISTS idx_affiliates_user_id ON affiliates(user_id);
CREATE INDEX IF NOT EXISTS idx_affiliate_accounts_email ON affiliate_accounts(email);
CREATE INDEX IF NOT EXISTS idx_withdrawals_affiliate_id ON withdrawals(affiliate_id);
CREATE INDEX IF NOT EXISTS idx_feature_requests_user_id ON feature_requests(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);