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 { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { CheckCircle2, AlertTriangle, ExternalLink } from "lucide-react";
|
import { CheckCircle2, AlertTriangle, ExternalLink, Loader2 } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -15,11 +15,41 @@ interface OratsStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
// Form state is React-only — values never touch localStorage/sessionStorage/cookies.
|
// Form state is React-only — values never touch localStorage/sessionStorage/cookies.
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [baseUrl, setBaseUrl] = useState("https://api.orats.io/datav2");
|
const [baseUrl, setBaseUrl] = useState("https://api.orats.io/datav2");
|
||||||
const { data: status } = useQuery<OratsStatus>({ queryKey: ["/api/orats/status"] });
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-5 p-4 sm:p-6">
|
<div className="flex flex-col gap-5 p-4 sm:p-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -52,7 +82,7 @@ export default function SettingsPage() {
|
||||||
<AlertDescription className="text-xs">
|
<AlertDescription className="text-xs">
|
||||||
{status.configured
|
{status.configured
|
||||||
? "Endpoints will attempt live ORATS calls. Verify network egress and rate limits."
|
? "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>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
@ -75,7 +105,7 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="orats-api-key" className="text-xs font-medium">
|
<Label htmlFor="orats-api-key" className="text-xs font-medium">
|
||||||
API key (session only)
|
API Key
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="orats-api-key"
|
id="orats-api-key"
|
||||||
|
|
@ -87,11 +117,20 @@ export default function SettingsPage() {
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -103,6 +142,16 @@ export default function SettingsPage() {
|
||||||
>
|
>
|
||||||
Clear form
|
Clear form
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"drizzle-zod": "^0.7.0",
|
"drizzle-zod": "^0.7.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"express": "^5.0.1",
|
"express": "^5.0.1",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"express-validator": "^7.3.2",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
|
"helmet": "^8.2.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
|
|
@ -4728,6 +4732,23 @@
|
||||||
"node": ">=6.6.0"
|
"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": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
|
|
@ -5384,6 +5405,24 @@
|
||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/express-session": {
|
||||||
"version": "1.19.0",
|
"version": "1.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||||
|
|
@ -5428,6 +5467,19 @@
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-equals": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||||
|
|
@ -5737,6 +5789,18 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
|
@ -5837,6 +5901,15 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|
@ -7094,9 +7167,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
|
|
@ -8781,6 +8854,15 @@
|
||||||
"node": ">= 0.4.0"
|
"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": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|
@ -9436,9 +9518,9 @@
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.20.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|
|
||||||
|
|
@ -46,14 +46,18 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"drizzle-zod": "^0.7.0",
|
"drizzle-zod": "^0.7.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"express": "^5.0.1",
|
"express": "^5.0.1",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"express-validator": "^7.3.2",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
|
"helmet": "^8.2.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,203 @@
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// 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 { createUser, getUserByEmail, getUserById, updateUser, updateUserPassword } from "./db";
|
||||||
|
import { sanitizeInput } from "./middleware";
|
||||||
|
|
||||||
export function requireAuth(req: any, res: any, next: any) {
|
export function requireAuth(req: Request, res: Response, next: () => void) {
|
||||||
if (!(req.session as any)?.userId) {
|
const session = req.session as Record<string, unknown>;
|
||||||
|
if (!session?.userId) {
|
||||||
return res.status(401).json({ error: "Not authenticated" });
|
return res.status(401).json({ error: "Not authenticated" });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerAuthRoutes(app: any) {
|
export function registerAuthRoutes(app: Express) {
|
||||||
// POST /api/auth/register
|
// POST /api/auth/register
|
||||||
app.post("/api/auth/register", async (req: any, res: any) => {
|
app.post("/api/auth/register", async (req: Request, res: Response) => {
|
||||||
const { name, email, password } = req.body;
|
const { name, email, password } = req.body as Record<string, string>;
|
||||||
if (!name || !email || !password || password.length < 6) {
|
|
||||||
return res.status(400).json({ error: "Name, email, and password (min 6 chars) are required" });
|
// 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());
|
|
||||||
if (existing) {
|
// Validate password complexity
|
||||||
return res.status(409).json({ error: "Email already in use" });
|
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, 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" });
|
||||||
}
|
}
|
||||||
const hash = await bcrypt.hash(password, 10);
|
|
||||||
const user = await createUser({ name, email: email.toLowerCase(), passwordHash: hash });
|
|
||||||
(req.session as any).userId = user.id;
|
|
||||||
res.status(201).json({ id: user.id, name: user.name, email: user.email });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/auth/login
|
// POST /api/auth/login
|
||||||
app.post("/api/auth/login", async (req: any, res: any) => {
|
app.post("/api/auth/login", async (req: Request, res: Response) => {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body as Record<string, string>;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return res.status(400).json({ error: "Email and password are required" });
|
return res.status(400).json({ error: "Email and password are required" });
|
||||||
}
|
}
|
||||||
const user = await getUserByEmail(email.toLowerCase());
|
|
||||||
if (!user) {
|
try {
|
||||||
return res.status(401).json({ error: "Invalid email or password" });
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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" });
|
||||||
}
|
}
|
||||||
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;
|
|
||||||
res.json({ id: user.id, name: user.name, email: user.email });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/auth/logout
|
// POST /api/auth/logout
|
||||||
app.post("/api/auth/logout", (req: any, res: any) => {
|
app.post("/api/auth/logout", (req: Request, res: Response) => {
|
||||||
req.session.destroy(() => { res.json({ ok: true }); });
|
req.session.destroy(() => {
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/auth/me
|
// GET /api/auth/me
|
||||||
app.get("/api/auth/me", requireAuth, async (req: any, res: any) => {
|
app.get("/api/auth/me", requireAuth, async (req: Request, res: Response) => {
|
||||||
const user = await getUserById((req.session as any).userId);
|
try {
|
||||||
if (!user) {
|
const session = req.session as Record<string, unknown>;
|
||||||
req.session.destroy();
|
const user = await getUserById(session.userId as number);
|
||||||
return res.status(401).json({ error: "Not authenticated" });
|
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" });
|
||||||
}
|
}
|
||||||
res.json({ id: user.id, name: user.name, email: user.email });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/auth/profile
|
// PUT /api/auth/profile
|
||||||
app.put("/api/auth/profile", requireAuth, async (req: any, res: any) => {
|
app.put("/api/auth/profile", requireAuth, async (req: Request, res: Response) => {
|
||||||
const { name, email } = req.body;
|
const { name, email } = req.body as Record<string, string>;
|
||||||
const updates: any = {};
|
const session = req.session as Record<string, unknown>;
|
||||||
if (name) updates.name = name;
|
|
||||||
|
const updates: Record<string, string> = {};
|
||||||
|
if (name) updates.name = sanitizeInput(name);
|
||||||
if (email) {
|
if (email) {
|
||||||
const existing = await getUserByEmail(email.toLowerCase());
|
const sanitizedEmail = sanitizeInput(email).toLowerCase();
|
||||||
if (existing && existing.id !== (req.session as any).userId) {
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
return res.status(409).json({ error: "Email already in use" });
|
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 = sanitizedEmail;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Email check error:", error);
|
||||||
|
return res.status(500).json({ error: "Internal server error" });
|
||||||
}
|
}
|
||||||
updates.email = email.toLowerCase();
|
|
||||||
}
|
}
|
||||||
const user = await updateUser((req.session as any).userId, updates);
|
|
||||||
res.json({ id: user.id, name: user.name, email: user.email });
|
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
|
// PUT /api/auth/password
|
||||||
app.put("/api/auth/password", requireAuth, async (req: any, res: any) => {
|
app.put("/api/auth/password", requireAuth, async (req: Request, res: Response) => {
|
||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body as Record<string, string>;
|
||||||
if (!currentPassword || !newPassword || newPassword.length < 6) {
|
const session = req.session as Record<string, unknown>;
|
||||||
return res.status(400).json({ error: "Current password and new password (min 6 chars) required" });
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return res.status(400).json({ error: "Current and new password are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 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" });
|
||||||
}
|
}
|
||||||
const user = await getUserById((req.session as any).userId);
|
|
||||||
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);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/auth/request-reset (stub)
|
// POST /api/auth/request-reset (stub)
|
||||||
app.post("/api/auth/request-reset", async (req: any, res: any) => {
|
app.post("/api/auth/request-reset", (req: Request, res: Response) => {
|
||||||
const { email } = req.body;
|
const { email } = req.body as Record<string, string>;
|
||||||
if (!email) return res.status(400).json({ error: "Email is required" });
|
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" });
|
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",
|
user: process.env.DB_USER || "gn_admin",
|
||||||
password: process.env.DB_PASSWORD || "",
|
password: process.env.DB_PASSWORD || "",
|
||||||
database: process.env.DB_NAME || "gammanexus",
|
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,
|
max: 20,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 5000,
|
connectionTimeoutMillis: 5000,
|
||||||
|
|
|
||||||
|
|
@ -92,10 +92,11 @@ app.use((req, res, next) => {
|
||||||
// this serves both the API and the client.
|
// this serves both the API and the client.
|
||||||
// It is the only port that is not firewalled.
|
// It is the only port that is not firewalled.
|
||||||
const port = parseInt(process.env.PORT || "5000", 10);
|
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(
|
httpServer.listen(
|
||||||
{
|
{
|
||||||
port,
|
port,
|
||||||
host: "127.0.0.1",
|
host,
|
||||||
reusePort: true,
|
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
|
// ORATS Data API v2 client
|
||||||
//
|
//
|
||||||
// Documentation: https://docs.orats.io/
|
|
||||||
// Base URL: https://api.orats.io/datav2
|
// 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:
|
// Key endpoints:
|
||||||
// - GET /strikes?ticker=SPY - Strike-level gamma, OI, delta, vega
|
// - GET /strikes?ticker=SPY - Strike-level OI, IV, greeks
|
||||||
// - GET /summaries?ticker=SPY - IV, IVR, expected move, skew
|
// - GET /summaries?ticker=SPY - IV curve, skew, implied move
|
||||||
// - GET /cores?ticker=SPY - Spot price, daily series
|
// - GET /cores?ticker=SPY - Spot price, forecasts, historical vol
|
||||||
// - GET /monies/implied?ticker=SPY - Implied volatility skew curves
|
// - GET /monies/implied?ticker=SPY - Implied vol skew by delta
|
||||||
//
|
//
|
||||||
// Rate limits: 100 req/min per API key
|
// 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 {
|
export interface OratsClientConfig {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
|
@ -23,19 +23,32 @@ export class OratsClient {
|
||||||
private apiKey: string;
|
private apiKey: string;
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private cache = new Map<string, { data: unknown; expires: number }>();
|
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 = {}) {
|
constructor(config: OratsClientConfig = {}) {
|
||||||
this.apiKey = config.apiKey ?? process.env.ORATS_API_KEY ?? "";
|
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 {
|
isConfigured(): boolean {
|
||||||
return this.apiKey.length > 0;
|
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> {
|
private async request<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
|
||||||
const url = new URL(`${this.baseUrl}${endpoint}`);
|
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||||||
|
if (this.apiKey) {
|
||||||
|
url.searchParams.set("token", this.apiKey);
|
||||||
|
}
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v));
|
Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v));
|
||||||
}
|
}
|
||||||
|
|
@ -47,15 +60,13 @@ export class OratsClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
const response = await fetch(url.toString(), {
|
||||||
headers: {
|
headers: { Accept: "application/json" },
|
||||||
"X-API-Key": this.apiKey,
|
signal: AbortSignal.timeout(15000),
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const data = await response.json();
|
||||||
|
|
@ -63,57 +74,62 @@ export class OratsClient {
|
||||||
return data as T;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch strike-level data for gamma exposure calculation
|
// ── Raw fetchers ──────────────────────────────────────────────────
|
||||||
async fetchStrikes(ticker: string): Promise<OratsStrikesResponse> {
|
async fetchCores(ticker: string): Promise<OratsFlatResponse<OratsCore>> {
|
||||||
return this.request<OratsStrikesResponse>("/strikes", { ticker });
|
return this.request<OratsFlatResponse<OratsCore>>("/cores", { ticker });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch summary data (IV, IVR, expected move, skew)
|
async fetchSummaries(ticker: string): Promise<OratsFlatResponse<OratsSummary>> {
|
||||||
async fetchSummaries(ticker: string): Promise<OratsSummariesResponse> {
|
return this.request<OratsFlatResponse<OratsSummary>>("/summaries", { ticker });
|
||||||
return this.request<OratsSummariesResponse>("/summaries", { ticker });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch core data (spot price, daily series)
|
async fetchStrikes(ticker: string): Promise<OratsFlatResponse<OratsStrike>> {
|
||||||
async fetchCores(ticker: string): Promise<OratsCoresResponse> {
|
return this.request<OratsFlatResponse<OratsStrike>>("/strikes", { ticker });
|
||||||
return this.request<OratsCoresResponse>("/cores", { ticker });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch implied volatility skew curves
|
async fetchMonies(ticker: string): Promise<OratsFlatResponse<OratsMoney>> {
|
||||||
async fetchMonies(ticker: string): Promise<OratsMoniesResponse> {
|
return this.request<OratsFlatResponse<OratsMoney>>("/monies/implied", { ticker });
|
||||||
return this.request<OratsMoniesResponse>("/monies/implied", { ticker });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch available symbols/exchanges
|
// ── Compute GammaDesk Summary ─────────────────────────────────────
|
||||||
async fetchSymbols(): Promise<OratsSymbolsResponse> {
|
|
||||||
return this.request<OratsSymbolsResponse>("/symbols");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute Summary from ORATS data
|
|
||||||
async computeSummary(ticker: string): Promise<Summary> {
|
async computeSummary(ticker: string): Promise<Summary> {
|
||||||
const [cores, summaries] = await Promise.all([
|
const [coresRes, sumRes, strikesRes] = await Promise.all([
|
||||||
this.fetchCores(ticker),
|
this.fetchCores(ticker),
|
||||||
this.fetchSummaries(ticker),
|
this.fetchSummaries(ticker),
|
||||||
|
this.fetchStrikes(ticker),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Get the latest core data
|
const core = coresRes.data[0];
|
||||||
const latestCore = cores[0]?.data?.[0];
|
const summary = sumRes.data[0];
|
||||||
const spot = latestCore?.price ?? 0;
|
const strikes = strikesRes.data;
|
||||||
const spotChange = latestCore?.change ?? 0;
|
|
||||||
const spotChangePct = ((spotChange / spot) * 100) || 0;
|
|
||||||
|
|
||||||
// Get the latest summary
|
// Spot price from cores (pxAtmIv = pin/ATM implied price) or summaries stockPrice
|
||||||
const latestSummary = summaries[0]?.data?.[0];
|
const spot = core?.pxAtmIv ?? summary?.stockPrice ?? 0;
|
||||||
const ivx = latestSummary?.iv ?? 15;
|
const priorClose = core?.priorCls ?? spot;
|
||||||
const ivRank = latestSummary?.ivr ?? 50;
|
const spotChange = spot - priorClose;
|
||||||
const skew = latestSummary?.skew ?? 0;
|
const spotChangePct = priorClose > 0 ? (spotChange / priorClose) * 100 : 0;
|
||||||
const expectedMove = latestSummary?.expected_move ?? spot * 0.01;
|
|
||||||
const expectedMovePct = ((expectedMove / spot) * 100) || 0;
|
|
||||||
|
|
||||||
// Fetch strikes to compute GEX metrics
|
// IV from summaries (30d as default "ivx")
|
||||||
const strikesData = await this.fetchStrikes(ticker);
|
const ivx = (summary?.iv30d ?? summary?.iv20d ?? core?.atmIvM1 ?? 0) * 100; // ORATS uses decimals
|
||||||
const strikes = strikesData[0]?.data ?? [];
|
|
||||||
|
|
||||||
// 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 { callWall, putWall, hvl, netGex } = this.computeGexMetrics(strikes, spot);
|
||||||
|
|
||||||
const gammaRegime = netGex >= 0 ? "positive" : "negative";
|
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> {
|
async computeGexProfile(ticker: string): Promise<GexProfile> {
|
||||||
const [summary, strikesData] = await Promise.all([
|
const [summary, strikesRes] = await Promise.all([
|
||||||
this.computeSummary(ticker),
|
this.computeSummary(ticker),
|
||||||
this.fetchStrikes(ticker),
|
this.fetchStrikes(ticker),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const strikes = strikesData[0]?.data ?? [];
|
const strikes = strikesRes.data;
|
||||||
const spot = summary.spot;
|
const spot = summary.spot;
|
||||||
|
|
||||||
// Process strikes into GEX bars
|
// Filter to strikes within ~20% of spot, near-term (DTE<=7), with meaningful OI
|
||||||
const bars = strikes
|
const strikeFloor = spot * 0.8;
|
||||||
.filter((s: any) => s.strike && s.strike > 0)
|
const strikeCeiling = spot * 1.2;
|
||||||
.map((s: any) => {
|
const relevantStrikes = strikes.filter(
|
||||||
const callGex = (s.call_gamma || 0) * (s.call_oi || 0) * 100 * spot * spot * 0.01;
|
(s) => s.dte <= 7 && s.strike >= strikeFloor && s.strike <= strikeCeiling && s.callOpenInterest + s.putOpenInterest > 500
|
||||||
const putGex = (s.put_gamma || 0) * (s.put_oi || 0) * 100 * spot * spot * 0.01;
|
);
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
strike: round2(s.strike),
|
strike: round2(s.strike),
|
||||||
callGex: Math.round(callGex),
|
callGex: Math.round(callGex),
|
||||||
|
|
@ -160,7 +187,8 @@ export class OratsClient {
|
||||||
netGex: Math.round(callGex - putGex),
|
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 {
|
return {
|
||||||
ticker,
|
ticker,
|
||||||
|
|
@ -173,69 +201,73 @@ export class OratsClient {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute expirations response from ORATS data
|
// ── Compute Expirations Matrix ────────────────────────────────────
|
||||||
async computeExpirations(ticker: string): Promise<ExpirationsResponse> {
|
async computeExpirations(ticker: string): Promise<ExpirationsResponse> {
|
||||||
const summary = await this.computeSummary(ticker);
|
const summary = await this.computeSummary(ticker);
|
||||||
const strikesData = await this.fetchStrikes(ticker);
|
const strikesRes = await this.fetchStrikes(ticker);
|
||||||
const strikes = strikesData[0]?.data ?? [];
|
const moniesRes = await this.fetchMonies(ticker);
|
||||||
|
const strikes = strikesRes.data;
|
||||||
|
const monies = moniesRes.data;
|
||||||
|
|
||||||
// Group by expiration and compute metrics for each
|
// Group strikes by expiration
|
||||||
const expiryMap = new Map<string, any[]>();
|
const expiryMap = new Map<string, OratsStrike[]>();
|
||||||
for (const strike of strikes) {
|
for (const s of strikes) {
|
||||||
const expiry = strike.expiry || strike.expiration_date;
|
const expiry = s.expirDate || "";
|
||||||
if (expiry) {
|
if (expiry) {
|
||||||
if (!expiryMap.has(expiry)) expiryMap.set(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 rows = Array.from(expiryMap.entries()).map(([expiry, expiryStrikes]) => {
|
||||||
const expiryDate = new Date(expiry);
|
const expiryDate = new Date(expiry);
|
||||||
const dte = Math.max(0, Math.floor((expiryDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000)));
|
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
|
// Find matching money row for this expiry
|
||||||
const ivs = expiryStrikes.map(s => s.iv).filter(v => v > 0);
|
const moneyRow = monies.find((m) => m.expirDate === expiry);
|
||||||
const avgIv = ivs.length ? ivs.reduce((a, b) => a + b, 0) / ivs.length : 15;
|
const atmIv = ((moneyRow?.atmiv ?? moneyRow?.vol100 ?? 0) * 100) || 0; // ORATS uses decimals
|
||||||
const expectedMovePct = (avgIv / 100) * Math.sqrt(Math.max(dte, 1) / 365) * 100;
|
|
||||||
|
// 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;
|
const expectedMove = (expectedMovePct / 100) * summary.spot;
|
||||||
|
|
||||||
// Compute net GEX for this expiry
|
// Net GEX for this expiry
|
||||||
let netGex = 0;
|
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 callWall = 0;
|
||||||
let putWall = 0;
|
let putWall = 0;
|
||||||
let maxCallGex = -Infinity;
|
let maxCallGex = -Infinity;
|
||||||
let minPutGex = Infinity;
|
let maxPutGex = -Infinity;
|
||||||
|
|
||||||
for (const s of expiryStrikes) {
|
for (const s of expiryStrikes) {
|
||||||
const callGex = (s.call_gamma || 0) * (s.call_oi || 0) * 100 * summary.spot * summary.spot * 0.01;
|
const gamma = Math.abs(s.gamma || 0);
|
||||||
const putGex = (s.put_gamma || 0) * (s.put_oi || 0) * 100 * summary.spot * summary.spot * 0.01;
|
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 (callGex > maxCallGex) { maxCallGex = callGex; callWall = s.strike; }
|
||||||
if (putGex < minPutGex) { minPutGex = putGex; putWall = s.strike; }
|
if (putGex > maxPutGex) { maxPutGex = putGex; putWall = s.strike; }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
expiry: expiry.slice(0, 10),
|
expiry: expiry.slice(0, 10),
|
||||||
dte,
|
dte,
|
||||||
netGex: Math.round(netGex),
|
netGex: Math.round(netGex),
|
||||||
ivx: round2(avgIv),
|
ivx: round2(atmIv),
|
||||||
skew: round2(summary.skew),
|
skew: round2(skew),
|
||||||
expectedMove: round2(expectedMove),
|
expectedMove: round2(expectedMove),
|
||||||
expectedMovePct: round2(expectedMovePct),
|
expectedMovePct: round2(expectedMovePct),
|
||||||
callWall: round2(callWall),
|
callWall: round2(callWall),
|
||||||
putWall: round2(putWall),
|
putWall: round2(putWall),
|
||||||
callSkew: round2(avgIv * 0.9),
|
callSkew: round2(vol75),
|
||||||
putSkew: round2(avgIv * 1.1),
|
putSkew: round2(vol25),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by DTE
|
|
||||||
rows.sort((a, b) => a.dte - b.dte);
|
rows.sort((a, b) => a.dte - b.dte);
|
||||||
|
|
||||||
return {
|
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 callWall = spot;
|
||||||
let putWall = spot;
|
let putWall = spot;
|
||||||
let maxCallGex = -Infinity;
|
let maxCallGex = -Infinity;
|
||||||
let minPutGex = Infinity;
|
let maxPutGex = -Infinity;
|
||||||
let cumulativeNetGex = 0;
|
let cumulativeNetGex = 0;
|
||||||
let hvl = spot;
|
let hvl = spot;
|
||||||
let totalNetGex = 0;
|
let totalNetGex = 0;
|
||||||
|
|
||||||
// Sort by strike
|
// Focus on near-term expirations (DTE <= 7) for gamma pinning signal
|
||||||
const sorted = [...strikes].sort((a, b) => a.strike - b.strike);
|
// 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) {
|
for (const s of sorted) {
|
||||||
const callGex = (strike.call_gamma || 0) * (strike.call_oi || 0) * 100 * spot * spot * 0.01;
|
const gamma = Math.abs(s.gamma || 0);
|
||||||
const putGex = (strike.put_gamma || 0) * (strike.put_oi || 0) * 100 * spot * spot * 0.01;
|
const callGex = gamma * s.callOpenInterest * spot * spot * 0.01;
|
||||||
|
const putGex = gamma * s.putOpenInterest * spot * spot * 0.01;
|
||||||
const netGex = callGex - putGex;
|
const netGex = callGex - putGex;
|
||||||
|
|
||||||
totalNetGex += netGex;
|
totalNetGex += netGex;
|
||||||
cumulativeNetGex += netGex;
|
cumulativeNetGex += netGex;
|
||||||
|
|
||||||
if (callGex > maxCallGex) { maxCallGex = callGex; callWall = strike.strike; }
|
if (callGex > maxCallGex) { maxCallGex = callGex; callWall = s.strike; }
|
||||||
if (putGex > minPutGex) { minPutGex = putGex; putWall = strike.strike; }
|
if (putGex > maxPutGex) { maxPutGex = putGex; putWall = s.strike; }
|
||||||
|
|
||||||
// HVL is where cumulative net GEX crosses zero
|
|
||||||
if (cumulativeNetGex >= 0 && hvl === spot) {
|
if (cumulativeNetGex >= 0 && hvl === spot) {
|
||||||
hvl = strike.strike;
|
hvl = s.strike;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,64 +322,85 @@ export class OratsClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ORATS API response types
|
// ── ORATS API v2 Types ──────────────────────────────────────────────
|
||||||
export interface OratsStrikesResponse {
|
|
||||||
[ticker: string]: {
|
interface OratsFlatResponse<T> {
|
||||||
data: Array<{
|
data: T[];
|
||||||
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;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OratsSummariesResponse {
|
export interface OratsCore {
|
||||||
[ticker: string]: {
|
ticker: string;
|
||||||
data: Array<{
|
tradeDate: string;
|
||||||
iv: number;
|
assetType: number;
|
||||||
ivr: number;
|
priorCls: number;
|
||||||
skew: number;
|
pxAtmIv: number;
|
||||||
expected_move: 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 OratsCoresResponse {
|
export interface OratsSummary {
|
||||||
[ticker: string]: {
|
ticker: string;
|
||||||
data: Array<{
|
tradeDate: string;
|
||||||
price: number;
|
stockPrice: number;
|
||||||
change: number;
|
iv10d: number;
|
||||||
change_pct: number;
|
iv20d: number;
|
||||||
volume: number;
|
iv30d: number;
|
||||||
}>;
|
iv60d: number;
|
||||||
};
|
iv90d: number;
|
||||||
|
iv6m: number;
|
||||||
|
iv1y: number;
|
||||||
|
dlt25Iv30d: number;
|
||||||
|
dlt75Iv30d: number;
|
||||||
|
impliedMove: number;
|
||||||
|
skewing?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OratsMoniesResponse {
|
export interface OratsStrike {
|
||||||
[ticker: string]: {
|
ticker: string;
|
||||||
data: Array<{
|
tradeDate: string;
|
||||||
strike: number;
|
expirDate: string;
|
||||||
call_iv: number;
|
dte: number;
|
||||||
put_iv: 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 OratsSymbolsResponse {
|
export interface OratsMoney {
|
||||||
symbols: Array<{
|
ticker: string;
|
||||||
ticker: string;
|
tradeDate: string;
|
||||||
name: string;
|
expirDate: string;
|
||||||
type: string;
|
stockPrice: number;
|
||||||
}>;
|
atmiv: number;
|
||||||
|
vol100: number;
|
||||||
|
vol75: number;
|
||||||
|
vol50: number;
|
||||||
|
vol25: number;
|
||||||
|
slope: number;
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function round1(n: number): number {
|
function round1(n: number): number {
|
||||||
|
|
|
||||||
102
server/routes.ts
102
server/routes.ts
|
|
@ -1,8 +1,11 @@
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import type { Server } from "node:http";
|
import type { Server } from "node:http";
|
||||||
import session from "express-session";
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
import MemoryStore from "memorystore";
|
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 {
|
import {
|
||||||
SYMBOLS,
|
SYMBOLS,
|
||||||
computeExpirations as mockExpirations,
|
computeExpirations as mockExpirations,
|
||||||
|
|
@ -13,8 +16,9 @@ import {
|
||||||
import { oratsClient } from "./oratsClient";
|
import { oratsClient } from "./oratsClient";
|
||||||
import { registerAuthRoutes } from "./authRoutes";
|
import { registerAuthRoutes } from "./authRoutes";
|
||||||
import { initDb } from "./db";
|
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));
|
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 {
|
try {
|
||||||
res.json(fn(raw));
|
res.json(fn(raw));
|
||||||
} catch (err) {
|
} 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> {
|
export async function registerRoutes(httpServer: Server, app: Express): Promise<Server> {
|
||||||
// Initialize database
|
|
||||||
await initDb();
|
await initDb();
|
||||||
|
|
||||||
// Session middleware
|
// ── Security Headers ──────────────────────────────────────────────
|
||||||
app.use(session({
|
app.use(securityHeaders);
|
||||||
store: new SessionMemoryStore({ checkPeriod: 86400000 }),
|
|
||||||
secret: process.env.SESSION_SECRET || "gammadesk-dev-secret-change-me",
|
// ── Rate Limiting ─────────────────────────────────────────────────
|
||||||
resave: false,
|
// General rate limit: 500 requests per 15 minutes (relaxed for dev)
|
||||||
saveUninitialized: false,
|
// Home subnet (192.168.x.x) is whitelisted in middleware.ts
|
||||||
cookie: {
|
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 500 }));
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
maxAge: 24 * 60 * 60 * 1000,
|
// 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",
|
||||||
|
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
|
// Auth routes
|
||||||
registerAuthRoutes(app);
|
registerAuthRoutes(app);
|
||||||
|
|
||||||
|
// Public API routes
|
||||||
app.get("/api/symbols", (_req, res) => {
|
app.get("/api/symbols", (_req, res) => {
|
||||||
res.json(SYMBOLS);
|
res.json(SYMBOLS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ORATS status - hide sensitive info
|
||||||
app.get("/api/orats/status", (_req, res) => {
|
app.get("/api/orats/status", (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
configured: oratsClient.isConfigured(),
|
configured: oratsClient.isConfigured(),
|
||||||
baseUrl: oratsClient.baseUrl,
|
baseUrl: oratsClient.isConfigured() ? "https://api.orats.io/datav2" : "not-configured",
|
||||||
apiKey: oratsClient.apiKey.slice(0, 8) + "...",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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) => {
|
app.get("/api/market/:symbol/summary", async (req, res) => {
|
||||||
const ticker = String(req.params.symbol || "").toUpperCase();
|
const ticker = String(req.params.symbol || "").toUpperCase();
|
||||||
if (!KNOWN_TICKERS.has(ticker)) {
|
if (!KNOWN_TICKERS.has(ticker)) {
|
||||||
|
|
@ -75,7 +123,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
||||||
res.json(summary);
|
res.json(summary);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`ORATS summary error for ${ticker}:`, 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);
|
res.json(gex);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`ORATS GEX error for ${ticker}:`, 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);
|
res.json(exp);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`ORATS expirations error for ${ticker}:`, 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) => {
|
app.get("/api/screener", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
if (oratsClient.isConfigured()) {
|
if (oratsClient.isConfigured()) {
|
||||||
// Fetch real data for all symbols
|
|
||||||
const summaries = await Promise.all(
|
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 screenerData = summaries.map((summary, i) => {
|
||||||
const symbol = SYMBOLS[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,
|
distanceToPutWall: ((summary.putWall - summary.spot) / summary.spot) * 100,
|
||||||
expectedMovePct: summary.expectedMovePct,
|
expectedMovePct: summary.expectedMovePct,
|
||||||
skew: summary.skew,
|
skew: summary.skew,
|
||||||
zeroDteNetGex: summary.netGex, // Simplified for now
|
zeroDteNetGex: summary.netGex,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
res.json(screenerData);
|
res.json(screenerData);
|
||||||
|
|
@ -144,9 +191,12 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("ORATS screener error:", err);
|
console.error("ORATS screener error:", err);
|
||||||
res.json(mockScreener()); // Fallback to mock
|
res.json(mockScreener());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return httpServer;
|
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 type { Express } from 'express';
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
export function serveStatic(app: Express) {
|
export function serveStatic(app: Express) {
|
||||||
const distPath = path.resolve(__dirname, "public");
|
const distPath = path.resolve(__dirname, "..", "dist", "public");
|
||||||
if (!fs.existsSync(distPath)) {
|
if (!fs.existsSync(distPath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Could not find the build directory: ${distPath}, make sure to build the client first`,
|
`Could not find the build directory: ${distPath}, make sure to build the client first`,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue