Skip to content

Commit 06126dd

Browse files
authored
Merge pull request #108 from wheval/fix/issues-85-94-99-101
fix: resolve issues #101, #99, #94, #85
2 parents 8c2ba12 + df569b7 commit 06126dd

File tree

19 files changed

+17704
-499
lines changed

19 files changed

+17704
-499
lines changed

wata-board-dapp/package-lock.json

Lines changed: 15715 additions & 499 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Tier-based Rate Limit Configuration (#85)
3+
*
4+
* Defines per-tier request limits:
5+
* anonymous → 5 req/min
6+
* verified → 15 req/min
7+
* premium → 50 req/min
8+
* admin → 200 req/min
9+
*/
10+
11+
import { UserTier, TierRateLimitConfig } from '../types/userTier';
12+
13+
export const TIER_RATE_LIMITS: Record<UserTier, TierRateLimitConfig> = {
14+
[UserTier.ANONYMOUS]: {
15+
windowMs: 60 * 1000,
16+
maxRequests: 5,
17+
queueSize: 5,
18+
},
19+
[UserTier.VERIFIED]: {
20+
windowMs: 60 * 1000,
21+
maxRequests: 15,
22+
queueSize: 10,
23+
},
24+
[UserTier.PREMIUM]: {
25+
windowMs: 60 * 1000,
26+
maxRequests: 50,
27+
queueSize: 25,
28+
},
29+
[UserTier.ADMIN]: {
30+
windowMs: 60 * 1000,
31+
maxRequests: 200,
32+
queueSize: 50,
33+
},
34+
};
35+
36+
/**
37+
* Get the rate-limit configuration for a given user tier.
38+
* Falls back to ANONYMOUS limits for unknown tiers.
39+
*/
40+
export function getRateLimitForTier(tier: UserTier): TierRateLimitConfig {
41+
return TIER_RATE_LIMITS[tier] ?? TIER_RATE_LIMITS[UserTier.ANONYMOUS];
42+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Metrics Collection Middleware (#99)
3+
*
4+
* Collects per-request API metrics (method, path, status, response time,
5+
* user ID) and exposes aggregation helpers consumed by the
6+
* MonitoringService and the frontend dashboard.
7+
*/
8+
9+
import { Request, Response, NextFunction } from 'express';
10+
11+
export interface ApiMetric {
12+
timestamp: number;
13+
method: string;
14+
path: string;
15+
statusCode: number;
16+
responseTimeMs: number;
17+
userId: string;
18+
}
19+
20+
export interface SystemHealth {
21+
uptime: number;
22+
memoryUsageMb: number;
23+
activeConnections: number;
24+
requestsPerMinute: number;
25+
errorRate: number;
26+
}
27+
28+
class MetricsCollector {
29+
private metrics: ApiMetric[] = [];
30+
private readonly maxRetention = 10_000;
31+
private activeConnections = 0;
32+
33+
// ── Express middleware ──────────────────────────────────────
34+
35+
middleware() {
36+
return (req: Request, res: Response, next: NextFunction) => {
37+
const start = Date.now();
38+
this.activeConnections++;
39+
40+
const userId =
41+
(req.headers['x-user-id'] as string) || req.ip || 'unknown';
42+
43+
res.on('finish', () => {
44+
this.activeConnections--;
45+
const metric: ApiMetric = {
46+
timestamp: Date.now(),
47+
method: req.method,
48+
path: req.path,
49+
statusCode: res.statusCode,
50+
responseTimeMs: Date.now() - start,
51+
userId,
52+
};
53+
54+
this.metrics.push(metric);
55+
56+
if (this.metrics.length > this.maxRetention) {
57+
this.metrics = this.metrics.slice(-this.maxRetention);
58+
}
59+
});
60+
61+
next();
62+
};
63+
}
64+
65+
// ── Query helpers ──────────────────────────────────────────
66+
67+
/** Metrics within the given time window (default 1 min). */
68+
getMetrics(windowMs: number = 60_000): ApiMetric[] {
69+
const cutoff = Date.now() - windowMs;
70+
return this.metrics.filter((m) => m.timestamp > cutoff);
71+
}
72+
73+
/** Aggregated system health snapshot. */
74+
getSystemHealth(): SystemHealth {
75+
const recentMetrics = this.getMetrics(60_000);
76+
const errors = recentMetrics.filter((m) => m.statusCode >= 400);
77+
const mem = process.memoryUsage();
78+
79+
return {
80+
uptime: process.uptime(),
81+
memoryUsageMb: Math.round(mem.heapUsed / 1024 / 1024),
82+
activeConnections: this.activeConnections,
83+
requestsPerMinute: recentMetrics.length,
84+
errorRate:
85+
recentMetrics.length > 0 ? errors.length / recentMetrics.length : 0,
86+
};
87+
}
88+
89+
/** Per-user request counts in the last window. */
90+
getUserMetrics(
91+
windowMs: number = 60_000,
92+
): Record<string, { count: number; errors: number }> {
93+
const recent = this.getMetrics(windowMs);
94+
const result: Record<string, { count: number; errors: number }> = {};
95+
96+
for (const m of recent) {
97+
if (!result[m.userId]) result[m.userId] = { count: 0, errors: 0 };
98+
result[m.userId].count++;
99+
if (m.statusCode >= 400) result[m.userId].errors++;
100+
}
101+
102+
return result;
103+
}
104+
105+
/** Per-endpoint breakdown in the last window. */
106+
getEndpointMetrics(
107+
windowMs: number = 60_000,
108+
): Record<string, { count: number; avgResponseMs: number }> {
109+
const recent = this.getMetrics(windowMs);
110+
const agg: Record<
111+
string,
112+
{ count: number; totalMs: number; avgResponseMs: number }
113+
> = {};
114+
115+
for (const m of recent) {
116+
const key = `${m.method} ${m.path}`;
117+
if (!agg[key]) agg[key] = { count: 0, totalMs: 0, avgResponseMs: 0 };
118+
agg[key].count++;
119+
agg[key].totalMs += m.responseTimeMs;
120+
agg[key].avgResponseMs = Math.round(agg[key].totalMs / agg[key].count);
121+
}
122+
123+
return agg;
124+
}
125+
}
126+
127+
/** Singleton instance */
128+
export const metricsCollector = new MetricsCollector();
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* Tiered Rate Limiter Middleware (#85)
3+
*
4+
* Sliding-window rate limiter that respects user tiers.
5+
* Exposes both an Express middleware and a programmatic API
6+
* (checkLimit / getStatus) so the monitoring service (#99)
7+
* can query rate-limit state without consuming a slot.
8+
*/
9+
10+
import { Request, Response, NextFunction } from 'express';
11+
import { UserTier, TierRateLimitStatus } from '../types/userTier';
12+
import { getRateLimitForTier } from '../config/rateLimits';
13+
import { userTierService } from '../services/userTierService';
14+
import logger from '../utils/logger';
15+
16+
interface WindowEntry {
17+
timestamps: number[];
18+
queueCount: number;
19+
}
20+
21+
export class TieredRateLimiter {
22+
private windows: Map<string, WindowEntry> = new Map();
23+
private cleanupInterval: NodeJS.Timeout;
24+
25+
constructor() {
26+
// Prune stale entries every 2 minutes
27+
this.cleanupInterval = setInterval(() => this.cleanup(), 2 * 60 * 1000);
28+
}
29+
30+
// ── Core logic ─────────────────────────────────────────────
31+
32+
/**
33+
* Check (and consume) one request slot for a user.
34+
*/
35+
checkLimit(userId: string): TierRateLimitStatus {
36+
const tier = userTierService.getUserTier(userId);
37+
const config = getRateLimitForTier(tier);
38+
const now = Date.now();
39+
const windowStart = now - config.windowMs;
40+
41+
let entry = this.windows.get(userId);
42+
if (!entry) {
43+
entry = { timestamps: [], queueCount: 0 };
44+
this.windows.set(userId, entry);
45+
}
46+
47+
// Slide the window
48+
entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
49+
50+
const remaining = config.maxRequests - entry.timestamps.length;
51+
const resetTime = new Date(
52+
entry.timestamps.length > 0
53+
? entry.timestamps[0] + config.windowMs
54+
: now + config.windowMs,
55+
);
56+
57+
if (remaining > 0) {
58+
entry.timestamps.push(now);
59+
return {
60+
tier,
61+
allowed: true,
62+
remainingRequests: remaining - 1,
63+
resetTime,
64+
queued: false,
65+
limit: config.maxRequests,
66+
};
67+
}
68+
69+
// Try to queue the request
70+
if (entry.queueCount < config.queueSize) {
71+
entry.queueCount++;
72+
return {
73+
tier,
74+
allowed: false,
75+
remainingRequests: 0,
76+
resetTime,
77+
queued: true,
78+
queuePosition: entry.queueCount,
79+
limit: config.maxRequests,
80+
};
81+
}
82+
83+
// Rejected entirely
84+
return {
85+
tier,
86+
allowed: false,
87+
remainingRequests: 0,
88+
resetTime,
89+
queued: false,
90+
limit: config.maxRequests,
91+
};
92+
}
93+
94+
/**
95+
* Read-only status check (does NOT consume a request slot).
96+
*/
97+
getStatus(userId: string): TierRateLimitStatus {
98+
const tier = userTierService.getUserTier(userId);
99+
const config = getRateLimitForTier(tier);
100+
const now = Date.now();
101+
const windowStart = now - config.windowMs;
102+
103+
const entry = this.windows.get(userId);
104+
const timestamps = entry
105+
? entry.timestamps.filter((t) => t > windowStart)
106+
: [];
107+
const remaining = Math.max(0, config.maxRequests - timestamps.length);
108+
const resetTime = new Date(
109+
timestamps.length > 0
110+
? timestamps[0] + config.windowMs
111+
: now + config.windowMs,
112+
);
113+
114+
return {
115+
tier,
116+
allowed: remaining > 0,
117+
remainingRequests: remaining,
118+
resetTime,
119+
queued: false,
120+
limit: config.maxRequests,
121+
};
122+
}
123+
124+
// ── Express middleware factory ─────────────────────────────
125+
126+
middleware() {
127+
return (req: Request, res: Response, next: NextFunction) => {
128+
const userId =
129+
(req.headers['x-user-id'] as string) || req.ip || 'unknown';
130+
const status = this.checkLimit(userId);
131+
132+
// Always expose rate-limit headers
133+
res.set('X-RateLimit-Limit', String(status.limit));
134+
res.set('X-RateLimit-Remaining', String(status.remainingRequests));
135+
res.set(
136+
'X-RateLimit-Reset',
137+
String(Math.ceil(status.resetTime.getTime() / 1000)),
138+
);
139+
res.set('X-RateLimit-Tier', status.tier);
140+
141+
if (!status.allowed && !status.queued) {
142+
logger.warn('Rate limit exceeded', { userId, tier: status.tier });
143+
return res.status(429).json({
144+
error: 'Rate limit exceeded',
145+
tier: status.tier,
146+
retryAfter: Math.ceil(
147+
(status.resetTime.getTime() - Date.now()) / 1000,
148+
),
149+
limit: status.limit,
150+
});
151+
}
152+
153+
if (status.queued) {
154+
logger.info('Request queued', {
155+
userId,
156+
tier: status.tier,
157+
position: status.queuePosition,
158+
});
159+
return res.status(202).json({
160+
message: 'Request queued',
161+
queuePosition: status.queuePosition,
162+
tier: status.tier,
163+
});
164+
}
165+
166+
next();
167+
};
168+
}
169+
170+
// ── Helpers ────────────────────────────────────────────────
171+
172+
private cleanup() {
173+
const now = Date.now();
174+
for (const [userId, entry] of this.windows.entries()) {
175+
entry.timestamps = entry.timestamps.filter(
176+
(t) => t > now - 5 * 60 * 1000,
177+
);
178+
if (entry.timestamps.length === 0 && entry.queueCount === 0) {
179+
this.windows.delete(userId);
180+
}
181+
}
182+
}
183+
184+
destroy() {
185+
clearInterval(this.cleanupInterval);
186+
}
187+
}
188+
189+
/** Singleton instance */
190+
export const tieredRateLimiter = new TieredRateLimiter();
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Migration 001 — Initial Data Preservation Setup (#101)
3+
*
4+
* Template migration that demonstrates the execute / rollback
5+
* pattern. Replace the body with real logic before deploying
6+
* a new contract version.
7+
*/
8+
9+
import { MigrationStep } from '../services/contractUpgradeService';
10+
import logger from '../utils/logger';
11+
12+
export const migration001: MigrationStep = {
13+
id: 'migration-001-initial-setup',
14+
version: '1.1.0',
15+
description: 'Set up data preservation mappings for contract upgrade',
16+
17+
async execute(): Promise<void> {
18+
logger.info('Executing migration 001: Initial setup');
19+
// TODO: backup existing payment records to off-chain storage
20+
// TODO: create state mapping for new contract fields
21+
},
22+
23+
async rollback(): Promise<void> {
24+
logger.info('Rolling back migration 001');
25+
// Reverse the changes made in execute()
26+
},
27+
};

0 commit comments

Comments
 (0)