fyliu Claude Opus 4.6 commited on
Commit
6bb015e
·
1 Parent(s): c090f53

Add HF Spaces deployment config and passkey auth

Browse files

- Update Dockerfile to use $PORT env var (default 7860 for HF Spaces)
- Add HF Spaces README.md with Docker SDK metadata
- Add passkey authentication gate (backend + frontend)
- Update frontend components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Dockerfile CHANGED
@@ -21,6 +21,7 @@ COPY airline_routes.json ./
21
  # Copy built frontend
22
  COPY --from=frontend-build /app/frontend/dist ./frontend/dist
23
 
24
- EXPOSE 8080
 
25
 
26
- CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080"]
 
21
  # Copy built frontend
22
  COPY --from=frontend-build /app/frontend/dist ./frontend/dist
23
 
24
+ ENV PORT=7860
25
+ EXPOSE $PORT
26
 
27
+ CMD uvicorn backend.main:app --host 0.0.0.0 --port $PORT
README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Flight Search
3
+ emoji: ✈️
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
backend/api/auth.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authentication endpoints for passkey gate."""
2
+
3
+ import os
4
+ import secrets
5
+
6
+ from fastapi import APIRouter, Request, Response
7
+ from fastapi.responses import JSONResponse
8
+ from pydantic import BaseModel
9
+
10
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
11
+
12
+ PASSKEY = "flyingagents"
13
+ # Token written into the HTTP-only cookie after successful verification.
14
+ AUTH_TOKEN = secrets.token_hex(32)
15
+
16
+
17
+ def _require_passkey() -> bool:
18
+ """Return True unless REQUIRE_PASSKEY is explicitly set to 'false'."""
19
+ return os.environ.get("REQUIRE_PASSKEY", "true").lower() != "false"
20
+
21
+
22
+ class PasskeyBody(BaseModel):
23
+ passkey: str
24
+
25
+
26
+ @router.post("/verify")
27
+ async def verify(body: PasskeyBody, response: Response):
28
+ if not _require_passkey():
29
+ response.set_cookie(
30
+ key="flight_auth",
31
+ value=AUTH_TOKEN,
32
+ httponly=True,
33
+ samesite="lax",
34
+ max_age=60 * 60 * 24 * 30, # 30 days
35
+ )
36
+ return {"ok": True}
37
+
38
+ if body.passkey != PASSKEY:
39
+ return JSONResponse(status_code=401, content={"detail": "Invalid passkey"})
40
+
41
+ response.set_cookie(
42
+ key="flight_auth",
43
+ value=AUTH_TOKEN,
44
+ httponly=True,
45
+ samesite="lax",
46
+ max_age=60 * 60 * 24 * 30, # 30 days
47
+ )
48
+ return {"ok": True}
49
+
50
+
51
+ @router.get("/check")
52
+ async def check(request: Request):
53
+ if not _require_passkey():
54
+ return {"ok": True}
55
+
56
+ token = request.cookies.get("flight_auth")
57
+ if token != AUTH_TOKEN:
58
+ return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
59
+ return {"ok": True}
backend/main.py CHANGED
@@ -3,12 +3,13 @@
3
  import os
4
  import time
5
 
6
- from fastapi import FastAPI
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from fastapi.staticfiles import StaticFiles
9
- from fastapi.responses import FileResponse
 
10
 
11
- from .api import airports, calendar, search
12
  from .data_loader import get_route_graph
13
  from .hub_detector import compute_hub_scores
14
 
@@ -23,7 +24,31 @@ app.add_middleware(
23
  allow_headers=["*"],
24
  )
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  # Register API routers
 
27
  app.include_router(airports.router)
28
  app.include_router(search.router)
29
  app.include_router(calendar.router)
 
3
  import os
4
  import time
5
 
6
+ from fastapi import FastAPI, Request
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from fastapi.staticfiles import StaticFiles
9
+ from fastapi.responses import FileResponse, JSONResponse
10
+ from starlette.middleware.base import BaseHTTPMiddleware
11
 
12
+ from .api import airports, auth, calendar, search
13
  from .data_loader import get_route_graph
14
  from .hub_detector import compute_hub_scores
15
 
 
24
  allow_headers=["*"],
25
  )
26
 
27
+ # Passkey middleware — protects /api/* routes when REQUIRE_PASSKEY is enabled
28
+ class PasskeyMiddleware(BaseHTTPMiddleware):
29
+ EXEMPT_PREFIXES = ("/api/auth/", "/api/health")
30
+
31
+ async def dispatch(self, request: Request, call_next):
32
+ if not auth._require_passkey():
33
+ return await call_next(request)
34
+
35
+ path = request.url.path
36
+ if path.startswith("/api/") and not any(
37
+ path.startswith(p) for p in self.EXEMPT_PREFIXES
38
+ ):
39
+ token = request.cookies.get("flight_auth")
40
+ if token != auth.AUTH_TOKEN:
41
+ return JSONResponse(
42
+ status_code=401, content={"detail": "Not authenticated"}
43
+ )
44
+
45
+ return await call_next(request)
46
+
47
+
48
+ app.add_middleware(PasskeyMiddleware)
49
+
50
  # Register API routers
51
+ app.include_router(auth.router)
52
  app.include_router(airports.router)
53
  app.include_router(search.router)
54
  app.include_router(calendar.router)
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
+
18
+ ```js
19
+ export default defineConfig([
20
+ globalIgnores(['dist']),
21
+ {
22
+ files: ['**/*.{ts,tsx}'],
23
+ extends: [
24
+ // Other configs...
25
+
26
+ // Remove tseslint.configs.recommended and replace with this
27
+ tseslint.configs.recommendedTypeChecked,
28
+ // Alternatively, use this for stricter rules
29
+ tseslint.configs.strictTypeChecked,
30
+ // Optionally, add this for stylistic rules
31
+ tseslint.configs.stylisticTypeChecked,
32
+
33
+ // Other configs...
34
+ ],
35
+ languageOptions: {
36
+ parserOptions: {
37
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
+ tsconfigRootDir: import.meta.dirname,
39
+ },
40
+ // other options...
41
+ },
42
+ },
43
+ ])
44
+ ```
45
+
46
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import reactX from 'eslint-plugin-react-x'
51
+ import reactDom from 'eslint-plugin-react-dom'
52
+
53
+ export default defineConfig([
54
+ globalIgnores(['dist']),
55
+ {
56
+ files: ['**/*.{ts,tsx}'],
57
+ extends: [
58
+ // Other configs...
59
+ // Enable lint rules for React
60
+ reactX.configs['recommended-typescript'],
61
+ // Enable lint rules for React DOM
62
+ reactDom.configs.recommended,
63
+ ],
64
+ languageOptions: {
65
+ parserOptions: {
66
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
+ tsconfigRootDir: import.meta.dirname,
68
+ },
69
+ // other options...
70
+ },
71
+ },
72
+ ])
73
+ ```
frontend/src/App.tsx CHANGED
@@ -1,9 +1,28 @@
 
1
  import { BrowserRouter, Route, Routes } from 'react-router-dom';
2
  import Header from './components/shared/Header';
 
3
  import SearchPage from './pages/SearchPage';
4
  import ResultsPage from './pages/ResultsPage';
5
 
6
  export default function App() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  return (
8
  <BrowserRouter>
9
  <Header />
 
1
+ import { useEffect, useState } from 'react';
2
  import { BrowserRouter, Route, Routes } from 'react-router-dom';
3
  import Header from './components/shared/Header';
4
+ import PasskeyGate from './components/shared/PasskeyGate';
5
  import SearchPage from './pages/SearchPage';
6
  import ResultsPage from './pages/ResultsPage';
7
 
8
  export default function App() {
9
+ const [authenticated, setAuthenticated] = useState<boolean | null>(null);
10
+
11
+ useEffect(() => {
12
+ fetch('/api/auth/check')
13
+ .then((res) => setAuthenticated(res.ok))
14
+ .catch(() => setAuthenticated(false));
15
+ }, []);
16
+
17
+ // Still checking auth status
18
+ if (authenticated === null) {
19
+ return null;
20
+ }
21
+
22
+ if (!authenticated) {
23
+ return <PasskeyGate onAuthenticated={() => setAuthenticated(true)} />;
24
+ }
25
+
26
  return (
27
  <BrowserRouter>
28
  <Header />
frontend/src/components/results/FlightCard.tsx CHANGED
@@ -78,22 +78,33 @@ export default function FlightCard({ flight, roundTripPrice, priceLabel, onSelec
78
  </div>
79
  </div>
80
 
81
- {/* Price column */}
82
- <div className="text-right pl-3 min-w-[80px]" data-testid="price">
83
- {roundTripPrice != null ? (
84
- <>
85
- <div className="text-[15px] font-medium text-gray-900">{formatPrice(roundTripPrice)}</div>
86
- <div className="text-[11px] text-gray-500">{priceLabel || 'round trip'}</div>
87
- </>
88
- ) : (
89
- <>
90
- <div className="text-[15px] font-medium text-gray-900">{formatPrice(flight.price_usd)}</div>
91
- <div className="text-[11px] text-gray-500">{flight.cabin_class.replace('_', ' ')}</div>
92
- </>
93
- )}
94
- {discountApplied && (
95
- <div className="text-[10px] text-green-600 mt-0.5">Airline discount</div>
96
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  </div>
98
 
99
  {/* Chevron */}
@@ -105,19 +116,6 @@ export default function FlightCard({ flight, roundTripPrice, priceLabel, onSelec
105
  </svg>
106
  </div>
107
 
108
- {/* Select flight button */}
109
- {onSelect && (
110
- <div className="px-4 pb-3 -mt-1">
111
- <button
112
- onClick={(e) => { e.stopPropagation(); onSelect(flight); }}
113
- className="w-full rounded-full bg-[#1a73e8] px-4 py-2 text-sm font-medium text-white hover:bg-[#1557b0] cursor-pointer"
114
- data-testid="select-flight-btn"
115
- >
116
- Select flight
117
- </button>
118
- </div>
119
- )}
120
-
121
  {/* Expanded detail view */}
122
  {expanded && (
123
  <div className="border-t border-gray-100 px-4 pt-3 pb-4" data-testid="segments-detail">
@@ -262,6 +260,7 @@ export default function FlightCard({ flight, roundTripPrice, priceLabel, onSelec
262
  </>
263
  )}
264
  </div>
 
265
  </div>
266
  )}
267
  </div>
 
78
  </div>
79
  </div>
80
 
81
+ {/* Select button + Price column */}
82
+ <div className="flex items-center gap-3 pl-3">
83
+ {onSelect && expanded && (
84
+ <button
85
+ onClick={(e) => { e.stopPropagation(); onSelect(flight); }}
86
+ className="rounded-full bg-[#1a73e8] px-4 py-1.5 text-xs font-medium text-white hover:bg-[#1557b0] cursor-pointer whitespace-nowrap"
87
+ data-testid="select-flight-btn"
88
+ >
89
+ Select flight
90
+ </button>
 
 
 
 
 
91
  )}
92
+ <div className="text-right min-w-[80px]" data-testid="price">
93
+ {roundTripPrice != null ? (
94
+ <>
95
+ <div className="text-[15px] font-medium text-gray-900">{formatPrice(roundTripPrice)}</div>
96
+ <div className="text-[11px] text-gray-500">{priceLabel || 'round trip'}</div>
97
+ </>
98
+ ) : (
99
+ <>
100
+ <div className="text-[15px] font-medium text-gray-900">{formatPrice(flight.price_usd)}</div>
101
+ <div className="text-[11px] text-gray-500">{flight.cabin_class.replace('_', ' ')}</div>
102
+ </>
103
+ )}
104
+ {discountApplied && (
105
+ <div className="text-[10px] text-green-600 mt-0.5">Airline discount</div>
106
+ )}
107
+ </div>
108
  </div>
109
 
110
  {/* Chevron */}
 
116
  </svg>
117
  </div>
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  {/* Expanded detail view */}
120
  {expanded && (
121
  <div className="border-t border-gray-100 px-4 pt-3 pb-4" data-testid="segments-detail">
 
260
  </>
261
  )}
262
  </div>
263
+
264
  </div>
265
  )}
266
  </div>
frontend/src/components/shared/PasskeyGate.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, type FormEvent } from 'react';
2
+
3
+ interface Props {
4
+ onAuthenticated: () => void;
5
+ }
6
+
7
+ export default function PasskeyGate({ onAuthenticated }: Props) {
8
+ const [passkey, setPasskey] = useState('');
9
+ const [error, setError] = useState('');
10
+ const [loading, setLoading] = useState(false);
11
+
12
+ async function handleSubmit(e: FormEvent) {
13
+ e.preventDefault();
14
+ setError('');
15
+ setLoading(true);
16
+
17
+ try {
18
+ const res = await fetch('/api/auth/verify', {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({ passkey }),
22
+ });
23
+
24
+ if (res.ok) {
25
+ onAuthenticated();
26
+ } else {
27
+ setError('Invalid passkey');
28
+ setPasskey('');
29
+ }
30
+ } catch {
31
+ setError('Connection error. Please try again.');
32
+ } finally {
33
+ setLoading(false);
34
+ }
35
+ }
36
+
37
+ return (
38
+ <div className="min-h-screen flex items-center justify-center bg-gray-50">
39
+ <div className="w-full max-w-sm mx-4">
40
+ <div className="text-center mb-8">
41
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" className="mx-auto mb-4 text-[#1a73e8]">
42
+ <path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" fill="currentColor"/>
43
+ </svg>
44
+ <h1 className="text-2xl font-medium text-gray-900">Flights</h1>
45
+ <p className="text-sm text-gray-500 mt-1">Enter passkey to continue</p>
46
+ </div>
47
+
48
+ <form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
49
+ <input
50
+ type="password"
51
+ value={passkey}
52
+ onChange={(e) => setPasskey(e.target.value)}
53
+ placeholder="Passkey"
54
+ autoFocus
55
+ className="w-full px-4 py-3 rounded-lg border border-gray-300 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#1a73e8] focus:border-transparent"
56
+ />
57
+
58
+ {error && (
59
+ <p className="mt-3 text-sm text-red-600">{error}</p>
60
+ )}
61
+
62
+ <button
63
+ type="submit"
64
+ disabled={loading || !passkey}
65
+ className="mt-4 w-full py-3 rounded-lg bg-[#1a73e8] text-white font-medium hover:bg-[#1557b0] disabled:opacity-50 disabled:cursor-not-allowed transition-colors cursor-pointer"
66
+ >
67
+ {loading ? 'Verifying...' : 'Continue'}
68
+ </button>
69
+ </form>
70
+ </div>
71
+ </div>
72
+ );
73
+ }