add keycrow feature ideas, exclude features/ from biome
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
|
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": ["src/client/routeTree.gen.ts", "drizzle/"]
|
"ignore": ["src/client/routeTree.gen.ts", "drizzle/", "dist/", "features/"]
|
||||||
},
|
},
|
||||||
"organizeImports": {
|
"organizeImports": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|||||||
80
features/keycrow/README.md
Normal file
80
features/keycrow/README.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# KeyCrow - Steam Key Trading Platform with Escrow
|
||||||
|
|
||||||
|
Technical foundation for a automated Steam key trading platform with escrow system.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client/App │
|
||||||
|
└──────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Backend API (Express) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Routes: auth | listings | transactions | theoretical │
|
||||||
|
└──────┬──────────────┬──────────────────┬───────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐
|
||||||
|
│ Store │ │ Encryption │ │ Services │
|
||||||
|
│ (In-Memory) │ │ Service │ │ - PaymentProvider (Mock) │
|
||||||
|
│ │ │ (AES) │ │ - KeyActivationProvider │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Implemented
|
||||||
|
|
||||||
|
### Realistic Flow (Production-Ready Pattern)
|
||||||
|
1. **Seller** creates a listing with encrypted Steam key
|
||||||
|
2. **Buyer** purchases via escrow (payment held)
|
||||||
|
3. **Platform** delivers decrypted key to buyer
|
||||||
|
4. **Buyer** confirms key works → money released to seller
|
||||||
|
5. **Buyer** reports failure → dispute, refund initiated
|
||||||
|
|
||||||
|
### Theoretica/Ideal Flow (Mock Only)
|
||||||
|
- Automated server-side key activation on buyer's Steam account
|
||||||
|
- **DISABLED by default** - requires `ALLOW_THEORETICAL_ACTIVATION=true`
|
||||||
|
- Clearly marked as potentially violating Steam ToS
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /auth/register` - Register user
|
||||||
|
- `GET /auth/me` - Get current user
|
||||||
|
- `POST /auth/auth/steam/login` - Steam login (mock)
|
||||||
|
|
||||||
|
### Listings
|
||||||
|
- `POST /listings` - Create listing
|
||||||
|
- `GET /listings` - Get active listings
|
||||||
|
- `GET /listings/:id` - Get listing by ID
|
||||||
|
- `GET /listings/seller/me` - Get seller's listings
|
||||||
|
- `DELETE /listings/:id` - Cancel listing
|
||||||
|
|
||||||
|
### Transactions
|
||||||
|
- `POST /transactions` - Create purchase (escrow hold)
|
||||||
|
- `GET /transactions/:id` - Get transaction
|
||||||
|
- `GET /transactions/:id/key` - Get decrypted key (buyer only)
|
||||||
|
- `POST /transactions/:id/confirm` - Confirm key works/failed
|
||||||
|
- `GET /transactions/buyer/me` - Buyer's transactions
|
||||||
|
- `GET /transactions/seller/me` - Seller's transactions
|
||||||
|
|
||||||
|
### Theoretical (Mock)
|
||||||
|
- `POST /theoretical/activate` - Attempt automated activation
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=3000
|
||||||
|
ENCRYPTION_KEY=your-256-bit-key
|
||||||
|
STEAM_API_KEY=your-steam-api-key
|
||||||
|
STEAM_REDIRECT_URI=http://localhost:3000/auth/steam/callback
|
||||||
|
ALLOW_THEORETICAL_ACTIVATION=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Legal Notice
|
||||||
|
|
||||||
|
This implementation is a **technical proof-of-concept**. Automated Steam key activation is likely to violate Steam's Terms of Service unless you have an official partnership with Valve.
|
||||||
|
|
||||||
|
The "theoretical" module is clearly marked and disabled by default. Use at your own risk.
|
||||||
5617
features/keycrow/package-lock.json
generated
Normal file
5617
features/keycrow/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
features/keycrow/package.json
Normal file
34
features/keycrow/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "keycrow-opencode",
|
||||||
|
"version": "2026.02.18",
|
||||||
|
"description": "Steam Key Trading Platform with Escrow - Technical Foundation",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && cp -r src/gui dist/",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "ts-node src/index.ts",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/crypto-js": "^4.2.1",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.3.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
features/keycrow/src/config/index.ts
Normal file
16
features/keycrow/src/config/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const config = {
|
||||||
|
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
|
||||||
|
encryption: {
|
||||||
|
key: process.env.ENCRYPTION_KEY || 'default-dev-key-change-in-prod',
|
||||||
|
},
|
||||||
|
steam: {
|
||||||
|
apiKey: process.env.STEAM_API_KEY || '',
|
||||||
|
redirectUri: process.env.STEAM_REDIRECT_URI || 'http://localhost:3000/auth/steam/callback',
|
||||||
|
},
|
||||||
|
escrow: {
|
||||||
|
holdDurationDays: 7,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
allowTheoreticalActivation: process.env.ALLOW_THEORETICAL_ACTIVATION === 'true',
|
||||||
|
},
|
||||||
|
};
|
||||||
246
features/keycrow/src/gui/index.html
Normal file
246
features/keycrow/src/gui/index.html
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>KeyCrow Admin</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
h1 { color: #333; margin-bottom: 20px; }
|
||||||
|
h2 { color: #555; margin: 20px 0 10px; }
|
||||||
|
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.form-group { margin-bottom: 15px; }
|
||||||
|
label { display: block; margin-bottom: 5px; color: #666; }
|
||||||
|
input, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||||
|
button { background: #007AFF; color: white; border: none; padding: 12px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||||
|
button:hover { background: #0056b3; }
|
||||||
|
button.secondary { background: #6c757d; }
|
||||||
|
button.danger { background: #dc3545; }
|
||||||
|
button.success { background: #28a745; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
|
||||||
|
th { background: #f8f9fa; font-weight: 600; }
|
||||||
|
.status { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.status.ACTIVE { background: #d4edda; color: #155724; }
|
||||||
|
.status.SOLD { background: #cce5ff; color: #004085; }
|
||||||
|
.status.HELD { background: #fff3cd; color: #856404; }
|
||||||
|
.status.RELEASED { background: #d4edda; color: #155724; }
|
||||||
|
.status.REFUNDED { background: #f8d7da; color: #721c24; }
|
||||||
|
.status.PENDING { background: #fff3cd; color: #856404; }
|
||||||
|
.tabs { display: flex; gap: 10px; margin-bottom: 20px; }
|
||||||
|
.tab { padding: 10px 20px; background: white; border: 1px solid #ddd; border-radius: 6px; cursor: pointer; }
|
||||||
|
.tab.active { background: #007AFF; color: white; border-color: #007AFF; }
|
||||||
|
.tab-content { display: none; }
|
||||||
|
.tab-content.active { display: block; }
|
||||||
|
.alert { padding: 15px; border-radius: 6px; margin-bottom: 15px; }
|
||||||
|
.alert.error { background: #f8d7da; color: #721c24; }
|
||||||
|
.alert.success { background: #d4edda; color: #155724; }
|
||||||
|
.user-info { background: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; }
|
||||||
|
pre { background: #f8f9fa; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>KeyCrow Admin</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Session</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>User ID (for testing)</label>
|
||||||
|
<input type="text" id="userId" placeholder="Enter user ID from registration">
|
||||||
|
</div>
|
||||||
|
<button onclick="setUser()">Set User</button>
|
||||||
|
<span id="currentUser" style="margin-left: 15px; color: #666;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" onclick="showTab('listings')">Listings</div>
|
||||||
|
<div class="tab" onclick="showTab('transactions')">Transactions</div>
|
||||||
|
<div class="tab" onclick="showTab('create')">Create Listing</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="listings" class="tab-content active">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Active Listings</h2>
|
||||||
|
<button class="secondary" onclick="loadListings()">Refresh</button>
|
||||||
|
<table style="margin-top: 15px;">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Game</th><th>Platform</th><th>Price</th><th>Status</th><th>Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="listingsTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="transactions" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Transactions</h2>
|
||||||
|
<button class="secondary" onclick="loadTransactions()">Refresh</button>
|
||||||
|
<table style="margin-top: 15px;">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Listing</th><th>Amount</th><th>Escrow</th><th>Status</th><th>Key</th><th>Confirm</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="transactionsTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="create" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Create New Listing</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Game Title</label>
|
||||||
|
<input type="text" id="gameTitle" placeholder="e.g., Cyberpunk 2077">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Platform</label>
|
||||||
|
<select id="platform">
|
||||||
|
<option value="STEAM">Steam</option>
|
||||||
|
<option value="GOG">GOG</option>
|
||||||
|
<option value="EPIC">Epic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Price</label>
|
||||||
|
<input type="number" id="price" step="0.01" placeholder="9.99">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Steam Key</label>
|
||||||
|
<input type="text" id="key" placeholder="XXXX-XXXX-XXXX">
|
||||||
|
</div>
|
||||||
|
<button onclick="createListing()">Create Listing</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alert" class="alert" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
function showTab(tabId) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||||
|
event.target.classList.add('active');
|
||||||
|
document.getElementById(tabId).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAlert(message, type = 'success') {
|
||||||
|
const alert = document.getElementById('alert');
|
||||||
|
alert.className = `alert ${type}`;
|
||||||
|
alert.textContent = message;
|
||||||
|
alert.style.display = 'block';
|
||||||
|
setTimeout(() => alert.style.display = 'none', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUser() {
|
||||||
|
currentUser = document.getElementById('userId').value;
|
||||||
|
if (currentUser) {
|
||||||
|
document.getElementById('currentUser').textContent = `Current user: ${currentUser.substring(0, 8)}...`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, endpoint, body = null) {
|
||||||
|
const options = { method, headers: { 'Content-Type': 'application/json' } };
|
||||||
|
if (currentUser) options.headers['x-user-id'] = currentUser;
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(endpoint, options);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadListings() {
|
||||||
|
const result = await api('GET', '/listings');
|
||||||
|
const tbody = document.getElementById('listingsTable');
|
||||||
|
if (!result.data?.listings?.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6">No listings found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = result.data.listings.map(l => `
|
||||||
|
<tr>
|
||||||
|
<td>${l.id.substring(0, 8)}...</td>
|
||||||
|
<td>${l.gameTitle}</td>
|
||||||
|
<td>${l.platform}</td>
|
||||||
|
<td>${l.price} ${l.currency}</td>
|
||||||
|
<td><span class="status ${l.status}">${l.status}</span></td>
|
||||||
|
<td><button class="secondary" onclick="deleteListing('${l.id}')">Cancel</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createListing() {
|
||||||
|
const data = {
|
||||||
|
gameTitle: document.getElementById('gameTitle').value,
|
||||||
|
platform: document.getElementById('platform').value,
|
||||||
|
price: parseFloat(document.getElementById('price').value),
|
||||||
|
currency: 'EUR',
|
||||||
|
key: document.getElementById('key').value
|
||||||
|
};
|
||||||
|
const result = await api('POST', '/listings', data);
|
||||||
|
if (result.success) {
|
||||||
|
showAlert('Listing created!');
|
||||||
|
document.getElementById('gameTitle').value = '';
|
||||||
|
document.getElementById('price').value = '';
|
||||||
|
document.getElementById('key').value = '';
|
||||||
|
loadListings();
|
||||||
|
} else {
|
||||||
|
showAlert(result.error || 'Failed to create listing', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteListing(id) {
|
||||||
|
if (!confirm('Cancel this listing?')) return;
|
||||||
|
const result = await api('DELETE', `/listings/${id}`);
|
||||||
|
if (result.success) {
|
||||||
|
showAlert('Listing cancelled');
|
||||||
|
loadListings();
|
||||||
|
} else {
|
||||||
|
showAlert(result.error, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTransactions() {
|
||||||
|
const result = await api('GET', '/transactions/buyer/me');
|
||||||
|
const tbody = document.getElementById('transactionsTable');
|
||||||
|
if (!result.data?.transactions?.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7">No transactions found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = result.data.transactions.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td>${t.id.substring(0, 8)}...</td>
|
||||||
|
<td>${t.listingId.substring(0, 8)}...</td>
|
||||||
|
<td>${t.amount} ${t.currency}</td>
|
||||||
|
<td><span class="status ${t.escrowStatus}">${t.escrowStatus}</span></td>
|
||||||
|
<td><span class="status ${t.transactionStatus}">${t.transactionStatus}</span></td>
|
||||||
|
<td>${t.keyDelivered ? '✓' : '<button class="secondary" onclick="getKey(\'' + t.id + '\')">Get Key</button>'}</td>
|
||||||
|
<td>${t.transactionStatus === 'PENDING' ? '<button class="success" onclick="confirmTx(\'' + t.id + '\', \'SUCCESS\')">✓</button> <button class="danger" onclick="confirmTx(\'' + t.id + '\', \'FAILED\')">✗</button>' : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getKey(txId) {
|
||||||
|
const result = await api('GET', `/transactions/${txId}/key`);
|
||||||
|
if (result.success && result.data.key) {
|
||||||
|
showAlert(`Key: ${result.data.key}`);
|
||||||
|
loadTransactions();
|
||||||
|
} else {
|
||||||
|
showAlert(result.error || 'Failed to get key', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmTx(txId, status) {
|
||||||
|
const result = await api('POST', `/transactions/${txId}/confirm`, { status });
|
||||||
|
if (result.success) {
|
||||||
|
showAlert(`Transaction ${status === 'SUCCESS' ? 'confirmed' : 'disputed'}`);
|
||||||
|
loadTransactions();
|
||||||
|
} else {
|
||||||
|
showAlert(result.error, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadListings();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
features/keycrow/src/index.ts
Normal file
43
features/keycrow/src/index.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import express, { Express } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import path from 'path';
|
||||||
|
import { config } from './config';
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
|
import listingsRoutes from './routes/listings';
|
||||||
|
import transactionsRoutes from './routes/transactions';
|
||||||
|
import theoreticalRoutes from './routes/theoretical';
|
||||||
|
|
||||||
|
const app: Express = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'gui')));
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'gui', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/auth', authRoutes);
|
||||||
|
app.use('/listings', listingsRoutes);
|
||||||
|
app.use('/transactions', transactionsRoutes);
|
||||||
|
app.use('/theoretical', theoreticalRoutes);
|
||||||
|
|
||||||
|
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
res.status(500).json({ success: false, error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const startServer = () => {
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
console.log(`Server running on port ${config.port}`);
|
||||||
|
console.log(`Theoretical activation: ${config.features.allowTheoreticalActivation ? 'ENABLED' : 'DISABLED'}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
export default app;
|
||||||
50
features/keycrow/src/middleware/auth.ts
Normal file
50
features/keycrow/src/middleware/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { store } from '../services/Store';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockAuthMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = store.getUserById(userId);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ success: false, error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireSteamAuth = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = store.getUserById(userId);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ success: false, error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.steamId) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Steam authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
next();
|
||||||
|
};
|
||||||
65
features/keycrow/src/models/index.ts
Normal file
65
features/keycrow/src/models/index.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
steamId?: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListingStatus = 'ACTIVE' | 'SOLD' | 'CANCELLED' | 'EXPIRED';
|
||||||
|
export type Platform = 'STEAM' | 'GOG' | 'EPIC' | 'OTHER';
|
||||||
|
|
||||||
|
export interface Listing {
|
||||||
|
id: string;
|
||||||
|
sellerId: string;
|
||||||
|
gameTitle: string;
|
||||||
|
platform: Platform;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
keyEncrypted: string;
|
||||||
|
status: ListingStatus;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EscrowStatus = 'PENDING' | 'HELD' | 'RELEASED' | 'REFUNDED';
|
||||||
|
export type TransactionStatus = 'PENDING' | 'COMPLETED' | 'DISPUTED' | 'CANCELLED';
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: string;
|
||||||
|
listingId: string;
|
||||||
|
buyerId: string;
|
||||||
|
sellerId: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
escrowStatus: EscrowStatus;
|
||||||
|
transactionStatus: TransactionStatus;
|
||||||
|
holdId?: string;
|
||||||
|
keyDelivered: boolean;
|
||||||
|
confirmedAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateListingDto {
|
||||||
|
gameTitle: string;
|
||||||
|
platform: Platform;
|
||||||
|
price: number;
|
||||||
|
currency?: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTransactionDto {
|
||||||
|
listingId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmTransactionDto {
|
||||||
|
status: 'SUCCESS' | 'FAILED';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
75
features/keycrow/src/routes/auth.ts
Normal file
75
features/keycrow/src/routes/auth.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { store } from '../services/Store';
|
||||||
|
import { ApiResponse } from '../models';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/register', (req: Request, res: Response) => {
|
||||||
|
const { username, email } = req.body;
|
||||||
|
|
||||||
|
if (!username || !email) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Username and email required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = store.createUser({ username, email });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { user: { id: user.id, username: user.username, email: user.email } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/me', (req: Request, res: Response) => {
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = store.getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { user },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/auth/steam/login', (req: Request, res: Response) => {
|
||||||
|
const { steamId, username } = req.body;
|
||||||
|
|
||||||
|
if (!steamId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Steam ID required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = store.getUserBySteamId(steamId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = store.createUser({
|
||||||
|
username: username || `SteamUser_${steamId}`,
|
||||||
|
email: '',
|
||||||
|
steamId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
store.updateUser(user.id, { steamId });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: { id: user.id, username: user.username, steamId: user.steamId },
|
||||||
|
message: 'Steam login successful (mock)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
121
features/keycrow/src/routes/listings.ts
Normal file
121
features/keycrow/src/routes/listings.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { store } from '../services/Store';
|
||||||
|
import { encryptionService } from '../utils/encryption';
|
||||||
|
import { mockAuthMiddleware } from '../middleware/auth';
|
||||||
|
import { CreateListingDto } from '../models';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/', mockAuthMiddleware, (req, res: Response) => {
|
||||||
|
const { gameTitle, platform, price, currency = 'EUR', key } = req.body as CreateListingDto;
|
||||||
|
|
||||||
|
if (!gameTitle || !platform || !price || !key) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'gameTitle, platform, price, and key are required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (price <= 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Price must be greater than 0',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyEncrypted = encryptionService.encrypt(key);
|
||||||
|
|
||||||
|
const listing = store.createListing({
|
||||||
|
sellerId: req.userId!,
|
||||||
|
gameTitle,
|
||||||
|
platform,
|
||||||
|
price,
|
||||||
|
currency,
|
||||||
|
keyEncrypted,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
listing: {
|
||||||
|
id: listing.id,
|
||||||
|
gameTitle: listing.gameTitle,
|
||||||
|
platform: listing.platform,
|
||||||
|
price: listing.price,
|
||||||
|
currency: listing.currency,
|
||||||
|
status: listing.status,
|
||||||
|
createdAt: listing.createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', (req, res: Response) => {
|
||||||
|
const listings = store.getActiveListings();
|
||||||
|
|
||||||
|
const safeListings = listings.map(l => ({
|
||||||
|
id: l.id,
|
||||||
|
gameTitle: l.gameTitle,
|
||||||
|
platform: l.platform,
|
||||||
|
price: l.price,
|
||||||
|
currency: l.currency,
|
||||||
|
sellerId: l.sellerId,
|
||||||
|
status: l.status,
|
||||||
|
createdAt: l.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { listings: safeListings },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', (req, res: Response) => {
|
||||||
|
const listing = store.getListingById(req.params.id);
|
||||||
|
|
||||||
|
if (!listing) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Listing not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { listing },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/seller/me', mockAuthMiddleware, (req, res: Response) => {
|
||||||
|
const listings = store.getListingsBySeller(req.userId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { listings },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', mockAuthMiddleware, (req, res: Response) => {
|
||||||
|
const listing = store.getListingById(req.params.id);
|
||||||
|
|
||||||
|
if (!listing) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Listing not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.sellerId !== req.userId) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.status !== 'ACTIVE') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Can only cancel active listings'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
store.updateListing(listing.id, { status: 'CANCELLED' });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { message: 'Listing cancelled' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
35
features/keycrow/src/routes/theoretical.ts
Normal file
35
features/keycrow/src/routes/theoretical.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { keyActivationProvider } from '../services/KeyActivationProvider';
|
||||||
|
import { mockAuthMiddleware, requireSteamAuth } from '../middleware/auth';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/activate', requireSteamAuth, async (req, res: Response) => {
|
||||||
|
if (!config.features.allowTheoreticalActivation) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Automated key activation is disabled. This feature is for demonstration only.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key } = req.body;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Key is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = req.userId;
|
||||||
|
|
||||||
|
const result = await keyActivationProvider.activateKey(user!, key);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: result.success,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
225
features/keycrow/src/routes/transactions.ts
Normal file
225
features/keycrow/src/routes/transactions.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { store } from '../services/Store';
|
||||||
|
import { paymentProvider } from '../services/PaymentProvider';
|
||||||
|
import { keyActivationProvider } from '../services/KeyActivationProvider';
|
||||||
|
import { encryptionService } from '../utils/encryption';
|
||||||
|
import { mockAuthMiddleware, requireSteamAuth } from '../middleware/auth';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { CreateTransactionDto } from '../models';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/', mockAuthMiddleware, async (req, res: Response) => {
|
||||||
|
const { listingId } = req.body as CreateTransactionDto;
|
||||||
|
|
||||||
|
if (!listingId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'listingId is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing = store.getListingById(listingId);
|
||||||
|
|
||||||
|
if (!listing) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Listing not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.status !== 'ACTIVE') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Listing is not available',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.sellerId === req.userId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot buy your own listing',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const holdResult = await paymentProvider.createHold(listing.price, listing.currency);
|
||||||
|
|
||||||
|
if (!holdResult.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: holdResult.error || 'Payment failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = store.createTransaction({
|
||||||
|
listingId: listing.id,
|
||||||
|
buyerId: req.userId!,
|
||||||
|
sellerId: listing.sellerId,
|
||||||
|
amount: listing.price,
|
||||||
|
currency: listing.currency,
|
||||||
|
escrowStatus: 'HELD',
|
||||||
|
transactionStatus: 'PENDING',
|
||||||
|
holdId: holdResult.holdId,
|
||||||
|
keyDelivered: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.updateListing(listing.id, { status: 'SOLD' });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
transaction: {
|
||||||
|
id: transaction.id,
|
||||||
|
listingId: transaction.listingId,
|
||||||
|
amount: transaction.amount,
|
||||||
|
currency: transaction.currency,
|
||||||
|
escrowStatus: transaction.escrowStatus,
|
||||||
|
transactionStatus: transaction.transactionStatus,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', mockAuthMiddleware, (req, res: Response) => {
|
||||||
|
const transaction = store.getTransactionById(req.params.id);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Transaction not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.buyerId !== req.userId && transaction.sellerId !== req.userId) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { transaction },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id/key', mockAuthMiddleware, (req, res: Response) => {
|
||||||
|
const transaction = store.getTransactionById(req.params.id);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Transaction not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.buyerId !== req.userId) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.escrowStatus !== 'HELD') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Key only available when payment is held in escrow',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.keyDelivered) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
keyAlreadyDelivered: true,
|
||||||
|
message: 'Key was already delivered in this transaction',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing = store.getListingById(transaction.listingId);
|
||||||
|
if (!listing) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Listing not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = encryptionService.decrypt(listing.keyEncrypted);
|
||||||
|
|
||||||
|
store.updateTransaction(transaction.id, { keyDelivered: true });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { key },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/confirm', requireSteamAuth, async (req, res: Response) => {
|
||||||
|
const { status } = req.body;
|
||||||
|
|
||||||
|
if (!status || !['SUCCESS', 'FAILED'].includes(status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'status must be SUCCESS or FAILED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = store.getTransactionById(req.params.id);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Transaction not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.buyerId !== req.userId) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.transactionStatus !== 'PENDING') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Transaction already confirmed or disputed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'SUCCESS') {
|
||||||
|
if (transaction.holdId) {
|
||||||
|
await paymentProvider.release(transaction.holdId);
|
||||||
|
}
|
||||||
|
|
||||||
|
store.updateTransaction(transaction.id, {
|
||||||
|
escrowStatus: 'RELEASED',
|
||||||
|
transactionStatus: 'COMPLETED',
|
||||||
|
confirmedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: 'Key confirmed. Payment released to seller.',
|
||||||
|
escrowStatus: 'RELEASED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (transaction.holdId) {
|
||||||
|
await paymentProvider.refund(transaction.holdId);
|
||||||
|
}
|
||||||
|
|
||||||
|
store.updateTransaction(transaction.id, {
|
||||||
|
escrowStatus: 'REFUNDED',
|
||||||
|
transactionStatus: 'DISPUTED',
|
||||||
|
confirmedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: 'Key marked as failed. Payment refunded to buyer.',
|
||||||
|
escrowStatus: 'REFUNDED',
|
||||||
|
transactionStatus: 'DISPUTED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/buyer/me', mockAuthMiddleware, (req, res: Response) => {
|
||||||
|
const transactions = store.getTransactionsByBuyer(req.userId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { transactions },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/seller/me', mockAuthMiddleware, (req, res: Response) => {
|
||||||
|
const transactions = store.getTransactionsBySeller(req.userId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { transactions },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
49
features/keycrow/src/services/KeyActivationProvider.ts
Normal file
49
features/keycrow/src/services/KeyActivationProvider.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export interface ActivationResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
productId?: string;
|
||||||
|
purchaseId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyActivationProvider {
|
||||||
|
activateKey(steamId: string, key: string): Promise<ActivationResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockKeyActivationProvider implements KeyActivationProvider {
|
||||||
|
async activateKey(steamId: string, key: string): Promise<ActivationResult> {
|
||||||
|
await this.simulateDelay();
|
||||||
|
|
||||||
|
if (!key || key.length < 5) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid key format',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.toLowerCase().includes('invalid')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Key has already been redeemed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.toLowerCase().includes('expired')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Key has expired',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
productId: `prod_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
purchaseId: `purch_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private simulateDelay(): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const keyActivationProvider = new MockKeyActivationProvider();
|
||||||
53
features/keycrow/src/services/PaymentProvider.ts
Normal file
53
features/keycrow/src/services/PaymentProvider.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export type PaymentResult = {
|
||||||
|
success: boolean;
|
||||||
|
holdId?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PaymentProvider {
|
||||||
|
createHold(amount: number, currency: string): Promise<PaymentResult>;
|
||||||
|
release(holdId: string): Promise<{ success: boolean; error?: string }>;
|
||||||
|
refund(holdId: string): Promise<{ success: boolean; error?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockPaymentProvider implements PaymentProvider {
|
||||||
|
private holds: Map<string, { amount: number; currency: string; released: boolean }> = new Map();
|
||||||
|
|
||||||
|
async createHold(amount: number, currency: string): Promise<PaymentResult> {
|
||||||
|
const holdId = `hold_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
this.holds.set(holdId, { amount, currency, released: false });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
holdId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async release(holdId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const hold = this.holds.get(holdId);
|
||||||
|
|
||||||
|
if (!hold) {
|
||||||
|
return { success: false, error: 'Hold not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hold.released) {
|
||||||
|
return { success: false, error: 'Hold already released' };
|
||||||
|
}
|
||||||
|
|
||||||
|
hold.released = true;
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async refund(holdId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const hold = this.holds.get(holdId);
|
||||||
|
|
||||||
|
if (!hold) {
|
||||||
|
return { success: false, error: 'Hold not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.holds.delete(holdId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paymentProvider = new MockPaymentProvider();
|
||||||
103
features/keycrow/src/services/Store.ts
Normal file
103
features/keycrow/src/services/Store.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { User, Listing, Transaction } from '../models';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
class InMemoryStore {
|
||||||
|
users: Map<string, User> = new Map();
|
||||||
|
listings: Map<string, Listing> = new Map();
|
||||||
|
transactions: Map<string, Transaction> = new Map();
|
||||||
|
|
||||||
|
createUser(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): User {
|
||||||
|
const user: User = {
|
||||||
|
...data,
|
||||||
|
id: uuidv4(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
this.users.set(user.id, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserById(id: string): User | undefined {
|
||||||
|
return this.users.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserBySteamId(steamId: string): User | undefined {
|
||||||
|
return Array.from(this.users.values()).find(u => u.steamId === steamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUser(id: string, data: Partial<User>): User | undefined {
|
||||||
|
const user = this.users.get(id);
|
||||||
|
if (!user) return undefined;
|
||||||
|
|
||||||
|
const updated = { ...user, ...data, updatedAt: new Date() };
|
||||||
|
this.users.set(id, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
createListing(data: Omit<Listing, 'id' | 'status' | 'createdAt' | 'updatedAt'>): Listing {
|
||||||
|
const listing: Listing = {
|
||||||
|
...data,
|
||||||
|
id: uuidv4(),
|
||||||
|
status: 'ACTIVE',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
this.listings.set(listing.id, listing);
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
|
||||||
|
getListingById(id: string): Listing | undefined {
|
||||||
|
return this.listings.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveListings(): Listing[] {
|
||||||
|
return Array.from(this.listings.values()).filter(l => l.status === 'ACTIVE');
|
||||||
|
}
|
||||||
|
|
||||||
|
getListingsBySeller(sellerId: string): Listing[] {
|
||||||
|
return Array.from(this.listings.values()).filter(l => l.sellerId === sellerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateListing(id: string, data: Partial<Listing>): Listing | undefined {
|
||||||
|
const listing = this.listings.get(id);
|
||||||
|
if (!listing) return undefined;
|
||||||
|
|
||||||
|
const updated = { ...listing, ...data, updatedAt: new Date() };
|
||||||
|
this.listings.set(id, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
createTransaction(data: Omit<Transaction, 'id' | 'createdAt' | 'updatedAt'>): Transaction {
|
||||||
|
const transaction: Transaction = {
|
||||||
|
...data,
|
||||||
|
id: uuidv4(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
this.transactions.set(transaction.id, transaction);
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTransactionById(id: string): Transaction | undefined {
|
||||||
|
return this.transactions.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTransactionsByBuyer(buyerId: string): Transaction[] {
|
||||||
|
return Array.from(this.transactions.values()).filter(t => t.buyerId === buyerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTransactionsBySeller(sellerId: string): Transaction[] {
|
||||||
|
return Array.from(this.transactions.values()).filter(t => t.sellerId === sellerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTransaction(id: string, data: Partial<Transaction>): Transaction | undefined {
|
||||||
|
const transaction = this.transactions.get(id);
|
||||||
|
if (!transaction) return undefined;
|
||||||
|
|
||||||
|
const updated = { ...transaction, ...data, updatedAt: new Date() };
|
||||||
|
this.transactions.set(id, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const store = new InMemoryStore();
|
||||||
33
features/keycrow/src/utils/encryption.ts
Normal file
33
features/keycrow/src/utils/encryption.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import CryptoJS from 'crypto-js';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export class EncryptionService {
|
||||||
|
private static instance: EncryptionService;
|
||||||
|
private readonly key: string;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.key = config.encryption.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): EncryptionService {
|
||||||
|
if (!EncryptionService.instance) {
|
||||||
|
EncryptionService.instance = new EncryptionService();
|
||||||
|
}
|
||||||
|
return EncryptionService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt(plainText: string): string {
|
||||||
|
return CryptoJS.AES.encrypt(plainText, this.key).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(cipherText: string): string {
|
||||||
|
const bytes = CryptoJS.AES.decrypt(cipherText, this.key);
|
||||||
|
return bytes.toString(CryptoJS.enc.Utf8);
|
||||||
|
}
|
||||||
|
|
||||||
|
hash(data: string): string {
|
||||||
|
return CryptoJS.SHA256(data).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptionService = EncryptionService.getInstance();
|
||||||
94
features/keycrow/tests/api.test.ts
Normal file
94
features/keycrow/tests/api.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import app from '../src/index';
|
||||||
|
|
||||||
|
describe('KeyCrow API', () => {
|
||||||
|
let sellerId: string;
|
||||||
|
let buyerId: string;
|
||||||
|
let listingId: string;
|
||||||
|
let transactionId: string;
|
||||||
|
const testKey = 'ABCD-EFGH-IJKL-MNOP';
|
||||||
|
|
||||||
|
describe('Auth Flow', () => {
|
||||||
|
it('should register a seller', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({ username: 'seller1', email: 'seller@test.com' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
sellerId = res.body.data.user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register a buyer with steam', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/auth/auth/steam/login')
|
||||||
|
.send({ steamId: '76561198000000001', username: 'buyer1' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
buyerId = res.body.data.user.id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Listings Flow', () => {
|
||||||
|
it('should create a listing', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/listings')
|
||||||
|
.set('x-user-id', sellerId)
|
||||||
|
.send({
|
||||||
|
gameTitle: 'Test Game',
|
||||||
|
platform: 'STEAM',
|
||||||
|
price: 9.99,
|
||||||
|
currency: 'EUR',
|
||||||
|
key: testKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
listingId = res.body.data.listing.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get active listings', async () => {
|
||||||
|
const res = await request(app).get('/listings');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.data.listings.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Transaction Flow (Escrow)', () => {
|
||||||
|
it('should create a purchase with escrow hold', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/transactions')
|
||||||
|
.set('x-user-id', buyerId)
|
||||||
|
.send({ listingId });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.data.transaction.escrowStatus).toBe('HELD');
|
||||||
|
transactionId = res.body.data.transaction.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deliver key to buyer', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/transactions/${transactionId}/key`)
|
||||||
|
.set('x-user-id', buyerId);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.data.key).toBe(testKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should confirm success and release escrow', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/transactions/${transactionId}/confirm`)
|
||||||
|
.set('x-user-id', buyerId)
|
||||||
|
.send({ status: 'SUCCESS' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.data.escrowStatus).toBe('RELEASED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
19
features/keycrow/tsconfig.json
Normal file
19
features/keycrow/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user