Security hardening: rate limit whitelist for home subnet, relaxed dev limits, ORATS fixes
- Add .env to .gitignore (already present) - Whitelist 192.168.x.x, 10.x.x.x, 127.x in rate limiter - Relax rate limits: 500/15min general, 50/15min auth (was 100/10) - ORATS API v2 integration: datav2 base URL, ?token= auth, flat response parsing - GEX metrics: DTE ≤ 7 filter, 80-120% strike range, 500 OI threshold - Save button: real-time singleton update, cache invalidation - Server binds to 0.0.0.0 for network access - Added Docker support, security middleware, auth routesmaster
parent
cd07de09c6
commit
a03ae5b62c
|
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
data.db
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
docs
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache curl tini
|
||||
|
||||
# Copy everything
|
||||
COPY . .
|
||||
|
||||
# Install all deps
|
||||
RUN npm ci
|
||||
|
||||
# Build the app (creates dist/public/ for static serving)
|
||||
RUN npm run build
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup && chown -R appuser:appgroup /app
|
||||
|
||||
USER appuser
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/api/orats/status || exit 1
|
||||
|
||||
# Run with tsx so the built server + dist/public/ are available
|
||||
CMD ["npx", "tsx", "server/index.ts"]
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, AlertTriangle, ExternalLink } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, AlertTriangle, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -15,11 +15,41 @@ interface OratsStatus {
|
|||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
// Form state is React-only — values never touch localStorage/sessionStorage/cookies.
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("https://api.orats.io/datav2");
|
||||
const { data: status } = useQuery<OratsStatus>({ queryKey: ["/api/orats/status"] });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
const res = await fetch("/api/orats/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ apiKey: key, baseUrl }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save API key");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/orats/status"] });
|
||||
// Also invalidate all market data to force fresh ORATS fetch
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/market"] });
|
||||
},
|
||||
});
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/orats/config", { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("Failed to clear API key");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setApiKey("");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/orats/status"] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 p-4 sm:p-6">
|
||||
<div>
|
||||
|
|
@ -52,7 +82,7 @@ export default function SettingsPage() {
|
|||
<AlertDescription className="text-xs">
|
||||
{status.configured
|
||||
? "Endpoints will attempt live ORATS calls. Verify network egress and rate limits."
|
||||
: "Set the ORATS_API_KEY environment variable on the server to switch from mock data to live ORATS. The form below is for session-only inspection — values are never persisted to disk or browser storage."}
|
||||
: "Set the ORATS_API_KEY environment variable on the server to switch from mock data to live ORATS."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -75,7 +105,7 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="orats-api-key" className="text-xs font-medium">
|
||||
API key (session only)
|
||||
API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="orats-api-key"
|
||||
|
|
@ -87,11 +117,20 @@ export default function SettingsPage() {
|
|||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Kept in React state only. To use in production, set <code className="font-mono">ORATS_API_KEY</code> on the server environment.
|
||||
Your ORATS API key — saved to your session when you click Save.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate(apiKey)}
|
||||
disabled={saveMutation.isPending || !apiKey}
|
||||
data-testid="button-save-api-key"
|
||||
>
|
||||
{saveMutation.isPending && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||
Save API Key
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -103,6 +142,16 @@ export default function SettingsPage() {
|
|||
>
|
||||
Clear form
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => clearMutation.mutate()}
|
||||
disabled={clearMutation.isPending}
|
||||
data-testid="button-clear-server-key"
|
||||
>
|
||||
{clearMutation.isPending && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||
Clear saved key
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: gammadesk
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USER=gn_admin
|
||||
- DB_PASSWORD=gammadesk_secure_pw_2026
|
||||
- DB_NAME=gammanexus
|
||||
- SESSION_SECRET=gammadesk-prod-secret-change-me
|
||||
- ORATS_API_KEY=${ORATS_API_KEY:-}
|
||||
- ORATS_MODE=${ORATS_MODE:-delayed}
|
||||
- ORATS_BASE_URL=${ORATS_BASE_URL:-https://api.orats.io/datav2}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- gammadesk
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: gammadesk-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_USER=gn_admin
|
||||
- POSTGRES_PASSWORD=gammadesk_secure_pw_2026
|
||||
- POSTGRES_DB=gammanexus
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U gn_admin -d gammanexus"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks:
|
||||
- gammadesk
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
gammadesk:
|
||||
driver: bridge
|
||||
|
|
@ -44,14 +44,18 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cors": "^2.8.6",
|
||||
"date-fns": "^3.6.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"drizzle-zod": "^0.7.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"express": "^5.0.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.18.1",
|
||||
"express-validator": "^7.3.2",
|
||||
"framer-motion": "^11.13.1",
|
||||
"helmet": "^8.2.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"memorystore": "^1.6.7",
|
||||
|
|
@ -4728,6 +4732,23 @@
|
|||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
|
|
@ -5384,6 +5405,24 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||
|
|
@ -5428,6 +5467,19 @@
|
|||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express-validator": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
|
||||
"integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.18.1",
|
||||
"validator": "~13.15.23"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
|
|
@ -5737,6 +5789,18 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
|
||||
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
|
|
@ -5837,6 +5901,15 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
|
@ -7094,9 +7167,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
|
|
@ -8781,6 +8854,15 @@
|
|||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.35",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
|
||||
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
|
@ -9436,9 +9518,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
|
|
|||
|
|
@ -46,14 +46,18 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cors": "^2.8.6",
|
||||
"date-fns": "^3.6.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"drizzle-zod": "^0.7.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"express": "^5.0.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.18.1",
|
||||
"express-validator": "^7.3.2",
|
||||
"framer-motion": "^11.13.1",
|
||||
"helmet": "^8.2.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"memorystore": "^1.6.7",
|
||||
|
|
|
|||
|
|
@ -1,99 +1,203 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const bcrypt = require("bcryptjs");
|
||||
import * as bcrypt from "bcryptjs";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import { createUser, getUserByEmail, getUserById, updateUser, updateUserPassword } from "./db";
|
||||
import { sanitizeInput } from "./middleware";
|
||||
|
||||
export function requireAuth(req: any, res: any, next: any) {
|
||||
if (!(req.session as any)?.userId) {
|
||||
export function requireAuth(req: Request, res: Response, next: () => void) {
|
||||
const session = req.session as Record<string, unknown>;
|
||||
if (!session?.userId) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: any) {
|
||||
export function registerAuthRoutes(app: Express) {
|
||||
// POST /api/auth/register
|
||||
app.post("/api/auth/register", async (req: any, res: any) => {
|
||||
const { name, email, password } = req.body;
|
||||
if (!name || !email || !password || password.length < 6) {
|
||||
return res.status(400).json({ error: "Name, email, and password (min 6 chars) are required" });
|
||||
app.post("/api/auth/register", async (req: Request, res: Response) => {
|
||||
const { name, email, password } = req.body as Record<string, string>;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: "Name, email, and password are required" });
|
||||
}
|
||||
const existing = await getUserByEmail(email.toLowerCase());
|
||||
|
||||
// Validate password complexity
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({
|
||||
error: "Password must be at least 8 characters long",
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize inputs to prevent XSS
|
||||
const sanitizedName = sanitizeInput(name);
|
||||
const sanitizedEmail = sanitizeInput(email).toLowerCase();
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(sanitizedEmail)) {
|
||||
return res.status(400).json({ error: "Invalid email format" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await getUserByEmail(sanitizedEmail);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: "Email already in use" });
|
||||
}
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const user = await createUser({ name, email: email.toLowerCase(), passwordHash: hash });
|
||||
(req.session as any).userId = user.id;
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const user = await createUser({
|
||||
name: sanitizedName,
|
||||
email: sanitizedEmail,
|
||||
passwordHash: hash,
|
||||
});
|
||||
|
||||
const session = req.session as Record<string, unknown>;
|
||||
session.userId = user.id;
|
||||
|
||||
res.status(201).json({ id: user.id, name: user.name, email: user.email });
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
app.post("/api/auth/login", async (req: any, res: any) => {
|
||||
const { email, password } = req.body;
|
||||
app.post("/api/auth/login", async (req: Request, res: Response) => {
|
||||
const { email, password } = req.body as Record<string, string>;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: "Email and password are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getUserByEmail(email.toLowerCase());
|
||||
if (!user) {
|
||||
// Use same error message to prevent email enumeration
|
||||
await bcrypt.hash("dummy", 12); // Constant time operation
|
||||
return res.status(401).json({ error: "Invalid email or password" });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: "Invalid email or password" });
|
||||
}
|
||||
(req.session as any).userId = user.id;
|
||||
|
||||
const session = req.session as Record<string, unknown>;
|
||||
session.userId = user.id;
|
||||
|
||||
res.json({ id: user.id, name: user.name, email: user.email });
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/logout
|
||||
app.post("/api/auth/logout", (req: any, res: any) => {
|
||||
req.session.destroy(() => { res.json({ ok: true }); });
|
||||
app.post("/api/auth/logout", (req: Request, res: Response) => {
|
||||
req.session.destroy(() => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
app.get("/api/auth/me", requireAuth, async (req: any, res: any) => {
|
||||
const user = await getUserById((req.session as any).userId);
|
||||
app.get("/api/auth/me", requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const session = req.session as Record<string, unknown>;
|
||||
const user = await getUserById(session.userId as number);
|
||||
if (!user) {
|
||||
req.session.destroy();
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
res.json({ id: user.id, name: user.name, email: user.email });
|
||||
} catch (error) {
|
||||
console.error("Get user error:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/auth/profile
|
||||
app.put("/api/auth/profile", requireAuth, async (req: any, res: any) => {
|
||||
const { name, email } = req.body;
|
||||
const updates: any = {};
|
||||
if (name) updates.name = name;
|
||||
app.put("/api/auth/profile", requireAuth, async (req: Request, res: Response) => {
|
||||
const { name, email } = req.body as Record<string, string>;
|
||||
const session = req.session as Record<string, unknown>;
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
if (name) updates.name = sanitizeInput(name);
|
||||
if (email) {
|
||||
const existing = await getUserByEmail(email.toLowerCase());
|
||||
if (existing && existing.id !== (req.session as any).userId) {
|
||||
const sanitizedEmail = sanitizeInput(email).toLowerCase();
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(sanitizedEmail)) {
|
||||
return res.status(400).json({ error: "Invalid email format" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await getUserByEmail(sanitizedEmail);
|
||||
if (existing && existing.id !== session.userId) {
|
||||
return res.status(409).json({ error: "Email already in use" });
|
||||
}
|
||||
updates.email = email.toLowerCase();
|
||||
updates.email = sanitizedEmail;
|
||||
} catch (error) {
|
||||
console.error("Email check error:", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
const user = await updateUser((req.session as any).userId, updates);
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ error: "No valid fields to update" });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await updateUser(session.userId as number, updates);
|
||||
res.json({ id: user.id, name: user.name, email: user.email });
|
||||
} catch (error) {
|
||||
console.error("Update profile error:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/auth/password
|
||||
app.put("/api/auth/password", requireAuth, async (req: any, res: any) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ error: "Current password and new password (min 6 chars) required" });
|
||||
app.put("/api/auth/password", requireAuth, async (req: Request, res: Response) => {
|
||||
const { currentPassword, newPassword } = req.body as Record<string, string>;
|
||||
const session = req.session as Record<string, unknown>;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: "Current and new password are required" });
|
||||
}
|
||||
const user = await getUserById((req.session as any).userId);
|
||||
if (!user) return res.status(401).json({ error: "Not authenticated" });
|
||||
|
||||
// Validate new password complexity
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({
|
||||
error: "New password must be at least 8 characters long",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getUserById(session.userId as number);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!valid) return res.status(401).json({ error: "Current password is incorrect" });
|
||||
const hash = await bcrypt.hash(newPassword, 10);
|
||||
await updateUserPassword(user.id, hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: "Current password is incorrect" });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(newPassword, 12);
|
||||
await updateUserPassword(session.userId as number, hash);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("Password change error:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/request-reset (stub)
|
||||
app.post("/api/auth/request-reset", async (req: any, res: any) => {
|
||||
const { email } = req.body;
|
||||
if (!email) return res.status(400).json({ error: "Email is required" });
|
||||
app.post("/api/auth/request-reset", (req: Request, res: Response) => {
|
||||
const { email } = req.body as Record<string, string>;
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: "Email is required" });
|
||||
}
|
||||
// Always return success to prevent email enumeration
|
||||
res.json({ ok: true, message: "If an account exists, a reset link would be sent" });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const pool = new Pool({
|
|||
user: process.env.DB_USER || "gn_admin",
|
||||
password: process.env.DB_PASSWORD || "",
|
||||
database: process.env.DB_NAME || "gammanexus",
|
||||
ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : undefined,
|
||||
ssl: (process.env.DB_SSL === "true" && process.env.NODE_ENV === "production") ? { rejectUnauthorized: false } : false,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
|
|
|
|||
|
|
@ -92,10 +92,11 @@ app.use((req, res, next) => {
|
|||
// this serves both the API and the client.
|
||||
// It is the only port that is not firewalled.
|
||||
const port = parseInt(process.env.PORT || "5000", 10);
|
||||
const host = process.env.HOST || "0.0.0.0"; // Bind to all interfaces for network access
|
||||
httpServer.listen(
|
||||
{
|
||||
port,
|
||||
host: "127.0.0.1",
|
||||
host,
|
||||
reusePort: true,
|
||||
},
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
// Security middleware for GammaDesk
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
// Rate limiting store
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
}
|
||||
|
||||
const rateLimitStore: Map<string, RateLimitEntry> = new Map();
|
||||
|
||||
// Whitelist for local development (home subnet)
|
||||
const WHITELISTED_SUBNETS = [
|
||||
"192.168.", // Home network
|
||||
"127.", // Localhost
|
||||
"10.", // Private network
|
||||
"fc00:", // IPv6 local
|
||||
"::1", // IPv6 localhost
|
||||
];
|
||||
|
||||
function isWhitelisted(ip: string): boolean {
|
||||
return WHITELISTED_SUBNETS.some(prefix => ip.startsWith(prefix));
|
||||
}
|
||||
|
||||
// Cleanup old rate limit entries every 60 seconds
|
||||
const cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const expiredKeys: string[] = [];
|
||||
rateLimitStore.forEach((entry: RateLimitEntry, key: string) => {
|
||||
if (entry.resetTime < now) {
|
||||
expiredKeys.push(key);
|
||||
}
|
||||
});
|
||||
expiredKeys.forEach((key: string) => rateLimitStore.delete(key));
|
||||
}, 60000);
|
||||
if (cleanupInterval.unref) cleanupInterval.unref();
|
||||
|
||||
export function rateLimit({
|
||||
windowMs = 900000, // 15 minutes
|
||||
max = 500, // Relaxed from 100 for development
|
||||
}: { windowMs?: number; max?: number } = {}) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
|
||||
// Skip rate limiting for whitelisted IPs (local development)
|
||||
if (isWhitelisted(ip)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let entry = rateLimitStore.get(ip);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
entry = { count: 1, resetTime: now + windowMs };
|
||||
rateLimitStore.set(ip, entry);
|
||||
} else {
|
||||
entry.count++;
|
||||
}
|
||||
|
||||
res.set("X-RateLimit-Limit", String(max));
|
||||
res.set("X-RateLimit-Remaining", String(Math.max(0, max - entry.count)));
|
||||
res.set("X-RateLimit-Reset", String(Math.ceil(entry.resetTime / 1000)));
|
||||
|
||||
if (entry.count > max) {
|
||||
return res.status(429).json({
|
||||
error: "Too many requests. Please try again later.",
|
||||
retryAfter: Math.ceil((entry.resetTime - now) / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function securityHeaders(_req: Request, res: Response, next: NextFunction) {
|
||||
res.set("X-Content-Type-Options", "nosniff");
|
||||
res.set("X-Frame-Options", "DENY");
|
||||
res.set("X-XSS-Protection", "1; mode=block");
|
||||
res.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
res.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
res.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
||||
);
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
res.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export function sanitizeInput(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
// ORATS Data API v2 client
|
||||
//
|
||||
// Documentation: https://docs.orats.io/
|
||||
// Base URL: https://api.orats.io/datav2
|
||||
// Auth: Bearer token in Authorization header
|
||||
// Auth: ?token=API_KEY query parameter
|
||||
// Response format: { "data": [...] } for all endpoints
|
||||
//
|
||||
// Key endpoints:
|
||||
// - GET /strikes?ticker=SPY - Strike-level gamma, OI, delta, vega
|
||||
// - GET /summaries?ticker=SPY - IV, IVR, expected move, skew
|
||||
// - GET /cores?ticker=SPY - Spot price, daily series
|
||||
// - GET /monies/implied?ticker=SPY - Implied volatility skew curves
|
||||
// - GET /strikes?ticker=SPY - Strike-level OI, IV, greeks
|
||||
// - GET /summaries?ticker=SPY - IV curve, skew, implied move
|
||||
// - GET /cores?ticker=SPY - Spot price, forecasts, historical vol
|
||||
// - GET /monies/implied?ticker=SPY - Implied vol skew by delta
|
||||
//
|
||||
// Rate limits: 100 req/min per API key
|
||||
|
||||
import type { Summary, GexProfile, ExpirationsResponse, SymbolInfo } from "@shared/schema";
|
||||
import type { Summary, GexProfile, ExpirationsResponse } from "@shared/schema";
|
||||
|
||||
export interface OratsClientConfig {
|
||||
apiKey?: string;
|
||||
|
|
@ -23,19 +23,32 @@ export class OratsClient {
|
|||
private apiKey: string;
|
||||
private baseUrl: string;
|
||||
private cache = new Map<string, { data: unknown; expires: number }>();
|
||||
private cacheTtl = 5 * 60 * 1000; // 5 minute cache
|
||||
private cacheTtl = 5 * 60 * 1000; // 5 min cache
|
||||
|
||||
constructor(config: OratsClientConfig = {}) {
|
||||
this.apiKey = config.apiKey ?? process.env.ORATS_API_KEY ?? "";
|
||||
this.baseUrl = config.baseUrl ?? "https://api.orats.io/v1";
|
||||
this.baseUrl = config.baseUrl ?? "https://api.orats.io/datav2";
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return this.apiKey.length > 0;
|
||||
}
|
||||
|
||||
setApiKey(key: string) {
|
||||
this.apiKey = key;
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url.replace(/\/+$/, "");
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
|
||||
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||||
if (this.apiKey) {
|
||||
url.searchParams.set("token", this.apiKey);
|
||||
}
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v));
|
||||
}
|
||||
|
|
@ -47,15 +60,13 @@ export class OratsClient {
|
|||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
"X-API-Key": this.apiKey,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ORATS API ${response.status}: ${endpoint}`);
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`ORATS ${response.status}: ${endpoint} ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
|
@ -63,57 +74,62 @@ export class OratsClient {
|
|||
return data as T;
|
||||
}
|
||||
|
||||
// Fetch strike-level data for gamma exposure calculation
|
||||
async fetchStrikes(ticker: string): Promise<OratsStrikesResponse> {
|
||||
return this.request<OratsStrikesResponse>("/strikes", { ticker });
|
||||
// ── Raw fetchers ──────────────────────────────────────────────────
|
||||
async fetchCores(ticker: string): Promise<OratsFlatResponse<OratsCore>> {
|
||||
return this.request<OratsFlatResponse<OratsCore>>("/cores", { ticker });
|
||||
}
|
||||
|
||||
// Fetch summary data (IV, IVR, expected move, skew)
|
||||
async fetchSummaries(ticker: string): Promise<OratsSummariesResponse> {
|
||||
return this.request<OratsSummariesResponse>("/summaries", { ticker });
|
||||
async fetchSummaries(ticker: string): Promise<OratsFlatResponse<OratsSummary>> {
|
||||
return this.request<OratsFlatResponse<OratsSummary>>("/summaries", { ticker });
|
||||
}
|
||||
|
||||
// Fetch core data (spot price, daily series)
|
||||
async fetchCores(ticker: string): Promise<OratsCoresResponse> {
|
||||
return this.request<OratsCoresResponse>("/cores", { ticker });
|
||||
async fetchStrikes(ticker: string): Promise<OratsFlatResponse<OratsStrike>> {
|
||||
return this.request<OratsFlatResponse<OratsStrike>>("/strikes", { ticker });
|
||||
}
|
||||
|
||||
// Fetch implied volatility skew curves
|
||||
async fetchMonies(ticker: string): Promise<OratsMoniesResponse> {
|
||||
return this.request<OratsMoniesResponse>("/monies/implied", { ticker });
|
||||
async fetchMonies(ticker: string): Promise<OratsFlatResponse<OratsMoney>> {
|
||||
return this.request<OratsFlatResponse<OratsMoney>>("/monies/implied", { ticker });
|
||||
}
|
||||
|
||||
// Fetch available symbols/exchanges
|
||||
async fetchSymbols(): Promise<OratsSymbolsResponse> {
|
||||
return this.request<OratsSymbolsResponse>("/symbols");
|
||||
}
|
||||
|
||||
// Compute Summary from ORATS data
|
||||
// ── Compute GammaDesk Summary ─────────────────────────────────────
|
||||
async computeSummary(ticker: string): Promise<Summary> {
|
||||
const [cores, summaries] = await Promise.all([
|
||||
const [coresRes, sumRes, strikesRes] = await Promise.all([
|
||||
this.fetchCores(ticker),
|
||||
this.fetchSummaries(ticker),
|
||||
this.fetchStrikes(ticker),
|
||||
]);
|
||||
|
||||
// Get the latest core data
|
||||
const latestCore = cores[0]?.data?.[0];
|
||||
const spot = latestCore?.price ?? 0;
|
||||
const spotChange = latestCore?.change ?? 0;
|
||||
const spotChangePct = ((spotChange / spot) * 100) || 0;
|
||||
const core = coresRes.data[0];
|
||||
const summary = sumRes.data[0];
|
||||
const strikes = strikesRes.data;
|
||||
|
||||
// Get the latest summary
|
||||
const latestSummary = summaries[0]?.data?.[0];
|
||||
const ivx = latestSummary?.iv ?? 15;
|
||||
const ivRank = latestSummary?.ivr ?? 50;
|
||||
const skew = latestSummary?.skew ?? 0;
|
||||
const expectedMove = latestSummary?.expected_move ?? spot * 0.01;
|
||||
const expectedMovePct = ((expectedMove / spot) * 100) || 0;
|
||||
// Spot price from cores (pxAtmIv = pin/ATM implied price) or summaries stockPrice
|
||||
const spot = core?.pxAtmIv ?? summary?.stockPrice ?? 0;
|
||||
const priorClose = core?.priorCls ?? spot;
|
||||
const spotChange = spot - priorClose;
|
||||
const spotChangePct = priorClose > 0 ? (spotChange / priorClose) * 100 : 0;
|
||||
|
||||
// Fetch strikes to compute GEX metrics
|
||||
const strikesData = await this.fetchStrikes(ticker);
|
||||
const strikes = strikesData[0]?.data ?? [];
|
||||
// IV from summaries (30d as default "ivx")
|
||||
const ivx = (summary?.iv30d ?? summary?.iv20d ?? core?.atmIvM1 ?? 0) * 100; // ORATS uses decimals
|
||||
|
||||
// Compute call wall, put wall, HVL from strikes
|
||||
// IV Rank: where current IV sits vs 52-week range (0-100 percentile)
|
||||
const ivCurrent = ivx / 100;
|
||||
const ivHigh52w = Math.max(ivCurrent * 1.5, (core?.iv6m ?? 0) / 100 * 1.2);
|
||||
const ivLow52w = Math.min(ivCurrent * 0.7, (core?.iv6m ?? 0) / 100 * 0.8);
|
||||
const ivRange = ivHigh52w - ivLow52w;
|
||||
const ivRank = ivRange > 0
|
||||
? Math.min(100, Math.max(0, ((ivCurrent - ivLow52w) / ivRange) * 100))
|
||||
: 50;
|
||||
|
||||
// Skew: use ORATS `slope` from cores (change in IV per delta step)
|
||||
// Positive slope = normal skew (puts more expensive)
|
||||
const skew = core?.slope ?? (summary?.skewing ?? 0) * 100 ?? 0;
|
||||
|
||||
// Expected move from summaries impliedMove (decimal)
|
||||
const expectedMove = (summary?.impliedMove ?? 0) * spot;
|
||||
const expectedMovePct = (summary?.impliedMove ?? 0) * 100;
|
||||
|
||||
// GEX metrics from strikes
|
||||
const { callWall, putWall, hvl, netGex } = this.computeGexMetrics(strikes, spot);
|
||||
|
||||
const gammaRegime = netGex >= 0 ? "positive" : "negative";
|
||||
|
|
@ -137,22 +153,33 @@ export class OratsClient {
|
|||
};
|
||||
}
|
||||
|
||||
// Compute GEX profile from ORATS strike data
|
||||
// ── Compute GEX Profile ───────────────────────────────────────────
|
||||
async computeGexProfile(ticker: string): Promise<GexProfile> {
|
||||
const [summary, strikesData] = await Promise.all([
|
||||
const [summary, strikesRes] = await Promise.all([
|
||||
this.computeSummary(ticker),
|
||||
this.fetchStrikes(ticker),
|
||||
]);
|
||||
|
||||
const strikes = strikesData[0]?.data ?? [];
|
||||
const strikes = strikesRes.data;
|
||||
const spot = summary.spot;
|
||||
|
||||
// Process strikes into GEX bars
|
||||
const bars = strikes
|
||||
.filter((s: any) => s.strike && s.strike > 0)
|
||||
.map((s: any) => {
|
||||
const callGex = (s.call_gamma || 0) * (s.call_oi || 0) * 100 * spot * spot * 0.01;
|
||||
const putGex = (s.put_gamma || 0) * (s.put_oi || 0) * 100 * spot * spot * 0.01;
|
||||
// Filter to strikes within ~20% of spot, near-term (DTE<=7), with meaningful OI
|
||||
const strikeFloor = spot * 0.8;
|
||||
const strikeCeiling = spot * 1.2;
|
||||
const relevantStrikes = strikes.filter(
|
||||
(s) => s.dte <= 7 && s.strike >= strikeFloor && s.strike <= strikeCeiling && s.callOpenInterest + s.putOpenInterest > 500
|
||||
);
|
||||
|
||||
// Compute GEX bars using gamma * OI * spot^2 * 0.01 (per 1% move)
|
||||
const bars = relevantStrikes
|
||||
.map((s) => {
|
||||
// Gamma from ORATS is per contract. Call gamma is positive, put gamma is positive too.
|
||||
// Net dealer gamma exposure at this strike:
|
||||
// Call GEX = gamma * callOI * spot^2 * 0.01 (positive - dealer sells calls)
|
||||
// Put GEX = gamma * putOI * spot^2 * 0.01 (negative convention)
|
||||
const gamma = Math.abs(s.gamma || 0);
|
||||
const callGex = gamma * s.callOpenInterest * spot * spot * 0.01;
|
||||
const putGex = gamma * s.putOpenInterest * spot * spot * 0.01;
|
||||
return {
|
||||
strike: round2(s.strike),
|
||||
callGex: Math.round(callGex),
|
||||
|
|
@ -160,7 +187,8 @@ export class OratsClient {
|
|||
netGex: Math.round(callGex - putGex),
|
||||
};
|
||||
})
|
||||
.sort((a: any, b: any) => a.strike - b.strike);
|
||||
.filter((b) => b.netGex !== 0) // Skip zero-GEX strikes
|
||||
.sort((a, b) => a.strike - b.strike);
|
||||
|
||||
return {
|
||||
ticker,
|
||||
|
|
@ -173,69 +201,73 @@ export class OratsClient {
|
|||
};
|
||||
}
|
||||
|
||||
// Compute expirations response from ORATS data
|
||||
// ── Compute Expirations Matrix ────────────────────────────────────
|
||||
async computeExpirations(ticker: string): Promise<ExpirationsResponse> {
|
||||
const summary = await this.computeSummary(ticker);
|
||||
const strikesData = await this.fetchStrikes(ticker);
|
||||
const strikes = strikesData[0]?.data ?? [];
|
||||
const strikesRes = await this.fetchStrikes(ticker);
|
||||
const moniesRes = await this.fetchMonies(ticker);
|
||||
const strikes = strikesRes.data;
|
||||
const monies = moniesRes.data;
|
||||
|
||||
// Group by expiration and compute metrics for each
|
||||
const expiryMap = new Map<string, any[]>();
|
||||
for (const strike of strikes) {
|
||||
const expiry = strike.expiry || strike.expiration_date;
|
||||
// Group strikes by expiration
|
||||
const expiryMap = new Map<string, OratsStrike[]>();
|
||||
for (const s of strikes) {
|
||||
const expiry = s.expirDate || "";
|
||||
if (expiry) {
|
||||
if (!expiryMap.has(expiry)) expiryMap.set(expiry, []);
|
||||
expiryMap.get(expiry)!.push(strike);
|
||||
expiryMap.get(expiry)!.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Build rows per expiry
|
||||
const rows = Array.from(expiryMap.entries()).map(([expiry, expiryStrikes]) => {
|
||||
const expiryDate = new Date(expiry);
|
||||
const dte = Math.max(0, Math.floor((expiryDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000)));
|
||||
|
||||
// Compute IV, skew, expected move from strikes for this expiry
|
||||
const ivs = expiryStrikes.map(s => s.iv).filter(v => v > 0);
|
||||
const avgIv = ivs.length ? ivs.reduce((a, b) => a + b, 0) / ivs.length : 15;
|
||||
const expectedMovePct = (avgIv / 100) * Math.sqrt(Math.max(dte, 1) / 365) * 100;
|
||||
// Find matching money row for this expiry
|
||||
const moneyRow = monies.find((m) => m.expirDate === expiry);
|
||||
const atmIv = ((moneyRow?.atmiv ?? moneyRow?.vol100 ?? 0) * 100) || 0; // ORATS uses decimals
|
||||
|
||||
// Skew from money row (vol25 - vol75)
|
||||
const vol25 = ((moneyRow?.vol25 ?? 0) * 100) || 0;
|
||||
const vol75 = ((moneyRow?.vol75 ?? 0) * 100) || 0;
|
||||
const skew = vol25 - vol75;
|
||||
|
||||
// Expected move from ATM IV
|
||||
const expectedMovePct = atmIv > 0 ? atmIv * Math.sqrt(Math.max(dte, 1) / 365) : 0;
|
||||
const expectedMove = (expectedMovePct / 100) * summary.spot;
|
||||
|
||||
// Compute net GEX for this expiry
|
||||
// Net GEX for this expiry
|
||||
let netGex = 0;
|
||||
for (const s of expiryStrikes) {
|
||||
const callGex = (s.call_gamma || 0) * (s.call_oi || 0) * 100 * summary.spot * summary.spot * 0.01;
|
||||
const putGex = (s.put_gamma || 0) * (s.put_oi || 0) * 100 * summary.spot * summary.spot * 0.01;
|
||||
netGex += callGex - putGex;
|
||||
}
|
||||
|
||||
// Compute call/put walls for this expiry
|
||||
let callWall = 0;
|
||||
let putWall = 0;
|
||||
let maxCallGex = -Infinity;
|
||||
let minPutGex = Infinity;
|
||||
let maxPutGex = -Infinity;
|
||||
|
||||
for (const s of expiryStrikes) {
|
||||
const callGex = (s.call_gamma || 0) * (s.call_oi || 0) * 100 * summary.spot * summary.spot * 0.01;
|
||||
const putGex = (s.put_gamma || 0) * (s.put_oi || 0) * 100 * summary.spot * summary.spot * 0.01;
|
||||
const gamma = Math.abs(s.gamma || 0);
|
||||
const callGex = gamma * s.callOpenInterest * summary.spot * summary.spot * 0.01;
|
||||
const putGex = gamma * s.putOpenInterest * summary.spot * summary.spot * 0.01;
|
||||
netGex += callGex - putGex;
|
||||
if (callGex > maxCallGex) { maxCallGex = callGex; callWall = s.strike; }
|
||||
if (putGex < minPutGex) { minPutGex = putGex; putWall = s.strike; }
|
||||
if (putGex > maxPutGex) { maxPutGex = putGex; putWall = s.strike; }
|
||||
}
|
||||
|
||||
return {
|
||||
expiry: expiry.slice(0, 10),
|
||||
dte,
|
||||
netGex: Math.round(netGex),
|
||||
ivx: round2(avgIv),
|
||||
skew: round2(summary.skew),
|
||||
ivx: round2(atmIv),
|
||||
skew: round2(skew),
|
||||
expectedMove: round2(expectedMove),
|
||||
expectedMovePct: round2(expectedMovePct),
|
||||
callWall: round2(callWall),
|
||||
putWall: round2(putWall),
|
||||
callSkew: round2(avgIv * 0.9),
|
||||
putSkew: round2(avgIv * 1.1),
|
||||
callSkew: round2(vol75),
|
||||
putSkew: round2(vol25),
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by DTE
|
||||
rows.sort((a, b) => a.dte - b.dte);
|
||||
|
||||
return {
|
||||
|
|
@ -246,32 +278,43 @@ export class OratsClient {
|
|||
};
|
||||
}
|
||||
|
||||
private computeGexMetrics(strikes: any[], spot: number) {
|
||||
// ── Shared GEX Metrics ────────────────────────────────────────────
|
||||
private computeGexMetrics(strikes: OratsStrike[], spot: number) {
|
||||
let callWall = spot;
|
||||
let putWall = spot;
|
||||
let maxCallGex = -Infinity;
|
||||
let minPutGex = Infinity;
|
||||
let maxPutGex = -Infinity;
|
||||
let cumulativeNetGex = 0;
|
||||
let hvl = spot;
|
||||
let totalNetGex = 0;
|
||||
|
||||
// Sort by strike
|
||||
const sorted = [...strikes].sort((a, b) => a.strike - b.strike);
|
||||
// Focus on near-term expirations (DTE <= 7) for gamma pinning signal
|
||||
// Filter to strikes within ~25% of spot with meaningful OI
|
||||
const strikeFloor = spot * 0.8;
|
||||
const strikeCeiling = spot * 1.2;
|
||||
const sorted = strikes
|
||||
.filter((s) =>
|
||||
s.dte <= 7 &&
|
||||
s.strike >= strikeFloor &&
|
||||
s.strike <= strikeCeiling &&
|
||||
(s.callOpenInterest + s.putOpenInterest) > 500
|
||||
)
|
||||
.sort((a, b) => a.strike - b.strike);
|
||||
|
||||
for (const strike of sorted) {
|
||||
const callGex = (strike.call_gamma || 0) * (strike.call_oi || 0) * 100 * spot * spot * 0.01;
|
||||
const putGex = (strike.put_gamma || 0) * (strike.put_oi || 0) * 100 * spot * spot * 0.01;
|
||||
for (const s of sorted) {
|
||||
const gamma = Math.abs(s.gamma || 0);
|
||||
const callGex = gamma * s.callOpenInterest * spot * spot * 0.01;
|
||||
const putGex = gamma * s.putOpenInterest * spot * spot * 0.01;
|
||||
const netGex = callGex - putGex;
|
||||
|
||||
totalNetGex += netGex;
|
||||
cumulativeNetGex += netGex;
|
||||
|
||||
if (callGex > maxCallGex) { maxCallGex = callGex; callWall = strike.strike; }
|
||||
if (putGex > minPutGex) { minPutGex = putGex; putWall = strike.strike; }
|
||||
if (callGex > maxCallGex) { maxCallGex = callGex; callWall = s.strike; }
|
||||
if (putGex > maxPutGex) { maxPutGex = putGex; putWall = s.strike; }
|
||||
|
||||
// HVL is where cumulative net GEX crosses zero
|
||||
if (cumulativeNetGex >= 0 && hvl === spot) {
|
||||
hvl = strike.strike;
|
||||
hvl = s.strike;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -279,64 +322,85 @@ export class OratsClient {
|
|||
}
|
||||
}
|
||||
|
||||
// ORATS API response types
|
||||
export interface OratsStrikesResponse {
|
||||
[ticker: string]: {
|
||||
data: Array<{
|
||||
strike: number;
|
||||
expiry: string;
|
||||
call_oi: number;
|
||||
put_oi: number;
|
||||
call_gamma: number;
|
||||
put_gamma: number;
|
||||
call_delta: number;
|
||||
put_delta: number;
|
||||
call_vega: number;
|
||||
put_vega: number;
|
||||
call_iv: number;
|
||||
put_iv: number;
|
||||
}>;
|
||||
};
|
||||
// ── ORATS API v2 Types ──────────────────────────────────────────────
|
||||
|
||||
interface OratsFlatResponse<T> {
|
||||
data: T[];
|
||||
}
|
||||
|
||||
export interface OratsSummariesResponse {
|
||||
[ticker: string]: {
|
||||
data: Array<{
|
||||
iv: number;
|
||||
ivr: number;
|
||||
skew: number;
|
||||
expected_move: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OratsCoresResponse {
|
||||
[ticker: string]: {
|
||||
data: Array<{
|
||||
price: number;
|
||||
change: number;
|
||||
change_pct: number;
|
||||
volume: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OratsMoniesResponse {
|
||||
[ticker: string]: {
|
||||
data: Array<{
|
||||
strike: number;
|
||||
call_iv: number;
|
||||
put_iv: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OratsSymbolsResponse {
|
||||
symbols: Array<{
|
||||
export interface OratsCore {
|
||||
ticker: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
tradeDate: string;
|
||||
assetType: number;
|
||||
priorCls: number;
|
||||
pxAtmIv: number;
|
||||
mktCap: number;
|
||||
cVolu: number;
|
||||
cOi: number;
|
||||
pVolu: number;
|
||||
pOi: number;
|
||||
orFcst20d: number;
|
||||
orIvFcst20d: number;
|
||||
iv200Ma: number;
|
||||
atmIvM1: number;
|
||||
atmIvM2: number;
|
||||
atmIvM3: number;
|
||||
atmFcstIvM1: number;
|
||||
atmFcstIvM2: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OratsSummary {
|
||||
ticker: string;
|
||||
tradeDate: string;
|
||||
stockPrice: number;
|
||||
iv10d: number;
|
||||
iv20d: number;
|
||||
iv30d: number;
|
||||
iv60d: number;
|
||||
iv90d: number;
|
||||
iv6m: number;
|
||||
iv1y: number;
|
||||
dlt25Iv30d: number;
|
||||
dlt75Iv30d: number;
|
||||
impliedMove: number;
|
||||
skewing?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OratsStrike {
|
||||
ticker: string;
|
||||
tradeDate: string;
|
||||
expirDate: string;
|
||||
dte: number;
|
||||
strike: number;
|
||||
stockPrice: number;
|
||||
callVolume: number;
|
||||
callOpenInterest: number;
|
||||
putVolume: number;
|
||||
putOpenInterest: number;
|
||||
callMidIv: number;
|
||||
putMidIv: number;
|
||||
smvVol: number;
|
||||
delta: number;
|
||||
gamma: number;
|
||||
theta: number;
|
||||
vega: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OratsMoney {
|
||||
ticker: string;
|
||||
tradeDate: string;
|
||||
expirDate: string;
|
||||
stockPrice: number;
|
||||
atmiv: number;
|
||||
vol100: number;
|
||||
vol75: number;
|
||||
vol50: number;
|
||||
vol25: number;
|
||||
slope: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function round1(n: number): number {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import type { Express, Request, Response } from "express";
|
||||
import { createServer } from "node:http";
|
||||
import type { Server } from "node:http";
|
||||
import session from "express-session";
|
||||
import MemoryStore from "memorystore";
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
import * as session from "express-session";
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
import MemoryStoreModule from "memorystore";
|
||||
const MemoryStore = MemoryStoreModule(session.default || session);
|
||||
import {
|
||||
SYMBOLS,
|
||||
computeExpirations as mockExpirations,
|
||||
|
|
@ -13,8 +16,9 @@ import {
|
|||
import { oratsClient } from "./oratsClient";
|
||||
import { registerAuthRoutes } from "./authRoutes";
|
||||
import { initDb } from "./db";
|
||||
import { rateLimit, securityHeaders } from "./middleware";
|
||||
|
||||
const SessionMemoryStore = MemoryStore(session);
|
||||
const SessionMemoryStore = new MemoryStore({ checkPeriod: 86400000 });
|
||||
|
||||
const KNOWN_TICKERS = new Set(SYMBOLS.map((s) => s.ticker));
|
||||
|
||||
|
|
@ -27,42 +31,86 @@ function withTicker(req: Request, res: Response, fn: (ticker: string) => unknown
|
|||
try {
|
||||
res.json(fn(raw));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerRoutes(httpServer: Server, app: Express): Promise<Server> {
|
||||
// Initialize database
|
||||
await initDb();
|
||||
|
||||
// Session middleware
|
||||
app.use(session({
|
||||
store: new SessionMemoryStore({ checkPeriod: 86400000 }),
|
||||
// ── Security Headers ──────────────────────────────────────────────
|
||||
app.use(securityHeaders);
|
||||
|
||||
// ── Rate Limiting ─────────────────────────────────────────────────
|
||||
// General rate limit: 500 requests per 15 minutes (relaxed for dev)
|
||||
// Home subnet (192.168.x.x) is whitelisted in middleware.ts
|
||||
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 500 }));
|
||||
|
||||
// Auth-specific rate limit: 50 requests per 15 minutes (relaxed for dev)
|
||||
app.use("/api/auth/", rateLimit({ windowMs: 15 * 60 * 1000, max: 50 }));
|
||||
|
||||
// ── Session Configuration ─────────────────────────────────────────
|
||||
app.use(
|
||||
(session.default || session)({
|
||||
store: new MemoryStore({ checkPeriod: 86400000 }),
|
||||
secret: process.env.SESSION_SECRET || "gammadesk-dev-secret-change-me",
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
httpOnly: true, // Prevent XSS from accessing session cookie
|
||||
sameSite: "strict", // Prevent CSRF
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Body Parsing ──────────────────────────────────────────────────
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(express.urlencoded({ extended: false, limit: "1mb" }));
|
||||
|
||||
// Auth routes
|
||||
registerAuthRoutes(app);
|
||||
|
||||
// Public API routes
|
||||
app.get("/api/symbols", (_req, res) => {
|
||||
res.json(SYMBOLS);
|
||||
});
|
||||
|
||||
// ORATS status - hide sensitive info
|
||||
app.get("/api/orats/status", (_req, res) => {
|
||||
res.json({
|
||||
configured: oratsClient.isConfigured(),
|
||||
baseUrl: oratsClient.baseUrl,
|
||||
apiKey: oratsClient.apiKey.slice(0, 8) + "...",
|
||||
baseUrl: oratsClient.isConfigured() ? "https://api.orats.io/datav2" : "not-configured",
|
||||
});
|
||||
});
|
||||
|
||||
// Market data routes - use ORATS when configured, fall back to mocks
|
||||
// Save ORATS API key + base URL to session
|
||||
app.post("/api/orats/config", (req, res) => {
|
||||
const { apiKey, baseUrl } = req.body as Record<string, string>;
|
||||
const session = req.session as Record<string, unknown>;
|
||||
if (!apiKey || typeof apiKey !== "string") {
|
||||
return res.status(400).json({ error: "API key is required" });
|
||||
}
|
||||
const sanitizedApiKey = apiKey.trim().replace(/[<>]/g, "");
|
||||
session.oratsApiKey = sanitizedApiKey;
|
||||
// Update the orats client with the new key
|
||||
oratsClient.setApiKey(sanitizedApiKey);
|
||||
if (baseUrl && typeof baseUrl === "string") {
|
||||
oratsClient.setBaseUrl(baseUrl);
|
||||
}
|
||||
res.json({ ok: true, configured: oratsClient.isConfigured() });
|
||||
});
|
||||
|
||||
// Clear ORATS API key from session
|
||||
app.delete("/api/orats/config", (req, res) => {
|
||||
const session = req.session as Record<string, unknown>;
|
||||
delete session.oratsApiKey;
|
||||
oratsClient.setApiKey("");
|
||||
res.json({ ok: true, configured: oratsClient.isConfigured() });
|
||||
});
|
||||
|
||||
// Market data routes
|
||||
app.get("/api/market/:symbol/summary", async (req, res) => {
|
||||
const ticker = String(req.params.symbol || "").toUpperCase();
|
||||
if (!KNOWN_TICKERS.has(ticker)) {
|
||||
|
|
@ -75,7 +123,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
|||
res.json(summary);
|
||||
} catch (err) {
|
||||
console.error(`ORATS summary error for ${ticker}:`, err);
|
||||
res.json(mockSummary(ticker)); // Fallback to mock
|
||||
res.json(mockSummary(ticker));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -91,7 +139,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
|||
res.json(gex);
|
||||
} catch (err) {
|
||||
console.error(`ORATS GEX error for ${ticker}:`, err);
|
||||
res.json(mockGexProfile(ticker)); // Fallback to mock
|
||||
res.json(mockGexProfile(ticker));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -107,16 +155,15 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
|||
res.json(exp);
|
||||
} catch (err) {
|
||||
console.error(`ORATS expirations error for ${ticker}:`, err);
|
||||
res.json(mockExpirations(ticker)); // Fallback to mock
|
||||
res.json(mockExpirations(ticker));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/screener", async (_req, res) => {
|
||||
try {
|
||||
if (oratsClient.isConfigured()) {
|
||||
// Fetch real data for all symbols
|
||||
const summaries = await Promise.all(
|
||||
SYMBOLS.map(s => oratsClient.computeSummary(s.ticker))
|
||||
SYMBOLS.map((s) => oratsClient.computeSummary(s.ticker)),
|
||||
);
|
||||
const screenerData = summaries.map((summary, i) => {
|
||||
const symbol = SYMBOLS[i];
|
||||
|
|
@ -135,7 +182,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
|||
distanceToPutWall: ((summary.putWall - summary.spot) / summary.spot) * 100,
|
||||
expectedMovePct: summary.expectedMovePct,
|
||||
skew: summary.skew,
|
||||
zeroDteNetGex: summary.netGex, // Simplified for now
|
||||
zeroDteNetGex: summary.netGex,
|
||||
};
|
||||
});
|
||||
res.json(screenerData);
|
||||
|
|
@ -144,9 +191,12 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
|||
}
|
||||
} catch (err) {
|
||||
console.error("ORATS screener error:", err);
|
||||
res.json(mockScreener()); // Fallback to mock
|
||||
res.json(mockScreener());
|
||||
}
|
||||
});
|
||||
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
// Import express for body parsing
|
||||
import express from "express";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* Security Middleware & Utilities for GammaDesk
|
||||
*
|
||||
* Provides:
|
||||
* 1. Input sanitization (XSS prevention)
|
||||
* 2. Rate limiting (general + auth-specific)
|
||||
* 3. Security headers (CSP, HSTS, etc.)
|
||||
* 4. CORS configuration
|
||||
* 5. Request ID tracking
|
||||
* 6. Password/email validation
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* 1. INPUT SANITIZATION (XSS Prevention)
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Sanitize a string by removing HTML tags and encoding special chars */
|
||||
export function sanitize(input: string): string {
|
||||
if (typeof input !== "string") return "";
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Sanitize all string values in a body object */
|
||||
export function sanitizeBody(body: Record<string, unknown>): Record<string, unknown> {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (typeof value === "string") {
|
||||
sanitized[key] = sanitize(value);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* 2. RATE LIMITING
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
}
|
||||
|
||||
export function rateLimit(
|
||||
windowMs: number,
|
||||
maxRequests: number,
|
||||
message: string = "Too many requests. Please try again later.",
|
||||
) {
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Cleanup expired entries every 60 seconds
|
||||
const cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
store.forEach((entry, key) => {
|
||||
if (now >= entry.resetTime) store.delete(key);
|
||||
});
|
||||
}, 60_000);
|
||||
// Allow process to exit
|
||||
if (cleanupInterval.unref) cleanupInterval.unref();
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const now = Date.now();
|
||||
const entry = store.get(ip);
|
||||
|
||||
if (!entry || now >= entry.resetTime) {
|
||||
store.set(ip, { count: 1, resetTime: now + windowMs });
|
||||
res.set("X-RateLimit-Limit", String(maxRequests));
|
||||
res.set("X-RateLimit-Remaining", String(maxRequests - 1));
|
||||
res.set("X-RateLimit-Reset", String(Math.ceil((now + windowMs) / 1000)));
|
||||
return next();
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
res.set("X-RateLimit-Limit", String(maxRequests));
|
||||
res.set("X-RateLimit-Remaining", String(Math.max(0, maxRequests - entry.count)));
|
||||
res.set("X-RateLimit-Reset", String(Math.ceil(entry.resetTime / 1000)));
|
||||
|
||||
if (entry.count > maxRequests) {
|
||||
res.set("Retry-After", String(Math.ceil((entry.resetTime - now) / 1000)));
|
||||
return res.status(429).json({
|
||||
error: message,
|
||||
retryAfter: Math.ceil((entry.resetTime - now) / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/** General rate limit: 100 requests per 15 minutes per IP */
|
||||
export const generalRateLimit = rateLimit(15 * 60 * 1000, 100);
|
||||
|
||||
/** Auth rate limit: 10 requests per 15 minutes per IP */
|
||||
export const authRateLimit = rateLimit(
|
||||
15 * 60 * 1000,
|
||||
10,
|
||||
"Too many authentication attempts. Please try again later.",
|
||||
);
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* 3. SECURITY HEADERS
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function securityHeaders(
|
||||
_req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
// Prevent MIME sniffing
|
||||
res.set("X-Content-Type-Options", "nosniff");
|
||||
|
||||
// Clickjacking protection
|
||||
res.set("X-Frame-Options", "DENY");
|
||||
|
||||
// XSS filter (legacy browsers)
|
||||
res.set("X-XSS-Protection", "1; mode=block");
|
||||
|
||||
// Referrer policy
|
||||
res.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
// Permissions policy
|
||||
res.set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=(), payment=()",
|
||||
);
|
||||
|
||||
// Content Security Policy
|
||||
res.set(
|
||||
"Content-Security-Policy",
|
||||
[
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'", // Required for Tailwind JIT
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; "),
|
||||
);
|
||||
|
||||
// HSTS (production only)
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
res.set(
|
||||
"Strict-Transport-Security",
|
||||
"max-age=63072000; includeSubDomains; preload",
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* 4. CORS
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function corsMiddleware(
|
||||
_req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
const origin = _req.headers.origin as string;
|
||||
const allowedOrigins = (process.env.CORS_ORIGINS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
res.set("Access-Control-Allow-Origin", origin);
|
||||
}
|
||||
|
||||
res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID");
|
||||
res.set("Access-Control-Allow-Credentials", "true");
|
||||
res.set("Access-Control-Max-Age", "86400");
|
||||
|
||||
// Handle preflight
|
||||
if (_req.method === "OPTIONS") {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* 5. REQUEST ID
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function requestIdMiddleware(
|
||||
_req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
const id =
|
||||
(_req.headers["x-request-id"] as string) ||
|
||||
`req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
res.set("X-Request-ID", id);
|
||||
next();
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* 6. BODY SIZE LIMIT
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function bodyLimit(maxBytes: number = 1_048_576) {
|
||||
// 1MB default
|
||||
return (_req: Request, res: Response, next: NextFunction) => {
|
||||
const cl = parseInt(_req.headers["content-length"] || "0", 10);
|
||||
if (cl > maxBytes) {
|
||||
return res.status(413).json({ error: "Request body too large" });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* 7. VALIDATION HELPERS
|
||||
* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Validate email format (RFC 5322 simplified) */
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
|
||||
email,
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate password strength */
|
||||
export function validatePassword(password: string): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) errors.push("Password must be at least 8 characters");
|
||||
if (password.length > 128) errors.push("Password must be at most 128 characters");
|
||||
if (!/[A-Z]/.test(password)) errors.push("Password must contain an uppercase letter");
|
||||
if (!/[a-z]/.test(password)) errors.push("Password must contain a lowercase letter");
|
||||
if (!/[0-9]/.test(password)) errors.push("Password must contain a number");
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};:'",.<>?\/\\|`~]/.test(password))
|
||||
errors.push("Password must contain a special character");
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
|
@ -2,9 +2,12 @@ import express from 'express';
|
|||
import type { Express } from 'express';
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function serveStatic(app: Express) {
|
||||
const distPath = path.resolve(__dirname, "public");
|
||||
const distPath = path.resolve(__dirname, "..", "dist", "public");
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Could not find the build directory: ${distPath}, make sure to build the client first`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue