Harp404 commited on
Commit
3d00a0a
·
1 Parent(s): 9d0dca9
.github/workflows/keep-alive.yml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Keep Render awake
2
+ # Pings /health every ~14 min so the free Render web service never spins down.
3
+ # NOTE: edit the URL below to your real Render URL after the first deploy.
4
+ on:
5
+ schedule:
6
+ - cron: '*/14 * * * *'
7
+ workflow_dispatch: {} # lets you trigger it manually too
8
+ jobs:
9
+ ping:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Ping health endpoint
13
+ run: curl -fsS https://astromesh.onrender.com/health || true
dev/mock-gateway.js CHANGED
@@ -13,12 +13,34 @@ const https = require('https');
13
  const crypto = require('crypto');
14
  const fs = require('fs');
15
  const path = require('path');
 
16
  const { URL } = require('url');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  // Reuse the frontend's installed SGP4 lib for real conjunction screening.
18
  let satLib = null;
19
  try { satLib = require(path.join(__dirname, '..', 'frontend', 'node_modules', 'satellite.js')); } catch { /* optional */ }
20
 
21
- const PORT = 8090;
22
 
23
  // ---------------------------------------------------------------------------
24
  // Minimal .env loader (no deps) — loads repo-root .env into process.env
@@ -311,12 +333,35 @@ setTimeout(() => {
311
  // HTTP helpers
312
  // ---------------------------------------------------------------------------
313
 
 
 
 
 
 
314
  function cors(res) {
315
- res.setHeader('Access-Control-Allow-Origin', '*');
 
 
 
 
 
 
316
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
317
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
318
  }
319
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  function json(res, status, body) {
321
  cors(res);
322
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -1195,6 +1240,7 @@ async function handleRequest(req, res) {
1195
  const parsed = new URL(req.url, `http://localhost:${PORT}`);
1196
  const path = parsed.pathname;
1197
  const method = req.method.toUpperCase();
 
1198
 
1199
  // Preflight
1200
  if (method === 'OPTIONS') {
@@ -1272,6 +1318,8 @@ async function handleRequest(req, res) {
1272
  // ---- POST routes ----
1273
 
1274
  if (method === 'POST' && path === '/api/chat') {
 
 
1275
  const body = await readBody(req);
1276
  const msgs = Array.isArray(body.messages) ? body.messages
1277
  : (body.message ? [{ role: 'user', content: String(body.message) }] : []);
@@ -1392,6 +1440,36 @@ async function handleRequest(req, res) {
1392
  return json(res, 200, { status: 'reset', leader_id: leaderId });
1393
  }
1394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1395
  // 404
1396
  cors(res);
1397
  res.writeHead(404, { 'Content-Type': 'application/json' });
 
13
  const crypto = require('crypto');
14
  const fs = require('fs');
15
  const path = require('path');
16
+ const path_ = path; // alias: `path` is shadowed inside handleRequest
17
  const { URL } = require('url');
18
+
19
+ // Built frontend dir (single-origin serving). Set SERVE_FRONTEND=0 to disable.
20
+ const DIST_DIR = process.env.SERVE_FRONTEND === '0'
21
+ ? null
22
+ : path.join(__dirname, '..', 'frontend', 'dist');
23
+ const MIME = {
24
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript',
25
+ '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
26
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
27
+ '.gif': 'image/gif', '.ico': 'image/x-icon', '.glb': 'model/gltf-binary',
28
+ '.gltf': 'model/gltf+json', '.bin': 'application/octet-stream', '.wasm': 'application/wasm',
29
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.txt': 'text/plain',
30
+ };
31
+ function sendFile(res, filePath) {
32
+ const ext = path_.extname(filePath).toLowerCase();
33
+ const type = MIME[ext] || 'application/octet-stream';
34
+ // hash-named build assets are immutable; cache hard. index.html stays fresh.
35
+ const cache = filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable';
36
+ res.writeHead(200, { 'Content-Type': type, 'Cache-Control': cache });
37
+ fs.createReadStream(filePath).pipe(res);
38
+ }
39
  // Reuse the frontend's installed SGP4 lib for real conjunction screening.
40
  let satLib = null;
41
  try { satLib = require(path.join(__dirname, '..', 'frontend', 'node_modules', 'satellite.js')); } catch { /* optional */ }
42
 
43
+ const PORT = process.env.PORT || 8090;
44
 
45
  // ---------------------------------------------------------------------------
46
  // Minimal .env loader (no deps) — loads repo-root .env into process.env
 
333
  // HTTP helpers
334
  // ---------------------------------------------------------------------------
335
 
336
+ // Comma-separated list in env, e.g. "https://astromesh.onrender.com,https://astromesh.web.app"
337
+ // Defaults to "*" (open) for local dev. Set ALLOWED_ORIGINS in production to lock it down.
338
+ const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '*')
339
+ .split(',').map((s) => s.trim()).filter(Boolean);
340
+
341
  function cors(res) {
342
+ const origin = res._reqOrigin;
343
+ if (ALLOWED_ORIGINS.includes('*')) {
344
+ res.setHeader('Access-Control-Allow-Origin', '*');
345
+ } else if (origin && ALLOWED_ORIGINS.includes(origin)) {
346
+ res.setHeader('Access-Control-Allow-Origin', origin);
347
+ res.setHeader('Vary', 'Origin');
348
+ }
349
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
350
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
351
  }
352
 
353
+ // ---- simple in-memory per-IP rate limiter (protects the paid Groq endpoint) ----
354
+ const _rlHits = new Map();
355
+ function rateLimited(req, max, windowMs) {
356
+ const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
357
+ || (req.socket && req.socket.remoteAddress) || 'unknown';
358
+ const now = Date.now();
359
+ const arr = (_rlHits.get(ip) || []).filter((t) => now - t < windowMs);
360
+ arr.push(now);
361
+ _rlHits.set(ip, arr);
362
+ return arr.length > max;
363
+ }
364
+
365
  function json(res, status, body) {
366
  cors(res);
367
  res.writeHead(status, { 'Content-Type': 'application/json' });
 
1240
  const parsed = new URL(req.url, `http://localhost:${PORT}`);
1241
  const path = parsed.pathname;
1242
  const method = req.method.toUpperCase();
1243
+ res._reqOrigin = req.headers.origin; // used by cors() for the allow-list
1244
 
1245
  // Preflight
1246
  if (method === 'OPTIONS') {
 
1318
  // ---- POST routes ----
1319
 
1320
  if (method === 'POST' && path === '/api/chat') {
1321
+ // 20 AI calls / minute / IP — stops anyone draining the Groq key via the public URL
1322
+ if (rateLimited(req, 20, 60000)) return json(res, 429, { error: 'Too many requests, slow down.' });
1323
  const body = await readBody(req);
1324
  const msgs = Array.isArray(body.messages) ? body.messages
1325
  : (body.message ? [{ role: 'user', content: String(body.message) }] : []);
 
1440
  return json(res, 200, { status: 'reset', leader_id: leaderId });
1441
  }
1442
 
1443
+ // ---- /celestrak proxy (so the browser never calls celestrak.org directly) ----
1444
+ if (method === 'GET' && path.startsWith('/celestrak/')) {
1445
+ const target = 'https://celestrak.org/' + path.slice('/celestrak/'.length) + (parsed.search || '');
1446
+ https.get(target, (up) => {
1447
+ cors(res);
1448
+ res.writeHead(up.statusCode || 200, { 'Content-Type': up.headers['content-type'] || 'text/plain' });
1449
+ up.pipe(res);
1450
+ }).on('error', (e) => json(res, 502, { error: 'celestrak proxy failed', detail: e.message }));
1451
+ return;
1452
+ }
1453
+
1454
+ // ---- static frontend (single-origin: serves the built Vue app) ----
1455
+ if (method === 'GET' && DIST_DIR) {
1456
+ const rel = path === '/' ? 'index.html' : path.replace(/^\/+/, '');
1457
+ const filePath = path_.join(DIST_DIR, rel);
1458
+ // block path traversal
1459
+ if (filePath.startsWith(DIST_DIR)) {
1460
+ try {
1461
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
1462
+ return sendFile(res, filePath);
1463
+ }
1464
+ } catch (_) {}
1465
+ // SPA fallback: any unknown non-API path -> index.html
1466
+ if (!path.startsWith('/api') && !path.startsWith('/control') && !path.startsWith('/celestrak')) {
1467
+ const idx = path_.join(DIST_DIR, 'index.html');
1468
+ if (fs.existsSync(idx)) return sendFile(res, idx);
1469
+ }
1470
+ }
1471
+ }
1472
+
1473
  // 404
1474
  cors(res);
1475
  res.writeHead(404, { 'Content-Type': 'application/json' });
frontend/src/config.js CHANGED
@@ -1,3 +1,6 @@
1
- const host = window.location.hostname || 'localhost'
2
- export const GATEWAY = `http://${host}:8090`
3
- export const WS_URL = `ws://${host}:8090/ws`
 
 
 
 
1
+ // Same-origin: in dev the Vite proxy forwards /api + /ws to :8090; in production
2
+ // the gateway serves this frontend AND /api + /ws from the very same origin.
3
+ // Nothing here exposes the backend host — the browser only ever sees its own URL.
4
+ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
5
+ export const GATEWAY = '' // relative — same origin
6
+ export const WS_URL = `${proto}://${window.location.host}/ws`
render.yaml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Render deploy — single service (gateway serves frontend + /api + /ws, all same-origin).
2
+ # Secrets are set in the Render dashboard, never committed.
3
+ services:
4
+ - type: web
5
+ name: astromesh
6
+ runtime: node
7
+ plan: free
8
+ # Installs frontend deps (incl. satellite.js used by the gateway) and builds the Vue app.
9
+ buildCommand: cd frontend && npm install && npm run build
10
+ startCommand: node dev/mock-gateway.js
11
+ healthCheckPath: /health
12
+ autoDeploy: true
13
+ envVars:
14
+ - key: GROQ_MODEL
15
+ value: openai/gpt-oss-120b
16
+ # Set these in the dashboard (Environment tab) — do NOT hardcode:
17
+ - key: SPACETRACK_IDENTITY
18
+ sync: false
19
+ - key: SPACETRACK_PASSWORD
20
+ sync: false
21
+ - key: GROQ_API_KEY
22
+ sync: false
23
+ # After first deploy, set this to your live URL to lock CORS, e.g.
24
+ # https://astromesh.onrender.com
25
+ - key: ALLOWED_ORIGINS
26
+ sync: false