Add authenticated dashboard and route deployment through Kong
This commit is contained in:
3
defense_sim_v2/.gitignore
vendored
3
defense_sim_v2/.gitignore
vendored
@@ -3,3 +3,6 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
deploy/supabase/.env
|
||||
deploy/supabase/kong.json.local
|
||||
deploy/auth.env.local
|
||||
|
||||
3
defense_sim_v2/DEMO_USER.txt
Normal file
3
defense_sim_v2/DEMO_USER.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
URL: https://defense.naori.cloud
|
||||
Username: demo@defense.naori.cloud
|
||||
Password: Udm3W4tHvEcj1qKGi0oCdCNN
|
||||
@@ -30,11 +30,16 @@ Drone navigation defaults to 10 Hz, flight control to 50 Hz, and actuator accele
|
||||
|
||||
`src/defense_sim` contains transport-independent simulation modules. The `server` package only adapts simulation state and events to Socket.IO. `tests` focuses on physical invariants and reproducibility. See `PROJECT_SPEC.md` for model assumptions and limitations.
|
||||
|
||||
The Vue 3 + Vite client in `frontend/` connects with `socket.io-client`, renders backend entity positions and trajectory history in Three.js, provides scenario and device configuration, and displays bounded terminal-style telemetry and event scrollback. Snapshot persistence uses a Supabase migration for per-user row-level security. Configure `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` before applying `frontend/supabase/migrations/20260930000000_simulation_runs.sql`.
|
||||
The Vue 3 + Vite client in `frontend/` connects with `socket.io-client`, renders backend entity positions and trajectory history in Three.js, provides scenario and device configuration, and displays bounded terminal-style telemetry and event scrollback. Sign-in is required before opening the simulator. Socket.IO validates Supabase access tokens on connection and before start/stop commands. Snapshot persistence uses per-user row-level security.
|
||||
|
||||
## Run the server
|
||||
|
||||
The backend requires `SUPABASE_URL` and `SUPABASE_ANON_KEY`. The rebuild script configures these automatically. For manual development, load `deploy/auth.env.local` into the environment before starting the backend.
|
||||
|
||||
```bash
|
||||
set -a
|
||||
source deploy/auth.env.local
|
||||
set +a
|
||||
python -m defense_sim.server.socketio_server
|
||||
```
|
||||
|
||||
@@ -42,12 +47,18 @@ Socket.IO emits `simulation_state`, `simulation_event`, `simulation_statistics`,
|
||||
|
||||
## Deployment
|
||||
|
||||
The site is served at `https://defense.naori.cloud`. The Vue app listens on `192.168.1.194:8088`; the Python Socket.IO backend listens on `192.168.1.194:8000`. Traefik at `192.168.1.10` routes the site and `/socket.io` over HTTPS. User services are defined under `deploy/` and enabled with systemd lingering.
|
||||
The site is served at `https://defense.naori.cloud`. Traefik in LXC 100 routes all site traffic to Kong at `192.168.1.194:54321`. Kong routes `/auth/v1/` to Supabase Auth, `/rest/v1/` to PostgREST, `/socket.io/` to the Python backend, and all other paths to the internal Nginx frontend container. The frontend has no separately exposed port.
|
||||
|
||||
Rebuild the client with:
|
||||
Start or rebuild the application and its self-hosted auth stack with:
|
||||
|
||||
```bash
|
||||
VITE_SIM_SOCKET_URL=https://defense.naori.cloud pnpm --dir frontend build
|
||||
./rebuild_restart.sh
|
||||
```
|
||||
|
||||
Restart backend changes with `systemctl --user restart defense-sim-backend.service`. Traefik's watched dynamic route is `/etc/traefik/conf.d/defense-sim-v2.yml` in LXC 100.
|
||||
Docker Compose starts the Postgres, Supabase Auth, PostgREST, Kong, and Nginx frontend services in `deploy/supabase/compose.yml`. This uses the components described in [Supabase's Docker self-hosting guide](https://supabase.com/docs/guides/self-hosting/docker). The script generates unique secrets on first run, waits for health checks, applies archive migrations, creates a confirmed demo account, verifies password login, builds the frontend, restarts Kong and Nginx, and updates/restarts the backend user service. The legacy frontend service on port 8088 is disabled. Repeating the command preserves the database, secrets, and demo password. Public signup and anonymous accounts are disabled.
|
||||
|
||||
The demo email is `demo@defense.naori.cloud`. Demo login credentials are recorded in `DEMO_USER.txt` as requested; the runtime copy is stored in `deploy/supabase/.env`. This file and the generated backend environment and gateway configuration are gitignored. Do not commit them. Kong exposes port 54321 for Traefik. Nginx and the database have no published ports. Nginx serves `/login` directly so refreshes work. Database volumes persist independently of frontend rebuilds. No database port is exposed.
|
||||
|
||||
For frontend development, configure `VITE_SUPABASE_URL=http://localhost:54321` and copy the generated `SUPABASE_ANON_KEY` into `VITE_SUPABASE_ANON_KEY` in `frontend/.env.local`. Keep the service-role key server-side. In production the rebuild script supplies the public anon key and uses the site origin for API requests.
|
||||
|
||||
The Traefik route in `deploy/traefik-defense-sim-v2.yml` serves `https://defense.naori.cloud` entirely through Kong at `192.168.1.194:54321`. Its watched dynamic route is `/etc/traefik/conf.d/defense-sim-v2.yml` in LXC 100. The Supabase stack restarts automatically with Docker; the backend user service is enabled at boot with systemd lingering.
|
||||
|
||||
@@ -6,9 +6,10 @@ Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/dre/git/plfm/defense_sim_v2
|
||||
EnvironmentFile=/home/dre/git/plfm/defense_sim_v2/deploy/auth.env.local
|
||||
Environment=SIM_BIND_HOST=0.0.0.0
|
||||
Environment=SIM_PORT=8000
|
||||
Environment=SIM_ALLOWED_ORIGINS=https://defense.naori.cloud,http://localhost:3333
|
||||
Environment=SIM_ALLOWED_ORIGINS=https://defense.naori.cloud,http://localhost:3333,http://localhost:8088,http://127.0.0.1:8088
|
||||
ExecStart=/home/dre/git/plfm/defense_sim_v2/.venv/bin/python -m defense_sim.server.socketio_server
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[Unit]
|
||||
Description=Defense Sim V2 static Vue frontend
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/dre/git/plfm/defense_sim_v2/frontend
|
||||
ExecStart=/usr/bin/python3 -m http.server 8088 --bind 0.0.0.0 --directory /home/dre/git/plfm/defense_sim_v2/frontend/dist
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
94
defense_sim_v2/deploy/supabase/compose.yml
Normal file
94
defense_sim_v2/deploy/supabase/compose.yml
Normal file
@@ -0,0 +1,94 @@
|
||||
name: defense-sim-auth
|
||||
services:
|
||||
db:
|
||||
image: supabase/postgres:17.6.1.136
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Run scripts/bootstrap_auth.py first}
|
||||
POSTGRES_DB: postgres
|
||||
JWT_EXP: 3600
|
||||
volumes:
|
||||
- database:/var/lib/postgresql/data
|
||||
- db-config:/etc/postgresql-custom
|
||||
- ./roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:ro
|
||||
- ../../frontend/supabase/migrations:/app-migrations:ro
|
||||
- ./migrate.sql:/app-migrate.sql:ro
|
||||
healthcheck:
|
||||
test: [CMD, pg_isready, -U, postgres, -h, localhost]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
command: [postgres, -c, config_file=/etc/postgresql/postgresql.conf, -c, log_min_messages=warning]
|
||||
auth:
|
||||
image: supabase/gotrue:v2.196.0
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
GOTRUE_API_HOST: 0.0.0.0
|
||||
GOTRUE_API_PORT: 9999
|
||||
API_EXTERNAL_URL: ${SITE_URL}/auth/v1
|
||||
GOTRUE_DB_DRIVER: postgres
|
||||
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgres
|
||||
GOTRUE_SITE_URL: ${SITE_URL}
|
||||
GOTRUE_DISABLE_SIGNUP: "true"
|
||||
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||
GOTRUE_JWT_AUD: authenticated
|
||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||
GOTRUE_JWT_EXP: 3600
|
||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
|
||||
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "false"
|
||||
GOTRUE_MAILER_AUTOCONFIRM: "true"
|
||||
GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
|
||||
healthcheck:
|
||||
test: [CMD, wget, --no-verbose, --tries=1, --spider, http://localhost:9999/health]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
rest:
|
||||
image: postgrest/postgrest:v14.17
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
auth:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/postgres
|
||||
PGRST_DB_SCHEMAS: public
|
||||
PGRST_DB_ANON_ROLE: anon
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||
gateway:
|
||||
image: kong:2.8.1
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
auth:
|
||||
condition: service_healthy
|
||||
rest:
|
||||
condition: service_started
|
||||
frontend:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${KONG_BIND_ADDRESS:-0.0.0.0}:${SUPABASE_PORT:-54321}:8000"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
KONG_DATABASE: "off"
|
||||
KONG_DECLARATIVE_CONFIG: /config/kong.json
|
||||
KONG_DNS_ORDER: LAST,A,CNAME
|
||||
KONG_PLUGINS: cors,key-auth,acl
|
||||
KONG_PROXY_ACCESS_LOG: /dev/stdout
|
||||
KONG_PROXY_ERROR_LOG: /dev/stderr
|
||||
KONG_ADMIN_LISTEN: "off"
|
||||
volumes:
|
||||
- ./kong.json.local:/config/kong.json:ro
|
||||
frontend:
|
||||
image: nginx:1.28-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ../../frontend/dist:/usr/share/nginx/html:ro
|
||||
- ./frontend.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
volumes:
|
||||
database:
|
||||
db-config:
|
||||
20
defense_sim_v2/deploy/supabase/frontend.conf
Normal file
20
defense_sim_v2/deploy/supabase/frontend.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri.html $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
}
|
||||
13
defense_sim_v2/deploy/supabase/migrate.sql
Normal file
13
defense_sim_v2/deploy/supabase/migrate.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
\set ON_ERROR_STOP on
|
||||
BEGIN;
|
||||
CREATE SCHEMA IF NOT EXISTS app_migrations;
|
||||
CREATE TABLE IF NOT EXISTS app_migrations.applied (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now());
|
||||
SELECT NOT EXISTS (SELECT 1 FROM app_migrations.applied WHERE name = '20260930000000_simulation_runs') AS needs_migration \gset
|
||||
\if :needs_migration
|
||||
\ir /app-migrations/20260930000000_simulation_runs.sql
|
||||
GRANT USAGE ON SCHEMA public TO authenticated;
|
||||
GRANT SELECT, INSERT, DELETE ON public.simulation_runs TO authenticated;
|
||||
INSERT INTO app_migrations.applied(name) VALUES ('20260930000000_simulation_runs');
|
||||
\endif
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
COMMIT;
|
||||
4
defense_sim_v2/deploy/supabase/roles.sql
Normal file
4
defense_sim_v2/deploy/supabase/roles.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Match the official Supabase Docker role-password initialization.
|
||||
\set pgpass `echo "$POSTGRES_PASSWORD"`
|
||||
ALTER USER authenticator WITH PASSWORD :'pgpass';
|
||||
ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass';
|
||||
@@ -4,26 +4,13 @@ http:
|
||||
rule: "Host(`defense.naori.cloud`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: defense-sim-v2-web
|
||||
service: defense-sim-v2-kong
|
||||
priority: 10
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
defense-sim-v2-socketio:
|
||||
rule: "Host(`defense.naori.cloud`) && PathPrefix(`/socket.io`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: defense-sim-v2-socketio
|
||||
priority: 100
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
defense-sim-v2-web:
|
||||
defense-sim-v2-kong:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.1.194:8088"
|
||||
defense-sim-v2-socketio:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.1.194:8000"
|
||||
- url: "http://192.168.1.194:54321"
|
||||
|
||||
32
defense_sim_v2/frontend/cypress/e2e/auth.spec.ts
Normal file
32
defense_sim_v2/frontend/cypress/e2e/auth.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
context('Simulator authentication', () => {
|
||||
it('requires sign-in, opens the simulator with the demo account, and signs out', () => {
|
||||
cy.visit('/')
|
||||
cy.location('pathname').should('eq', '/login')
|
||||
cy.get('.signin-panel').should('be.visible')
|
||||
cy.get('#email').type(Cypress.env('DEMO_EMAIL'))
|
||||
cy.get('#password').type('incorrect-password', { log: false })
|
||||
cy.get('button[type=submit]').click()
|
||||
cy.get('[role=alert]').should('be.visible')
|
||||
cy.location('pathname').should('eq', '/login')
|
||||
cy.get('#password').clear().type(Cypress.env('DEMO_PASSWORD'), { log: false })
|
||||
cy.get('button[type=submit]').click()
|
||||
cy.location('pathname').should('eq', '/')
|
||||
cy.contains('LINK ONLINE').should('be.visible')
|
||||
cy.get('.scene-canvas').should('be.visible')
|
||||
cy.get('.sidebar-toggle').click()
|
||||
cy.get('#scenario-sidebar').should('not.be.visible')
|
||||
cy.get('.sidebar-toggle').click()
|
||||
cy.get('#scenario-sidebar').should('be.visible')
|
||||
cy.get('#tab-telemetry').click()
|
||||
cy.get('#view-telemetry').should('be.visible')
|
||||
cy.get('#view-scene').should('not.be.visible')
|
||||
cy.get('#tab-scene').click()
|
||||
cy.get('#view-scene').should('be.visible')
|
||||
cy.contains('button', 'SIGN OUT').click()
|
||||
cy.location('pathname').should('eq', '/login')
|
||||
cy.visit('/')
|
||||
cy.location('pathname').should('eq', '/login')
|
||||
cy.reload()
|
||||
cy.get('.signin-panel').should('be.visible')
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { AmbientLight, BufferGeometry, ConeGeometry, GridHelper, Group, Line, Li
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||
import type { SimulationEntity, SimulationState } from '~/types/simulation'
|
||||
|
||||
const props = defineProps<{ state: SimulationState | null }>()
|
||||
const props = withDefaults(defineProps<{ state: SimulationState | null, altitudeScale?: number }>(), { altitudeScale: 1 })
|
||||
const host = ref<HTMLDivElement>()
|
||||
let renderer: WebGLRenderer
|
||||
let camera: PerspectiveCamera
|
||||
@@ -17,7 +17,7 @@ const up = new Vector3(0, 1, 0)
|
||||
const color = { projectile: 0xff6258, drone: 0x5ca8ff }
|
||||
|
||||
function groundPoint(ned: number[]) {
|
||||
return new Vector3(ned[1] || 0, -(ned[2] || 0), -(ned[0] || 0))
|
||||
return new Vector3(ned[1] || 0, -(ned[2] || 0) * props.altitudeScale, -(ned[0] || 0))
|
||||
}
|
||||
|
||||
function makeBody(entity: SimulationEntity) {
|
||||
@@ -87,6 +87,11 @@ function updateScene(state: SimulationState | null) {
|
||||
controls.update()
|
||||
}
|
||||
|
||||
// Refresh radar heights without resetting the orbit camera when the visual scale changes.
|
||||
const guides = scene.getObjectByName('scenario-guides')
|
||||
for (const site of radarSites)
|
||||
guides?.getObjectByName(site.id)?.position.copy(groundPoint(site.position_ned_m))
|
||||
|
||||
const currentIds = new Set((state?.entities ?? []).map(entity => entity.id))
|
||||
for (const [id, item] of entities) {
|
||||
if (!currentIds.has(id)) {
|
||||
@@ -163,6 +168,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
watch(() => props.state, updateScene)
|
||||
watch(() => props.altitudeScale, () => updateScene(props.state))
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(frame)
|
||||
@@ -184,6 +190,6 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scene-host { width: 100%; height: 100%; min-height: 420px; }
|
||||
.scene-host { width: 100%; height: 100%; min-height: 0; }
|
||||
.scene-host :deep(canvas) { display: block; width: 100%; height: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Socket } from 'socket.io-client'
|
||||
import type { SimulationEvent, SimulationState } from '~/types/simulation'
|
||||
import { io } from 'socket.io-client'
|
||||
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import { supabase } from '~/lib/supabaseClient'
|
||||
|
||||
interface SimulationOptions {
|
||||
projectile_preset: string
|
||||
@@ -42,7 +43,16 @@ export function useSimulationSocket() {
|
||||
|| (import.meta.env.PROD ? window.location.origin : 'http://localhost:8000')
|
||||
// Start with polling so networks that block WebSocket still come online.
|
||||
// Socket.IO upgrades the established session to WebSocket when available.
|
||||
socket = io(url, { autoConnect: true, transports: ['polling', 'websocket'], tryAllTransports: true })
|
||||
socket = io(url, {
|
||||
autoConnect: true,
|
||||
transports: ['polling', 'websocket'],
|
||||
tryAllTransports: true,
|
||||
auth: (callback) => {
|
||||
void supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
callback({ token: session?.access_token ?? '' })
|
||||
}).catch(() => callback({ token: '' }))
|
||||
},
|
||||
})
|
||||
socket.on('connect', () => {
|
||||
connected.value = true
|
||||
error.value = ''
|
||||
@@ -80,13 +90,22 @@ export function useSimulationSocket() {
|
||||
|
||||
onUnmounted(() => socket?.disconnect())
|
||||
|
||||
function start(options: SimulationOptions) {
|
||||
async function sendCommand(name: string, options = {}) {
|
||||
error.value = ''
|
||||
socket?.emit('simulation_start', options)
|
||||
const { data: { session }, error: sessionError } = await supabase.auth.getSession()
|
||||
if (sessionError || !session) {
|
||||
error.value = 'Session expired; sign in again'
|
||||
return
|
||||
}
|
||||
socket?.emit(name, { ...options, token: session.access_token })
|
||||
}
|
||||
|
||||
function start(options: SimulationOptions) {
|
||||
void sendCommand('simulation_start', options)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
socket?.emit('simulation_stop')
|
||||
void sendCommand('simulation_stop')
|
||||
}
|
||||
|
||||
return { state, events, connected, running, error, start, stop }
|
||||
|
||||
@@ -15,10 +15,13 @@ declare global {
|
||||
}
|
||||
|
||||
function getSupabaseUrl(): string {
|
||||
const configured = (import.meta.env.VITE_SUPABASE_URL as string | undefined)?.trim()
|
||||
if (configured)
|
||||
return configured.replace(TRAILING_SLASH_RE, '')
|
||||
if (typeof window !== 'undefined' && import.meta.env.PROD)
|
||||
return window.location.origin.replace(TRAILING_SLASH_RE, '')
|
||||
|
||||
const raw = (import.meta.env.VITE_SUPABASE_URL as string | undefined)?.trim() || 'http://localhost:54321'
|
||||
const raw = 'http://localhost:54321'
|
||||
return raw.replace(TRAILING_SLASH_RE, '')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<template>
|
||||
<div class="bg-background flex min-h-screen items-center justify-center">
|
||||
<RouterView />
|
||||
</div>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
/** Staff auth flows default here when no explicit `redirect` query is provided. */
|
||||
export const DEFAULT_POST_LOGIN_PATH = '/dashboard'
|
||||
export const DEFAULT_POST_LOGIN_PATH = '/'
|
||||
|
||||
export function stringQuery(q: unknown): string {
|
||||
if (typeof q === 'string')
|
||||
@@ -28,14 +28,14 @@ export function sanitizePostLoginRedirect(raw: string): string {
|
||||
const search = qIdx === -1 ? '' : noHash.slice(qIdx)
|
||||
const segments = pathPart.split('/').filter(Boolean)
|
||||
const pl = pathPart.toLowerCase()
|
||||
if (pl === '/login' || pl === '/login/')
|
||||
return DEFAULT_POST_LOGIN_PATH
|
||||
if (segments[0] === 'auth')
|
||||
return DEFAULT_POST_LOGIN_PATH
|
||||
if (pl.includes('/auth/v1'))
|
||||
return DEFAULT_POST_LOGIN_PATH
|
||||
if (segments[0] === 'oauth' && segments[1] === 'callback')
|
||||
return DEFAULT_POST_LOGIN_PATH
|
||||
if (pathPart === '/' || segments.length === 0)
|
||||
return DEFAULT_POST_LOGIN_PATH
|
||||
return pathPart + search
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
import type { UserModule } from '~/types'
|
||||
import { onMounted } from 'vue'
|
||||
import { supabase } from '~/lib/supabaseClient'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export const install: UserModule = ({ isClient, app }) => {
|
||||
export const install: UserModule = ({ isClient, router }) => {
|
||||
if (!isClient)
|
||||
return
|
||||
|
||||
app.component('AuthStateListener', {
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
const auth = useAuthStore()
|
||||
auth.fetchUser()
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
const authStore = useAuthStore()
|
||||
if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
|
||||
authStore.setUser(session?.user ?? null)
|
||||
authStore.sessionError = false
|
||||
}
|
||||
else if (event === 'SIGNED_OUT') {
|
||||
authStore.setUser(null)
|
||||
}
|
||||
else if (event === 'USER_UPDATED') {
|
||||
authStore.setUser(session?.user ?? null)
|
||||
}
|
||||
if (event === 'TOKEN_REFRESHED' && !session) {
|
||||
authStore.sessionError = true
|
||||
}
|
||||
})
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(() => subscription.unsubscribe())
|
||||
}
|
||||
})
|
||||
return () => null
|
||||
},
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
|
||||
const authStore = useAuthStore()
|
||||
if (event === 'INITIAL_SESSION')
|
||||
return // The route guard verifies the initial user with the auth server.
|
||||
authStore.handleAuthStateChange(event, session?.user ?? null)
|
||||
if (event === 'SIGNED_OUT') {
|
||||
// Keep router work outside the Supabase auth callback's session lock.
|
||||
setTimeout(() => {
|
||||
if (router.currentRoute.value.meta.requiresAuth)
|
||||
void router.replace({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
if (import.meta.hot)
|
||||
import.meta.hot.dispose(() => subscription.unsubscribe())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { UserModule } from '~/types'
|
||||
import { isGoTrueBrowserPath, isSpaAuthNamespaceGarbage } from '~/lib/auth/authReservedPaths'
|
||||
import { sanitizePostLoginRedirect, stringQuery } from '~/lib/auth/inviteRedirect'
|
||||
import { supabase } from '~/lib/supabaseClient'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
@@ -14,16 +15,21 @@ export const install: UserModule = ({ router, isClient }) => {
|
||||
if (isSpaAuthNamespaceGarbage(to.path))
|
||||
return { path: '/', replace: true }
|
||||
|
||||
supabase.auth.stopAutoRefresh()
|
||||
|
||||
if (to.meta.requiresAuth) {
|
||||
if (to.meta.requiresAuth || to.path === '/login') {
|
||||
const authStore = useAuthStore()
|
||||
if (!authStore.isReady)
|
||||
await authStore.fetchUser()
|
||||
if (!authStore.isLoggedIn)
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn)
|
||||
return { path: '/login', query: { redirect: to.fullPath }, replace: true }
|
||||
if (to.path === '/login' && authStore.isLoggedIn)
|
||||
return { path: sanitizePostLoginRedirect(stringQuery(to.query.redirect)), replace: true }
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth)
|
||||
supabase.auth.startAutoRefresh()
|
||||
else
|
||||
supabase.auth.stopAutoRefresh()
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,14 +1,47 @@
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
requiresAuth: true
|
||||
</route>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Activity, Database, Radio, Save, Square, Wifi, WifiOff } from 'lucide-vue-next'
|
||||
import { Activity, Database, PanelLeftClose, PanelLeftOpen, Radio, Save, Square, Wifi, WifiOff } from 'lucide-vue-next'
|
||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import SimulationScene from '~/components/SimulationScene.vue'
|
||||
import { useSimulationSocket } from '~/composables/useSimulationSocket'
|
||||
import { saveSimulationSnapshot } from '~/lib/simulationRuns'
|
||||
import { useLogout } from '~/composables/useLogout'
|
||||
|
||||
const { state, events, connected, running, error, start, stop } = useSimulationSocket()
|
||||
const { logout } = useLogout()
|
||||
const config = ref({ projectile_preset: 'random', approach_distance_km: 35, entry_speed_m_s: 150, entry_altitude_m: 1500, defense_drone_count: 24, city_radius_km: 10, defense_ring_km: 8, wind_speed_m_s: 4, wind_direction_deg: 270, radar_model: 'aeris_10x', radar_station_count: 8, drone_link_update_rate_hz: 20, drone_link_one_way_latency_ms: 50, drone_link_latency_jitter_ms: 5, drone_link_bitrate_kbps: 250, drone_link_packet_loss_percent: 0, drone_link_compute_latency_ms: 10, drone_nav_update_rate_hz: 10, drone_nav_position_sigma_m: 2, drone_nav_velocity_sigma_m_s: 0.4, drone_control_rate_hz: 50, drone_actuator_time_constant_s: 0.25, drone_max_speed_m_s: 100 })
|
||||
const saveMessage = ref('')
|
||||
const saving = ref(false)
|
||||
const sidebarOpen = ref(true)
|
||||
const viewTabs = [
|
||||
{ id: 'scene', label: '3D SIMULATION' },
|
||||
{ id: 'telemetry', label: 'ENTITY TELEMETRY' },
|
||||
{ id: 'events', label: 'SYSTEM LOG' },
|
||||
] as const
|
||||
const activeView = ref<(typeof viewTabs)[number]['id']>('scene')
|
||||
function navigateTabs(event: KeyboardEvent) {
|
||||
const index = viewTabs.findIndex(tab => tab.id === activeView.value)
|
||||
let next = index
|
||||
if (event.key === 'ArrowRight') next = (index + 1) % viewTabs.length
|
||||
else if (event.key === 'ArrowLeft') next = (index + viewTabs.length - 1) % viewTabs.length
|
||||
else if (event.key === 'Home') next = 0
|
||||
else if (event.key === 'End') next = viewTabs.length - 1
|
||||
else return
|
||||
event.preventDefault()
|
||||
activeView.value = viewTabs[next].id
|
||||
const tabs = (event.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>('[role=tab]')
|
||||
tabs[next]?.focus()
|
||||
}
|
||||
const altitudeScales = [1, 10, 100] as const
|
||||
const altitudeScaleIndex = ref(0)
|
||||
const altitudeScale = computed(() => altitudeScales[altitudeScaleIndex.value])
|
||||
function cycleAltitudeScale() {
|
||||
altitudeScaleIndex.value = (altitudeScaleIndex.value + 1) % altitudeScales.length
|
||||
}
|
||||
const telemetryTerminal = ref<HTMLElement>()
|
||||
const eventTerminal = ref<HTMLElement>()
|
||||
interface TelemetryLine { key: string, time: number, id: string, type: string, north: number, east: number, alt: number, speed: number, error: number | null, status: string }
|
||||
@@ -101,18 +134,22 @@ useHead({ title: 'Defense Sim V2' })
|
||||
<main class="console-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<button type="button" class="sidebar-toggle" :aria-expanded="sidebarOpen" aria-controls="scenario-sidebar" :aria-label="sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'" :title="sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'" @click="sidebarOpen = !sidebarOpen">
|
||||
<PanelLeftClose v-if="sidebarOpen" :size="16" />
|
||||
<PanelLeftOpen v-else :size="16" />
|
||||
</button>
|
||||
<div class="brand-mark"><Activity :size="16" /></div>
|
||||
<div><div class="brand-name">DEFENSE SIM <span>V2</span></div><div class="brand-sub">REAL-WORLD PHYSICS SIMULATOR</div></div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="clock-label">RUN {{ state?.run_id?.slice(0, 8) ?? 'STANDBY' }}</span>
|
||||
<span class="connection"><Wifi v-if="connected" :size="14" class="ok" /><WifiOff v-else :size="14" class="warn" />{{ connected ? 'LINK ONLINE' : 'LINK OFFLINE' }}</span>
|
||||
<RouterLink to="/login" class="signin">SIGN IN / ARCHIVE</RouterLink>
|
||||
<button type="button" class="signin" @click="logout()">SIGN OUT</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="workspace">
|
||||
<aside class="control-rail">
|
||||
<div class="workspace" :class="{ 'sidebar-collapsed': !sidebarOpen }">
|
||||
<aside v-show="sidebarOpen" id="scenario-sidebar" class="control-rail">
|
||||
<div class="rail-heading"><span>SCENARIO CONFIGURATION</span><span class="live-dot" /></div>
|
||||
<fieldset :disabled="running" class="config-group">
|
||||
<legend>ATTACKER INITIAL CONDITIONS</legend>
|
||||
@@ -165,15 +202,10 @@ useHead({ title: 'Defense Sim V2' })
|
||||
<button class="save-button" :disabled="!state || saving" @click="saveRun"><Save :size="14" /> {{ saving ? 'ARCHIVING…' : 'ARCHIVE SNAPSHOT' }}</button>
|
||||
<p v-if="saveMessage" class="notice">{{ saveMessage }}</p>
|
||||
<p v-if="error" class="error-notice">{{ error }}</p>
|
||||
<div class="rail-foot"><Database :size="13" /> USER ARCHIVE REQUIRES AUTHENTICATION</div>
|
||||
<div class="rail-foot"><Database :size="13" /> USER ARCHIVE</div>
|
||||
</aside>
|
||||
|
||||
<section class="main-display">
|
||||
<div class="display-heading">
|
||||
<div><div class="eyebrow">LIVE OPERATIONAL PICTURE</div><h1>SIMULATION MONITOR</h1></div>
|
||||
<div class="run-state" :class="{ active: running }"><span />{{ running ? 'RUNNING' : state ? 'COMPLETE' : 'READY' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-strip">
|
||||
<div class="metric"><span>SIM TIME</span><strong>{{ num(state?.simulation_time_s, 2) }}<i> s</i></strong></div>
|
||||
<div class="metric"><span>THREATS</span><strong>{{ projectiles.length.toString().padStart(2, '0') }}<i> TRACKS</i></strong></div>
|
||||
@@ -182,27 +214,33 @@ useHead({ title: 'Defense Sim V2' })
|
||||
<div class="metric"><span>CLOSEST APPROACH</span><strong>{{ num(state?.statistics?.closest_approach_m, 1) }}<i> m</i></strong></div>
|
||||
</div>
|
||||
|
||||
<div class="scene-panel">
|
||||
<div class="panel-cap"><span>THREE-DIMENSIONAL SITUATIONAL VIEW</span><span>WORLD FRAME / NED · ALTITUDE EXAGGERATION NONE · DRAG TO ORBIT / WHEEL TO ZOOM</span></div>
|
||||
<SimulationScene :state="state" />
|
||||
<div class="scene-legend"><span><b class="swatch threat" /> THREAT</span><span><b class="swatch defense" /> DEFENSE AIRCRAFT</span><span><b class="swatch radar" /> AERIS RADAR</span><span><b class="swatch city" /> PROTECTED AREA</span><span><b class="swatch perimeter" /> DEFENSE PERIMETER</span></div>
|
||||
</div>
|
||||
|
||||
<section class="environment-strip">
|
||||
<div class="section-caption">ENVIRONMENTAL TELEMETRY</div>
|
||||
<div class="environment-grid">
|
||||
<div><span>AIR DENSITY</span><b>{{ num(environment.density_kg_m3, 4) }} kg/m³</b></div>
|
||||
<div><span>TEMPERATURE</span><b>{{ num(typeof environment.temperature_k === 'number' ? environment.temperature_k - 273.15 : undefined, 1) }} °C</b></div>
|
||||
<div><span>PRESSURE</span><b>{{ num(typeof environment.pressure_pa === 'number' ? environment.pressure_pa / 100 : undefined, 1) }} hPa</b></div>
|
||||
<div><span>WIND / NED</span><b>{{ Array.isArray(environment.wind_ned_m_s) ? (environment.wind_ned_m_s as number[]).map((v: number) => num(v, 1)).join(' / ') : '—' }} m/s</b></div>
|
||||
<div><span>OBSERVATIONS</span><b>{{ observations.length.toString().padStart(2, '0') }} TRACKS</b></div>
|
||||
<div><span>RADAR SCAN / FULL TURN</span><b>{{ num(radarProfile?.full_azimuth_revisit_s, 2) }} s</b></div>
|
||||
<div class="view-panel">
|
||||
<div class="panel-cap view-toolbar">
|
||||
<div class="view-tabs" role="tablist" aria-label="Simulation views" @keydown="navigateTabs">
|
||||
<button v-for="tab in viewTabs" :id="`tab-${tab.id}`" :key="tab.id" type="button" class="view-button" role="tab" :aria-selected="activeView === tab.id" :aria-controls="`view-${tab.id}`" :tabindex="activeView === tab.id ? 0 : -1" @click="activeView = tab.id">{{ tab.label }}</button>
|
||||
</div>
|
||||
<button v-show="activeView === 'scene'" type="button" class="altitude-button" title="Cycle altitude exaggeration: 1×, 10×, 100×" @click="cycleAltitudeScale">ALTITUDE EXAGGERATION {{ altitudeScale }}×</button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-show="activeView === 'scene'" id="view-scene" class="view-content scene-panel" role="tabpanel" aria-labelledby="tab-scene">
|
||||
<SimulationScene :state="state" :altitude-scale="altitudeScale" />
|
||||
<div class="run-state" :class="{ active: running }" role="status"><span />{{ running ? 'RUNNING' : state ? 'COMPLETE' : 'READY' }}</div>
|
||||
<div class="scene-legend"><span><b class="swatch threat" /> THREAT</span><span><b class="swatch defense" /> DEFENSE AIRCRAFT</span><span><b class="swatch radar" /> AERIS RADAR</span><span><b class="swatch city" /> PROTECTED AREA</span><span><b class="swatch perimeter" /> DEFENSE PERIMETER</span></div>
|
||||
</section>
|
||||
|
||||
<div class="terminal-grid">
|
||||
<section class="terminal-panel">
|
||||
<div class="panel-cap"><span>ENTITY TELEMETRY / LIVE FEED</span><span>{{ entities.length }} OBJECTS · {{ telemetryHistory.length }} SCROLLBACK RECORDS</span></div>
|
||||
<section v-show="activeView === 'telemetry'" id="view-telemetry" class="view-content terminal-panel" role="tabpanel" aria-labelledby="tab-telemetry">
|
||||
<section class="environment-strip">
|
||||
<div class="section-caption">ENVIRONMENTAL TELEMETRY</div>
|
||||
<div class="environment-grid">
|
||||
<div><span>AIR DENSITY</span><b>{{ num(environment.density_kg_m3, 4) }} kg/m³</b></div>
|
||||
<div><span>TEMPERATURE</span><b>{{ num(typeof environment.temperature_k === 'number' ? environment.temperature_k - 273.15 : undefined, 1) }} °C</b></div>
|
||||
<div><span>PRESSURE</span><b>{{ num(typeof environment.pressure_pa === 'number' ? environment.pressure_pa / 100 : undefined, 1) }} hPa</b></div>
|
||||
<div><span>WIND / NED</span><b>{{ Array.isArray(environment.wind_ned_m_s) ? (environment.wind_ned_m_s as number[]).map((v: number) => num(v, 1)).join(' / ') : '—' }} m/s</b></div>
|
||||
<div><span>OBSERVATIONS</span><b>{{ observations.length.toString().padStart(2, '0') }} TRACKS</b></div>
|
||||
<div><span>RADAR SCAN / FULL TURN</span><b>{{ num(radarProfile?.full_azimuth_revisit_s, 2) }} s</b></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="panel-cap"><span>LIVE FEED</span><span>{{ entities.length }} OBJECTS · {{ telemetryHistory.length }} SCROLLBACK RECORDS</span></div>
|
||||
<div ref="telemetryTerminal" class="terminal-body telemetry-body" aria-live="polite">
|
||||
<div class="terminal-header">SIM TIME ENTITY / TYPE N [m] E [m] ALT [m] V [m/s] σP [m] STATE</div>
|
||||
<div v-for="row in telemetryHistory" :key="row.key" class="terminal-line">
|
||||
@@ -215,8 +253,8 @@ useHead({ title: 'Defense Sim V2' })
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="terminal-panel">
|
||||
<div class="panel-cap"><span>SIMULATION EVENTS / SYSTEM LOG</span><span>{{ events.length.toString().padStart(3, '0') }} RECORDS</span></div>
|
||||
<section v-show="activeView === 'events'" id="view-events" class="view-content terminal-panel" role="tabpanel" aria-labelledby="tab-events">
|
||||
<div class="panel-cap"><span>SIMULATION EVENTS</span><span>{{ events.length.toString().padStart(3, '0') }} RECORDS</span></div>
|
||||
<div ref="eventTerminal" class="terminal-body event-body" aria-live="polite">
|
||||
<div v-for="(event, index) in events" :key="`${event.type}-${event.time_s}-${index}`" class="event-line">
|
||||
<span class="term-time">{{ num(event.time_s, 2).padStart(8) }}</span><span class="event-tag">{{ event.type.toUpperCase().replaceAll('_', ' ') }}</span>
|
||||
@@ -231,19 +269,25 @@ useHead({ title: 'Defense Sim V2' })
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="screen-foot"><span>PHYSICS 100 Hz · CONTROL / SENSOR / LINK TIMING CONFIGURATION REQUIRED</span><span>STATE AUTHORITY / SIMULATION BACKEND</span></footer>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-toggle { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 1px solid #46564b; background: transparent; color: #bdcaad; cursor: pointer; }
|
||||
.sidebar-toggle:hover { background: #243127; border-color: #9bb66d; }
|
||||
.sidebar-toggle:focus-visible { outline: 2px solid #9bb66d; outline-offset: 2px; }
|
||||
.workspace.sidebar-collapsed { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
|
||||
.altitude-button, .view-button { border: 1px solid #46564b; background: transparent; color: #bdcaad; padding: 3px 7px; font: inherit; letter-spacing: inherit; cursor: pointer; }
|
||||
.altitude-button:hover, .view-button:hover { background: #243127; border-color: #9bb66d; }
|
||||
.altitude-button:focus-visible, .view-button:focus-visible { outline: 2px solid #9bb66d; outline-offset: 2px; }
|
||||
.console-shell { height: 100vh; height: 100dvh; display: flex; flex-direction: column; overflow: hidden; background: #0b100f; color: #dce5df; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.topbar { flex: 0 0 58px; height: 58px; padding: 0 20px; display: flex; align-items: center; justify-content: space-between; background: #111916; border-bottom: 1px solid #303d35; }
|
||||
.brand, .header-right, .connection, .brand-mark { display: flex; align-items: center; }
|
||||
.brand { gap: 10px; }.brand-mark { width: 30px; height: 30px; justify-content: center; color: #b4cb8e; border: 1px solid #60734b; background: #1e2a1c; }
|
||||
.brand-name { font-size: 13px; font-weight: 700; letter-spacing: .08em; }.brand-name span { color: #b4cb8e; }.brand-sub { color: #85948a; font-size: 9px; letter-spacing: .14em; margin-top: 2px; }
|
||||
.header-right { gap: 20px; color: #9aa99f; font-size: 10px; }.connection { gap: 7px; }.ok { color: #a9c87a; }.warn { color: #ddac64; }.signin { color: #c4d1c8; border: 1px solid #46564b; padding: 6px 9px; text-decoration: none; }.signin:hover { background: #1c2821; }
|
||||
.header-right { gap: 20px; color: #9aa99f; font-size: 10px; }.connection { gap: 7px; }.ok { color: #a9c87a; }.warn { color: #ddac64; }.signin { background: transparent; font: inherit; cursor: pointer; color: #c4d1c8; border: 1px solid #46564b; padding: 6px 9px; text-decoration: none; }.signin:hover { background: #1c2821; }
|
||||
.workspace { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: 260px minmax(0, 1fr); overflow: hidden; }
|
||||
.control-rail { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-color: #435147 #101714; scrollbar-width: thin; border-right: 1px solid #303d35; background: #101713; padding: 15px 13px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.rail-heading, .panel-cap { display: flex; align-items: center; justify-content: space-between; color: #bdcaad; font-size: 10px; letter-spacing: .1em; }
|
||||
@@ -252,12 +296,22 @@ useHead({ title: 'Defense Sim V2' })
|
||||
.select-input { width: 112px; height: 25px; border: 1px solid #3e4e43; background: #0b100f; padding: 0 4px; color: #dce5df; font: inherit; font-size: 9px; }.hardware-readout { margin: 1px 0 4px; color: #6f8376; font-size: 8px; line-height: 1.45; }
|
||||
.model-note { display: grid; gap: 5px; padding: 9px 0; border-top: 1px solid #29382f; border-bottom: 1px solid #29382f; color: #728378; font-size: 9px; letter-spacing: .03em; }
|
||||
.run-button, .stop-button, .save-button { height: 34px; display: flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid; font: inherit; font-size: 10px; letter-spacing: .08em; cursor: pointer; }.run-button { color: #10160e; background: #9bb66d; border-color: #b1cc83; font-weight: 700; }.run-button:disabled, .save-button:disabled { opacity: .4; cursor: not-allowed; }.stop-button { background: #4b201c; border-color: #985147; color: #ffd8cf; }.save-button { background: transparent; border-color: #46564b; color: #c1cec5; }.rail-foot { margin-top: auto; padding-top: 12px; border-top: 1px solid #29382f; display: flex; gap: 7px; color: #748278; font-size: 8px; }.notice, .error-notice { margin: -5px 0 0; overflow-wrap: anywhere; color: #a9c87a; font-size: 10px; }.error-notice { color: #e69a8b; }
|
||||
.main-display { min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-color: #435147 #101714; scrollbar-width: thin; padding: 14px 17px 8px; }.display-heading { display: flex; align-items: end; justify-content: space-between; margin-bottom: 11px; }.eyebrow, .section-caption { color: #839987; font-size: 9px; letter-spacing: .18em; }.display-heading h1 { margin: 3px 0 0; font-size: 17px; letter-spacing: .1em; font-weight: 650; }.run-state { display: flex; align-items: center; gap: 7px; border: 1px solid #38453b; padding: 5px 9px; font-size: 9px; color: #94a196; }.run-state span { background: #657368; }.run-state.active { border-color: #758a53; color: #c5d8a0; }.run-state.active span { background: #adc979; animation: pulse 1.1s steps(2) infinite; }
|
||||
.main-display { min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; padding: 14px 17px 8px; }.section-caption { color: #839987; font-size: 9px; letter-spacing: .18em; }.run-state { position: absolute; top: 9px; right: 9px; z-index: 1; background: rgb(11 16 15 / 84%); pointer-events: none; display: flex; align-items: center; gap: 7px; border: 1px solid #38453b; padding: 5px 9px; font-size: 9px; color: #94a196; }.run-state span { background: #657368; }.run-state.active { border-color: #758a53; color: #c5d8a0; }.run-state.active span { background: #adc979; animation: pulse 1.1s steps(2) infinite; }
|
||||
.metric-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border: 1px solid #303d35; background: #121a16; margin-bottom: 10px; }.metric { min-width: 0; padding: 8px 10px; border-right: 1px solid #303d35; }.metric:last-child { border: 0; }.metric span, .environment-grid span { display: block; color: #85958a; font-size: 8px; letter-spacing: .11em; }.metric strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-top: 4px; font-size: 14px; color: #dce7dc; font-weight: 550; }.metric i { font-style: normal; color: #819084; font-size: 8px; font-weight: 400; }
|
||||
.scene-panel, .environment-strip, .terminal-panel { min-width: 0; border: 1px solid #303d35; background: #101714; }.scene-panel { position: relative; }.panel-cap { height: 29px; padding: 0 9px; border-bottom: 1px solid #303d35; color: #afbeaf; font-size: 8px; }.panel-cap span:last-child { color: #73847a; text-align: right; }.scene-host { height: clamp(340px, 48vh, 580px); }.scene-legend { position: absolute; bottom: 8px; left: 9px; display: flex; gap: 14px; background: rgb(11 16 15 / 84%); padding: 5px 7px; color: #a2b0a6; font-size: 8px; }.scene-legend span { display: flex; gap: 5px; align-items: center; }.swatch { width: 7px; height: 7px; display: inline-block; }.threat { background: #ff6258; }.defense { background: #5ca8ff; }.radar { background: #f0c76d; }.city { border: 1px solid #a9ba75; }.perimeter { border: 1px solid #547a9a; }
|
||||
.view-panel, .environment-strip { min-width: 0; border: 1px solid #303d35; background: #101714; }.scene-panel { position: relative; overflow: hidden; }.panel-cap { height: 29px; padding: 0 9px; border-bottom: 1px solid #303d35; color: #afbeaf; font-size: 8px; }.panel-cap span:last-child { color: #73847a; text-align: right; }.scene-legend { position: absolute; bottom: 8px; left: 9px; display: flex; gap: 14px; background: rgb(11 16 15 / 84%); padding: 5px 7px; color: #a2b0a6; font-size: 8px; }.scene-legend span { display: flex; gap: 5px; align-items: center; }.swatch { width: 7px; height: 7px; display: inline-block; }.threat { background: #ff6258; }.defense { background: #5ca8ff; }.radar { background: #f0c76d; }.city { border: 1px solid #a9ba75; }.perimeter { border: 1px solid #547a9a; }
|
||||
.environment-strip { margin-top: 9px; padding: 8px 9px 9px; }.section-caption { margin-bottom: 8px; }.environment-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }.environment-grid > div { min-width: 0; }.environment-grid b { display: block; padding-top: 3px; color: #d3ded4; font-size: 10px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.terminal-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(260px, .8fr); gap: 9px; margin-top: 9px; }.terminal-body { height: 176px; overflow: auto; overscroll-behavior: contain; background: #080d0b; color: #a9b8ad; font: 9px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; scrollbar-color: #435147 #101714; scrollbar-width: thin; }.telemetry-body { white-space: pre; }.terminal-header { position: sticky; top: 0; z-index: 1; min-width: max-content; padding: 5px 8px; background: #131b16; color: #819487; border-bottom: 1px solid #27342c; font-size: 8px; }.terminal-line { min-width: max-content; padding: 2px 8px; border-bottom: 1px solid #141e18; display: flex; gap: 9px; }.terminal-line:hover, .event-line:hover { background: #111a14; }.term-time { color: #748c78; }.term-entity { min-width: 155px; }.threat-text, .event-tag { color: #ed8a75; }.defense-text { color: #80b8df; }.term-state { color: #8da283; }.term-state.assigned { color: #d5c879; }.terminal-empty { padding: 20px 9px; color: #607166; letter-spacing: .08em; }.event-body { padding: 4px 0; }.event-line { min-width: max-content; display: flex; gap: 8px; padding: 3px 8px; border-bottom: 1px solid #141e18; }.event-tag { min-width: 128px; }.event-detail { color: #aab8aa; }.screen-foot { display: flex; justify-content: space-between; gap: 10px; padding: 9px 1px 1px; color: #68796e; font-size: 8px; letter-spacing: .06em; }
|
||||
.terminal-body { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; background: #080d0b; color: #a9b8ad; font: 9px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; scrollbar-color: #435147 #101714; scrollbar-width: thin; }.telemetry-body { white-space: pre; }.terminal-header { position: sticky; top: 0; z-index: 1; min-width: max-content; padding: 5px 8px; background: #131b16; color: #819487; border-bottom: 1px solid #27342c; font-size: 8px; }.terminal-line { min-width: max-content; padding: 2px 8px; border-bottom: 1px solid #141e18; display: flex; gap: 9px; }.terminal-line:hover, .event-line:hover { background: #111a14; }.term-time { color: #748c78; }.term-entity { min-width: 155px; }.threat-text, .event-tag { color: #ed8a75; }.defense-text { color: #80b8df; }.term-state { color: #8da283; }.term-state.assigned { color: #d5c879; }.terminal-empty { padding: 20px 9px; color: #607166; letter-spacing: .08em; }.event-body { padding: 4px 0; }.event-line { min-width: max-content; display: flex; gap: 8px; padding: 3px 8px; border-bottom: 1px solid #141e18; }.event-tag { min-width: 128px; }.event-detail { color: #aab8aa; }
|
||||
.metric-strip { flex-shrink: 0; }
|
||||
.view-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.panel-cap.view-toolbar { flex-shrink: 0; height: auto; min-height: 33px; gap: 8px; padding: 4px 9px; flex-wrap: wrap; }
|
||||
.view-tabs { display: flex; gap: 5px; }
|
||||
.view-button[aria-selected="true"] { background: #243127; border-color: #9bb66d; color: #dce7dc; }
|
||||
.view-content { flex: 1; min-height: 0; min-width: 0; display: flex; flex-direction: column; }
|
||||
.view-content > .panel-cap { flex-shrink: 0; }
|
||||
.scene-panel :deep(.scene-host) { flex: 1; min-height: 0; height: 100%; width: 100%; }
|
||||
.environment-strip { margin: 0; border: 0; border-bottom: 1px solid #303d35; flex-shrink: 0; }
|
||||
.scene-legend { flex-wrap: wrap; right: 9px; width: fit-content; max-width: calc(100% - 18px); }
|
||||
@keyframes pulse { 50% { opacity: .4; } }
|
||||
@media (max-width: 1000px) { .workspace { grid-template-columns: 220px minmax(0, 1fr); }.environment-grid { grid-template-columns: repeat(3, 1fr); }.terminal-grid { grid-template-columns: 1fr; }.event-body { height: 120px; } }
|
||||
@media (max-width: 700px) { .topbar { flex-basis: 52px; height: 52px; padding: 0 10px; }.header-right { gap: 8px; }.clock-label, .brand-sub { display: none; }.workspace { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(190px, 38dvh) minmax(0, 1fr); }.control-rail { border-right: 0; border-bottom: 1px solid #303d35; display: grid; grid-template-columns: 1fr 1fr; }.rail-heading, .model-note, .rail-foot { grid-column: 1 / -1; }.run-button, .stop-button, .save-button { align-self: end; }.main-display { padding: 12px 9px; }.metric-strip { grid-template-columns: repeat(2, 1fr); }.metric { border-bottom: 1px solid #303d35; }.metric:nth-child(2n) { border-right: 0; }.scene-host { height: 360px; min-height: 360px; }.scene-legend { flex-wrap: wrap; gap: 5px 10px; bottom: 5px; left: 4px; }.panel-cap span:last-child { max-width: 47%; }.screen-foot { flex-direction: column; }.environment-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 1000px) { .workspace { grid-template-columns: 220px minmax(0, 1fr); }.environment-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 700px) { .topbar { flex-basis: 52px; height: 52px; padding: 0 10px; }.header-right { gap: 8px; }.clock-label, .brand-sub { display: none; }.workspace { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 25dvh) minmax(0, 1fr); }.control-rail { border-right: 0; border-bottom: 1px solid #303d35; display: grid; grid-template-columns: 1fr 1fr; }.rail-heading, .model-note, .rail-foot { grid-column: 1 / -1; }.run-button, .stop-button, .save-button { align-self: end; }.main-display { padding: 12px 9px; }.metric-strip { grid-template-columns: repeat(5, minmax(0, 1fr)); }.metric { padding: 4px; }.metric strong { font-size: 11px; }.metric { border-bottom: 1px solid #303d35; }.metric:nth-child(2n) { border-right: 0; }.scene-legend { flex-wrap: wrap; gap: 5px 10px; bottom: 5px; left: 4px; }.panel-cap span:last-child { max-width: 47%; }.environment-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -4,58 +4,81 @@ meta:
|
||||
</route>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Activity, LogIn } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { sanitizePostLoginRedirect, stringQuery } from '~/lib/auth/inviteRedirect'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { loginWithEmail, loading } = useAuth()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { loginWithEmail, loading, handleAuthError } = useAuth()
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
|
||||
async function handleSubmit() {
|
||||
if (loading.value)
|
||||
return
|
||||
error.value = ''
|
||||
const { error: err } = await loginWithEmail(email.value, password.value)
|
||||
if (err) {
|
||||
error.value = (err as Error).message || 'Login failed'
|
||||
const { data, error: cause } = await loginWithEmail(email.value.trim(), password.value)
|
||||
if (cause) {
|
||||
error.value = handleAuthError(cause)
|
||||
return
|
||||
}
|
||||
const redirect = sanitizePostLoginRedirect(stringQuery(route.query.redirect))
|
||||
router.push(redirect)
|
||||
authStore.setUser(data?.user ?? null)
|
||||
await router.replace(sanitizePostLoginRedirect(stringQuery(route.query.redirect)))
|
||||
}
|
||||
|
||||
useHead({ title: 'Sign in · Defense Sim V2' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="max-w-sm w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in</CardTitle>
|
||||
<CardDescription>Enter your credentials to continue</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="email">Email</Label>
|
||||
<Input id="email" v-model="email" type="email" placeholder="you@example.com" required />
|
||||
<main class="signin-shell">
|
||||
<header class="signin-topbar">
|
||||
<div class="signin-brand"><div class="signin-mark"><Activity :size="16" /></div><span>DEFENSE SIM <b>V2</b></span></div>
|
||||
</header>
|
||||
<div class="signin-content">
|
||||
<section class="signin-panel" aria-labelledby="signin-title">
|
||||
<div class="signin-caption"><span>SIMULATION ACCESS</span><span class="signin-dot" /></div>
|
||||
<div class="signin-form-wrap">
|
||||
<h1 id="signin-title">SIGN IN</h1>
|
||||
<p class="signin-description">Sign in to open the simulator.</p>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<label for="email">EMAIL</label>
|
||||
<input id="email" v-model="email" name="email" type="email" autocomplete="username" placeholder="you@example.com" :disabled="loading" required>
|
||||
<label for="password">PASSWORD</label>
|
||||
<input id="password" v-model="password" name="password" type="password" autocomplete="current-password" :disabled="loading" required>
|
||||
<p v-if="error" class="signin-error" role="alert">{{ error }}</p>
|
||||
<button type="submit" :disabled="loading"><LogIn :size="15" />{{ loading ? 'SIGNING IN…' : 'SIGN IN' }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="password">Password</Label>
|
||||
<Input id="password" v-model="password" type="password" placeholder="••••••••" required />
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-destructive">
|
||||
{{ error }}
|
||||
</p>
|
||||
<Button type="submit" :disabled="loading" class="w-full">
|
||||
{{ loading ? 'Signing in…' : 'Sign in' }}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.signin-shell { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; background: #0b100f; color: #dce5df; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.signin-topbar { height: 58px; display: flex; align-items: center; padding: 0 20px; border-bottom: 1px solid #303d35; background: #111916; }
|
||||
.signin-brand { display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 700; letter-spacing: .08em; }
|
||||
.signin-brand b { color: #b4cb8e; }
|
||||
.signin-mark { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; color: #b4cb8e; border: 1px solid #60734b; background: #1e2a1c; }
|
||||
.signin-content { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px 16px; }
|
||||
.signin-panel { width: 100%; max-width: 380px; border: 1px solid #303d35; background: #101714; }
|
||||
.signin-caption { display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; border-bottom: 1px solid #303d35; color: #afbeaf; font-size: 9px; letter-spacing: .1em; }
|
||||
.signin-dot { width: 7px; height: 7px; background: #849b62; }
|
||||
.signin-form-wrap { padding: 24px; }
|
||||
.signin-form-wrap h1 { margin: 0; font-size: 17px; letter-spacing: .1em; }
|
||||
.signin-description { margin: 8px 0 24px; color: #85948a; font-size: 11px; }
|
||||
.signin-form-wrap form { display: flex; flex-direction: column; }
|
||||
.signin-form-wrap label { margin-bottom: 7px; color: #8eb4c8; font-size: 9px; letter-spacing: .14em; }
|
||||
.signin-form-wrap input { width: 100%; box-sizing: border-box; height: 36px; margin-bottom: 18px; padding: 0 9px; border: 1px solid #3e4e43; background: #0b100f; color: #e1eadf; font: inherit; border-radius: 0; }
|
||||
.signin-form-wrap input:focus-visible, .signin-form-wrap button:focus-visible { outline: 2px solid #9bb66d; outline-offset: 2px; }
|
||||
.signin-form-wrap button { height: 36px; display: flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid #b1cc83; background: #9bb66d; color: #10160e; font: inherit; font-size: 10px; font-weight: 700; letter-spacing: .08em; cursor: pointer; }
|
||||
.signin-form-wrap button:hover { background: #b1cc83; }
|
||||
.signin-form-wrap button:disabled, .signin-form-wrap input:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.signin-error { margin: 0 0 16px; color: #e69a8b; overflow-wrap: anywhere; font-size: 11px; }
|
||||
</style>
|
||||
|
||||
@@ -39,6 +39,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) {
|
||||
this.user = null
|
||||
if (error.message?.includes('Auth session missing') || error.message?.includes('Invalid Refresh Token') || error.message?.includes('Refresh Token Not Found')) {
|
||||
this.sessionError = true
|
||||
this.user = null
|
||||
@@ -52,6 +53,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
return true
|
||||
}
|
||||
catch (err) {
|
||||
this.user = null
|
||||
console.error('Error fetching user:', err)
|
||||
if (err instanceof Error && (err.message?.includes('Auth session missing') || err.message?.includes('Invalid Refresh Token') || err.message?.includes('Refresh Token Not Found'))) {
|
||||
this.sessionError = true
|
||||
|
||||
57
defense_sim_v2/frontend/test/auth.test.ts
Normal file
57
defense_sim_v2/frontend/test/auth.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { sanitizePostLoginRedirect } from '../src/lib/auth/inviteRedirect'
|
||||
|
||||
const store = { isReady: false, isLoggedIn: false, fetchUser: vi.fn() }
|
||||
const refresh = { startAutoRefresh: vi.fn(), stopAutoRefresh: vi.fn() }
|
||||
vi.mock('../src/stores/auth', () => ({ useAuthStore: () => store }))
|
||||
vi.mock('../src/lib/supabaseClient', () => ({ supabase: { auth: refresh } }))
|
||||
|
||||
async function guard() {
|
||||
store.isReady = false
|
||||
store.isLoggedIn = false
|
||||
store.fetchUser.mockReset().mockImplementation(async () => { store.isReady = true })
|
||||
refresh.startAutoRefresh.mockClear()
|
||||
refresh.stopAutoRefresh.mockClear()
|
||||
const beforeEach = vi.fn()
|
||||
const { install } = await import('../src/modules/router-auth')
|
||||
install({ router: { beforeEach }, isClient: true } as any)
|
||||
return beforeEach.mock.calls[0][0]
|
||||
}
|
||||
|
||||
describe('required sign-in', () => {
|
||||
it('redirects signed-out visitors before allowing the simulation route', async () => {
|
||||
const check = await guard()
|
||||
expect(await check({ path: '/', fullPath: '/?run=demo', meta: { requiresAuth: true } }))
|
||||
.toEqual({ path: '/login', query: { redirect: '/?run=demo' }, replace: true })
|
||||
expect(store.fetchUser).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows an authenticated user and enables session refresh', async () => {
|
||||
const check = await guard()
|
||||
store.fetchUser.mockImplementation(async () => { store.isReady = true; store.isLoggedIn = true })
|
||||
expect(await check({ path: '/', fullPath: '/', meta: { requiresAuth: true } })).toBe(true)
|
||||
expect(refresh.startAutoRefresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the login route available when signed out', async () => {
|
||||
const check = await guard()
|
||||
expect(await check({ path: '/login', meta: {}, query: {} })).toBe(true)
|
||||
})
|
||||
|
||||
it('takes signed-in visitors from login to the simulator', async () => {
|
||||
const check = await guard()
|
||||
store.isReady = true
|
||||
store.isLoggedIn = true
|
||||
expect(await check({ path: '/login', meta: {}, query: {} })).toEqual({ path: '/', replace: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('login redirects', () => {
|
||||
it.each(['', '/', '/login', '/login/', '//other.test', 'https://other.test', '/auth/v1/token', '/oauth/callback'])('returns to the simulator for %s', (path) => {
|
||||
expect(sanitizePostLoginRedirect(path)).toBe('/')
|
||||
})
|
||||
|
||||
it('preserves an internal destination and query', () => {
|
||||
expect(sanitizePostLoginRedirect('/?run=demo')).toBe('/?run=demo')
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,9 @@ set -Eeuo pipefail
|
||||
PROJECT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FRONTEND_DIR="${PROJECT_DIR}/frontend"
|
||||
BACKEND_SERVICE="defense-sim-backend.service"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-8088}"
|
||||
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||
|
||||
for command in pnpm systemctl curl; do
|
||||
for command in pnpm systemctl curl python3 docker; do
|
||||
if ! command -v "${command}" >/dev/null 2>&1; then
|
||||
echo "Required command not found: ${command}" >&2
|
||||
exit 1
|
||||
@@ -19,26 +18,75 @@ if [[ ! -d "${FRONTEND_DIR}/node_modules" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Preparing the self-hosted authentication stack..."
|
||||
python3 "${PROJECT_DIR}/scripts/bootstrap_auth.py"
|
||||
# Generated values are stable across restarts; never enable shell tracing here.
|
||||
source "${PROJECT_DIR}/deploy/supabase/.env"
|
||||
COMPOSE=(docker compose --env-file "${PROJECT_DIR}/deploy/supabase/.env" -f "${PROJECT_DIR}/deploy/supabase/compose.yml")
|
||||
"${COMPOSE[@]}" up -d --wait --wait-timeout 180
|
||||
|
||||
echo "Waiting for the auth API..."
|
||||
auth_ready=false
|
||||
for attempt in {1..30}; do
|
||||
if curl --fail --silent --max-time 2 -H "apikey: ${SUPABASE_ANON_KEY}" \
|
||||
"http://127.0.0.1:${SUPABASE_PORT}/auth/v1/health" -o /dev/null; then
|
||||
auth_ready=true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ "${auth_ready}" != true ]]; then
|
||||
echo "Auth API did not become ready." >&2
|
||||
exit 1
|
||||
fi
|
||||
"${COMPOSE[@]}" exec -T db psql -U postgres -d postgres -f /app-migrate.sql
|
||||
python3 "${PROJECT_DIR}/scripts/create_demo_user.py"
|
||||
|
||||
echo "Building frontend..."
|
||||
(cd "${FRONTEND_DIR}" && pnpm build)
|
||||
(cd "${FRONTEND_DIR}" && VITE_SUPABASE_URL= VITE_SUPABASE_ANON_KEY="${SUPABASE_ANON_KEY}" pnpm build)
|
||||
|
||||
if [[ ! -s "${FRONTEND_DIR}/dist/index.html" ]]; then
|
||||
echo "Frontend build did not produce dist/index.html." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating user service definitions..."
|
||||
mkdir -p "${HOME}/.config/systemd/user"
|
||||
for service in defense-sim-backend.service; do
|
||||
install -m 644 "${PROJECT_DIR}/deploy/${service}" "${HOME}/.config/systemd/user/${service}"
|
||||
done
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable defense-sim-backend.service
|
||||
# Kong serves the frontend container; retire the old separately exposed server.
|
||||
if systemctl --user cat defense-sim-frontend.service >/dev/null 2>&1; then
|
||||
systemctl --user disable --now defense-sim-frontend.service
|
||||
fi
|
||||
|
||||
echo "Restarting ${BACKEND_SERVICE}..."
|
||||
systemctl --user restart "${BACKEND_SERVICE}"
|
||||
systemctl --user is-active --quiet "${BACKEND_SERVICE}"
|
||||
"${COMPOSE[@]}" restart frontend gateway
|
||||
|
||||
echo "Checking local frontend on port ${FRONTEND_PORT}..."
|
||||
curl --fail --silent --show-error --max-time 10 "http://127.0.0.1:${FRONTEND_PORT}/" -o /dev/null
|
||||
echo "Checking frontend through Kong on port ${SUPABASE_PORT}..."
|
||||
frontend_ready=false
|
||||
for attempt in {1..20}; do
|
||||
if curl --fail --silent --max-time 2 "http://127.0.0.1:${SUPABASE_PORT}/" -o /dev/null; then
|
||||
frontend_ready=true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ "${frontend_ready}" != true ]]; then
|
||||
echo "Frontend did not start on port ${SUPABASE_PORT}." >&2
|
||||
"${COMPOSE[@]}" ps >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checking local Socket.IO handshake on port ${BACKEND_PORT}..."
|
||||
echo "Checking Socket.IO handshake through Kong..."
|
||||
handshake=""
|
||||
for attempt in {1..20}; do
|
||||
if handshake="$(curl --fail --silent --max-time 2 \
|
||||
"http://127.0.0.1:${BACKEND_PORT}/socket.io/?EIO=4&transport=polling" 2>/dev/null)" \
|
||||
"http://127.0.0.1:${SUPABASE_PORT}/socket.io/?EIO=4&transport=polling" 2>/dev/null)" \
|
||||
&& [[ "${handshake}" == \0\{\"sid\"* ]]; then
|
||||
break
|
||||
fi
|
||||
@@ -50,4 +98,9 @@ if [[ "${handshake}" != \0\{\"sid\"* ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Frontend rebuilt and both services are responding."
|
||||
echo "Checking same-origin login and auth endpoints..."
|
||||
curl --fail --silent --show-error --max-time 10 "http://127.0.0.1:${SUPABASE_PORT}/login" -o /dev/null
|
||||
curl --fail --silent --show-error --max-time 10 -H "apikey: ${SUPABASE_ANON_KEY}" \
|
||||
"http://127.0.0.1:${SUPABASE_PORT}/auth/v1/health" -o /dev/null
|
||||
|
||||
echo "Frontend rebuilt; Kong, authentication, demo login, and the simulation backend are responding."
|
||||
|
||||
82
defense_sim_v2/scripts/bootstrap_auth.py
Normal file
82
defense_sim_v2/scripts/bootstrap_auth.py
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate stable local auth secrets. Nothing privileged is emitted to stdout."""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
AUTH_DIR = ROOT / 'deploy/supabase'
|
||||
|
||||
|
||||
def read_env(path):
|
||||
return dict(line.split('=', 1) for line in path.read_text().splitlines() if line and not line.startswith('#'))
|
||||
|
||||
|
||||
def jwt(secret, role):
|
||||
def encode(value):
|
||||
return base64.urlsafe_b64encode(json.dumps(value, separators=(',', ':')).encode()).rstrip(b'=').decode()
|
||||
now = int(time.time())
|
||||
message = encode({'alg': 'HS256', 'typ': 'JWT'}) + '.' + encode({'role': role, 'iss': 'supabase', 'iat': now, 'exp': now + 10 * 365 * 86400})
|
||||
signature = base64.urlsafe_b64encode(hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest()).rstrip(b'=').decode()
|
||||
return message + '.' + signature
|
||||
|
||||
|
||||
def write_private(path, content):
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, 'w') as output:
|
||||
output.write(content)
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def main():
|
||||
env_path = AUTH_DIR / '.env'
|
||||
if not env_path.exists():
|
||||
secret = secrets.token_hex(32)
|
||||
demo_path = ROOT / 'DEMO_USER.txt'
|
||||
demo = dict(line.split(': ', 1) for line in demo_path.read_text().splitlines() if ': ' in line) if demo_path.exists() else {}
|
||||
values = {
|
||||
'POSTGRES_PASSWORD': secrets.token_hex(24),
|
||||
'JWT_SECRET': secret,
|
||||
'SUPABASE_ANON_KEY': jwt(secret, 'anon'),
|
||||
'SUPABASE_SERVICE_KEY': jwt(secret, 'service_role'),
|
||||
'SUPABASE_PORT': os.getenv('SUPABASE_PORT', '54321'),
|
||||
'KONG_BIND_ADDRESS': os.getenv('KONG_BIND_ADDRESS', '0.0.0.0'),
|
||||
'SITE_URL': os.getenv('SITE_URL', 'https://defense.naori.cloud'),
|
||||
'DEMO_EMAIL': demo.get('Username', 'demo@defense.naori.cloud'),
|
||||
'DEMO_PASSWORD': demo.get('Password', secrets.token_urlsafe(18)),
|
||||
}
|
||||
write_private(env_path, ''.join(f'{key}={value}\n' for key, value in values.items()))
|
||||
print('Generated auth secrets and demo credentials in deploy/supabase/.env')
|
||||
values = read_env(env_path)
|
||||
config = {
|
||||
'_format_version': '2.1', '_transform': True,
|
||||
'consumers': [
|
||||
{'username': 'anon', 'keyauth_credentials': [{'key': values['SUPABASE_ANON_KEY']}]},
|
||||
{'username': 'service_role', 'keyauth_credentials': [{'key': values['SUPABASE_SERVICE_KEY']}]},
|
||||
],
|
||||
'services': [
|
||||
{'name': name, 'url': url, 'routes': [{'name': name, 'paths': [path], 'strip_path': True}],
|
||||
'plugins': [{'name': 'cors'}, {'name': 'key-auth', 'config': {'hide_credentials': False}}]}
|
||||
for name, url, path in [('auth', 'http://auth:9999/', '/auth/v1/'), ('rest', 'http://rest:3000/', '/rest/v1/')]
|
||||
],
|
||||
}
|
||||
config['services'].extend([
|
||||
{'name': 'simulation', 'url': 'http://host.docker.internal:8000',
|
||||
'routes': [{'name': 'simulation', 'paths': ['/socket.io/'], 'strip_path': False}],
|
||||
'read_timeout': 60000, 'write_timeout': 60000},
|
||||
{'name': 'frontend', 'url': 'http://frontend:80',
|
||||
'routes': [{'name': 'frontend', 'paths': ['/'], 'strip_path': False}]},
|
||||
])
|
||||
# Kong runs as UID 1337; its bind-mounted config must be readable inside the container.
|
||||
write_private(AUTH_DIR / 'kong.json.local', json.dumps(config, indent=2))
|
||||
(AUTH_DIR / 'kong.json.local').chmod(0o644)
|
||||
write_private(ROOT / 'deploy/auth.env.local', f"SUPABASE_URL=http://127.0.0.1:{values['SUPABASE_PORT']}\nSUPABASE_ANON_KEY={values['SUPABASE_ANON_KEY']}\n")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
45
defense_sim_v2/scripts/create_demo_user.py
Normal file
45
defense_sim_v2/scripts/create_demo_user.py
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Provision and verify the demo account without replacing an existing password."""
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from bootstrap_auth import AUTH_DIR, read_env
|
||||
|
||||
|
||||
def request(url, key, method='GET', payload=None):
|
||||
headers = {'apikey': key, 'Authorization': f'Bearer {key}', 'Content-Type': 'application/json'}
|
||||
body = json.dumps(payload).encode() if payload is not None else None
|
||||
with urllib.request.urlopen(urllib.request.Request(url, data=body, headers=headers, method=method), timeout=15) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def main():
|
||||
values = read_env(AUTH_DIR / '.env')
|
||||
url = f"http://127.0.0.1:{values['SUPABASE_PORT']}/auth/v1"
|
||||
credentials = {'email': values['DEMO_EMAIL'], 'password': values['DEMO_PASSWORD']}
|
||||
existing = None
|
||||
page = 1
|
||||
while True:
|
||||
users = request(f'{url}/admin/users?page={page}&per_page=100', values['SUPABASE_SERVICE_KEY']).get('users', [])
|
||||
existing = next((user for user in users if user.get('email') == credentials['email']), None)
|
||||
if existing or len(users) < 100:
|
||||
break
|
||||
page += 1
|
||||
if not existing:
|
||||
request(f'{url}/admin/users', values['SUPABASE_SERVICE_KEY'], 'POST', {
|
||||
**credentials, 'email_confirm': True, 'user_metadata': {'name': 'Demo user'},
|
||||
})
|
||||
print('Created demo account.')
|
||||
session = request(f'{url}/token?grant_type=password', values['SUPABASE_ANON_KEY'], 'POST', credentials)
|
||||
if not session.get('access_token') or not session.get('user', {}).get('email') == credentials['email']:
|
||||
raise RuntimeError('Demo login verification failed')
|
||||
print('Demo password login verified. Credentials are stored in deploy/supabase/.env.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as error:
|
||||
print(f'Demo account setup failed (HTTP {error.code}).', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
33
defense_sim_v2/src/defense_sim/server/auth.py
Normal file
33
defense_sim_v2/src/defense_sim/server/auth.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Verify simulation clients with the configured Supabase Auth server."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def verify_token(token: str) -> str:
|
||||
"""Return the verified user ID; missing configuration and anonymous users fail closed."""
|
||||
url = os.getenv("SUPABASE_URL", "").rstrip("/")
|
||||
key = os.getenv("SUPABASE_ANON_KEY", "")
|
||||
if not url or not key:
|
||||
raise AuthenticationError("Simulation authentication is not configured")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise AuthenticationError("Sign in to access the simulation")
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
f"{url}/auth/v1/user",
|
||||
headers={"apikey": key, "Authorization": f"Bearer {token}"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
user = json.load(response)
|
||||
except (OSError, ValueError, urllib.error.URLError) as exc:
|
||||
raise AuthenticationError("Unable to verify your session; sign in again") from exc
|
||||
if not isinstance(user, dict) or not isinstance(user.get("id"), str) or not user["id"] or user.get("is_anonymous"):
|
||||
raise AuthenticationError("Sign in to access the simulation")
|
||||
return user["id"]
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import secrets
|
||||
import socketio
|
||||
from .events import STATE, EVENT, ERROR, COMPLETED, STOPPED
|
||||
from .auth import AuthenticationError, verify_token
|
||||
from ..simulation.scenarios import basic_intercept
|
||||
|
||||
|
||||
@@ -19,6 +20,12 @@ stream_every = 5
|
||||
|
||||
@sio.event
|
||||
async def connect(sid, environ, auth=None):
|
||||
token = auth.get("token", "") if isinstance(auth, dict) else ""
|
||||
try:
|
||||
user_id = await asyncio.to_thread(verify_token, token)
|
||||
except AuthenticationError as exc:
|
||||
raise socketio.exceptions.ConnectionRefusedError(str(exc)) from exc
|
||||
await sio.save_session(sid, {"user_id": user_id, "token": token})
|
||||
await sio.emit(STATE, engine.snapshot() if engine else {
|
||||
"schema_version": 1,
|
||||
"kind": "simulation_state",
|
||||
@@ -34,9 +41,24 @@ async def connect(sid, environ, auth=None):
|
||||
}, to=sid)
|
||||
|
||||
|
||||
async def authenticated(sid, token=None):
|
||||
try:
|
||||
session = await sio.get_session(sid)
|
||||
token = token if token is not None else session.get("token", "")
|
||||
user_id = await asyncio.to_thread(verify_token, token)
|
||||
await sio.save_session(sid, {"user_id": user_id, "token": token})
|
||||
return True
|
||||
except (AuthenticationError, KeyError):
|
||||
await sio.emit(ERROR, {"message": "Session expired; sign in again"}, to=sid)
|
||||
await sio.disconnect(sid)
|
||||
return False
|
||||
|
||||
|
||||
@sio.on("simulation_start")
|
||||
async def start_simulation(sid, data=None):
|
||||
global engine, task, stream_every
|
||||
if not await authenticated(sid, data.get("token") if isinstance(data, dict) else None):
|
||||
return
|
||||
data = data or {}
|
||||
try:
|
||||
engine = basic_intercept(
|
||||
@@ -76,8 +98,10 @@ async def start_simulation(sid, data=None):
|
||||
|
||||
|
||||
@sio.on("simulation_stop")
|
||||
async def stop_simulation(sid):
|
||||
async def stop_simulation(sid, data=None):
|
||||
global task
|
||||
if not await authenticated(sid, data.get("token") if isinstance(data, dict) else None):
|
||||
return
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
await sio.emit(STOPPED, {}, to=sid)
|
||||
|
||||
88
defense_sim_v2/tests/test_auth.py
Normal file
88
defense_sim_v2/tests/test_auth.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Authentication fails closed and validates users against Supabase."""
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
import urllib.error
|
||||
|
||||
import pytest
|
||||
import socketio
|
||||
|
||||
from defense_sim.server.auth import AuthenticationError, verify_token
|
||||
from defense_sim.server import socketio_server as server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_environment(monkeypatch):
|
||||
monkeypatch.setenv('SUPABASE_URL', 'https://auth.example.test')
|
||||
monkeypatch.setenv('SUPABASE_ANON_KEY', 'public-key')
|
||||
|
||||
|
||||
def test_missing_auth_configuration_rejects_connections(monkeypatch):
|
||||
monkeypatch.delenv('SUPABASE_URL', raising=False)
|
||||
monkeypatch.delenv('SUPABASE_ANON_KEY', raising=False)
|
||||
with pytest.raises(AuthenticationError, match='not configured'):
|
||||
verify_token('token')
|
||||
|
||||
|
||||
def test_missing_token_does_not_contact_auth_server(auth_environment, monkeypatch):
|
||||
def unexpected_request(*args, **kwargs):
|
||||
pytest.fail('Missing tokens must be rejected locally')
|
||||
monkeypatch.setattr('urllib.request.urlopen', unexpected_request)
|
||||
with pytest.raises(AuthenticationError, match='Sign in'):
|
||||
verify_token('')
|
||||
|
||||
|
||||
def test_token_is_verified_with_auth_server(auth_environment, monkeypatch):
|
||||
def response(request, timeout):
|
||||
assert request.full_url == 'https://auth.example.test/auth/v1/user'
|
||||
assert request.get_header('Authorization') == 'Bearer signed-token'
|
||||
assert request.get_header('Apikey') == 'public-key'
|
||||
return io.BytesIO(json.dumps({'id': 'verified-user', 'is_anonymous': False}).encode())
|
||||
monkeypatch.setattr('urllib.request.urlopen', response)
|
||||
assert verify_token('signed-token') == 'verified-user'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('payload', [{'id': 'guest', 'is_anonymous': True}, {}, [], {'id': None}])
|
||||
def test_anonymous_or_invalid_users_rejected(auth_environment, monkeypatch, payload):
|
||||
monkeypatch.setattr('urllib.request.urlopen', lambda *args, **kwargs: io.BytesIO(json.dumps(payload).encode()))
|
||||
with pytest.raises(AuthenticationError):
|
||||
verify_token('token')
|
||||
|
||||
|
||||
def test_expired_token_rejected(auth_environment, monkeypatch):
|
||||
def response(*args, **kwargs):
|
||||
raise urllib.error.HTTPError('https://auth.example.test', 401, 'Expired token', {}, None)
|
||||
monkeypatch.setattr('urllib.request.urlopen', response)
|
||||
with pytest.raises(AuthenticationError):
|
||||
verify_token('expired-token')
|
||||
|
||||
|
||||
def test_socket_rejects_unauthenticated_client_before_sending_state(monkeypatch):
|
||||
def rejected(token):
|
||||
raise AuthenticationError('Sign in')
|
||||
monkeypatch.setattr(server, 'verify_token', rejected)
|
||||
emit = AsyncMock()
|
||||
monkeypatch.setattr(server.sio, 'emit', emit)
|
||||
with pytest.raises(socketio.exceptions.ConnectionRefusedError):
|
||||
asyncio.run(server.connect('client', {}, None))
|
||||
emit.assert_not_called()
|
||||
|
||||
|
||||
def test_expired_session_cannot_stop_running_simulation(monkeypatch):
|
||||
async def rejected(sid, token=None):
|
||||
return False
|
||||
running_task = AsyncMock()
|
||||
monkeypatch.setattr(server, 'authenticated', rejected)
|
||||
monkeypatch.setattr(server, 'task', running_task)
|
||||
asyncio.run(server.stop_simulation('client'))
|
||||
running_task.cancel.assert_not_called()
|
||||
|
||||
|
||||
def test_expired_session_cannot_start_simulation(monkeypatch):
|
||||
async def rejected(sid, token=None):
|
||||
return False
|
||||
monkeypatch.setattr(server, 'authenticated', rejected)
|
||||
original_engine = server.engine
|
||||
asyncio.run(server.start_simulation('client', {}))
|
||||
assert server.engine is original_engine
|
||||
Reference in New Issue
Block a user