fix(app): stabilize auth bootstrap and direct backend integration

This commit is contained in:
2026-03-16 17:50:00 +00:00
parent 5c2976b86d
commit d057626e15
6 changed files with 239 additions and 61 deletions

View File

@@ -30,6 +30,10 @@ const openApiDocument = buildOpenApiDocument();
const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean) ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
: []; : [];
const corsMiddleware = cors({
origin: trustedOrigins.length > 0 ? trustedOrigins : true,
credentials: true,
});
const buildMinioConnectOrigin = (): string | null => { const buildMinioConnectOrigin = (): string | null => {
const endpoint = process.env.MINIO_ENDPOINT?.trim(); const endpoint = process.env.MINIO_ENDPOINT?.trim();
@@ -71,8 +75,6 @@ app.get('/openapi.json', (_req, res) => {
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument)); app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));
app.all('/api/auth/*splat', toNodeHandler(auth));
app.use( app.use(
helmet({ helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
@@ -88,12 +90,8 @@ app.use(
}, },
}), }),
); );
app.use( app.use(corsMiddleware);
cors({ app.all('/api/auth/*splat', corsMiddleware, toNodeHandler(auth));
origin: trustedOrigins.length > 0 ? trustedOrigins : true,
credentials: true,
}),
);
app.use(rateLimit({ keyPrefix: 'global', windowMs: 60_000, max: 400 })); app.use(rateLimit({ keyPrefix: 'global', windowMs: 60_000, max: 400 }));
app.use(requestContext); app.use(requestContext);
app.use(express.json()); app.use(express.json());

View File

@@ -1,11 +1,16 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="dark" data-theme="black" style="background:#0a0a0c; color-scheme: dark">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0a0a0c" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body
data-sveltekit-preload-data="hover"
class="h-screen overflow-hidden flex bg-[#0a0a0c] text-gray-200"
style="background:#0a0a0c; color:#e5e7eb"
>
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

View File

@@ -2,6 +2,17 @@
// @ts-nocheck // @ts-nocheck
import { getAppState } from './store'; import { getAppState } from './store';
const rawBackendUrl = import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:3000';
const backendUrl = rawBackendUrl.replace(/\/+$/, '');
const toBackendUrl = (path) => {
if (/^https?:\/\//i.test(path)) {
return path;
}
return `${backendUrl}${path.startsWith('/') ? path : `/${path}`}`;
};
const request = async (path, options = {}) => { const request = async (path, options = {}) => {
const { deviceToken } = getAppState(); const { deviceToken } = getAppState();
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
@@ -10,8 +21,9 @@ const request = async (path, options = {}) => {
headers.Authorization = `Bearer ${deviceToken}`; headers.Authorization = `Bearer ${deviceToken}`;
} }
const response = await fetch(path, { const response = await fetch(toBackendUrl(path), {
...options, ...options,
credentials: 'include',
headers: { headers: {
...headers, ...headers,
...(options.headers || {}) ...(options.headers || {})
@@ -26,6 +38,8 @@ const request = async (path, options = {}) => {
return data; return data;
}; };
export const getBackendUrl = () => backendUrl;
export const api = { export const api = {
request, request,
auth: { auth: {

View File

@@ -2,7 +2,7 @@
// @ts-nocheck // @ts-nocheck
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { api } from './api'; import { api, getBackendUrl } from './api';
import { createMotionDetector } from './motion-detector'; import { createMotionDetector } from './motion-detector';
import { getAppState, patchAppState, resetAppState, setAppState } from './store'; import { getAppState, patchAppState, resetAppState, setAppState } from './store';
@@ -14,6 +14,13 @@ const PAGE_PATHS = {
activity: '/activity', activity: '/activity',
settings: '/settings' settings: '/settings'
}; };
const DEVICE_STORAGE_KEY = 'mobileSimDevice';
const INVALID_DEVICE_TOKEN_ERRORS = new Set([
'Missing device token',
'Invalid device token',
'Device not found',
'Token role does not match device role'
]);
const MOTION_DETECTION_SETTINGS_STORAGE_KEY = 'securecam-motion-detection-settings'; const MOTION_DETECTION_SETTINGS_STORAGE_KEY = 'securecam-motion-detection-settings';
const MOTION_DETECTION_PROFILES = { const MOTION_DETECTION_PROFILES = {
@@ -253,6 +260,151 @@ const navigateToScreen = (screen, options = {}) => {
return false; return false;
}; };
const readSavedDeviceRecord = () => {
if (typeof localStorage === 'undefined') {
return null;
}
const saved = localStorage.getItem(DEVICE_STORAGE_KEY);
if (!saved) {
return null;
}
try {
return JSON.parse(saved);
} catch (error) {
console.error('Failed to parse saved device', error);
localStorage.removeItem(DEVICE_STORAGE_KEY);
return null;
}
};
const persistSavedDeviceRecord = ({ device, deviceToken, userId }) => {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(
DEVICE_STORAGE_KEY,
JSON.stringify({
device,
deviceToken,
userId
})
);
};
const clearSavedDeviceRecord = () => {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(DEVICE_STORAGE_KEY);
};
const applySavedDeviceState = (device, deviceToken) => {
setAppState({
device,
deviceToken,
onboardingForm: {
...getAppState().onboardingForm,
name: device?.name ?? '',
role: device?.role ?? 'client',
pushToken: ''
}
});
};
const clearDeviceState = () => {
setAppState({
device: null,
deviceToken: null,
socketConnected: false,
isMotionActive: false,
activeMotionSource: null,
cameraStatus: 'idle',
cameraPreviewReady: false,
linkedCameras: [],
recordings: [],
activeCameraDeviceId: null,
activeStreamSessionId: null,
openLinkedCameraMenuId: null,
cameraSessions: {},
connectedStreamSessionIds: [],
clientStreamMode: 'none',
clientPlaceholderText: 'Select a camera to view',
onboardingForm: {
...getAppState().onboardingForm,
name: '',
role: 'client',
pushToken: ''
}
});
};
const restoreSavedDeviceForSession = async (session, options = {}) => {
const { showMissingToast = false, showInvalidToast = false } = options;
const saved = readSavedDeviceRecord();
if (!saved) {
if (showMissingToast) {
pushToast('No saved device found', 'info');
}
return false;
}
const sessionUserId = session?.user?.id;
const savedUserId = typeof saved.userId === 'string' ? saved.userId : null;
const savedDeviceId = saved?.device?.id;
const savedDeviceToken = typeof saved?.deviceToken === 'string' ? saved.deviceToken : '';
if (!sessionUserId || !savedDeviceId || !savedDeviceToken) {
clearSavedDeviceRecord();
clearDeviceState();
if (showInvalidToast) {
pushToast('Saved device is incomplete. Please register again.', 'error');
}
return false;
}
if (savedUserId && savedUserId !== sessionUserId) {
clearSavedDeviceRecord();
clearDeviceState();
if (showInvalidToast) {
pushToast('Saved device belongs to a different account.', 'info');
}
return false;
}
try {
const result = await api.devices.list();
const matchingDevice = result.devices?.find((device) => device.id === savedDeviceId);
if (!matchingDevice) {
clearSavedDeviceRecord();
clearDeviceState();
if (showInvalidToast) {
pushToast('Saved device was not found for this account.', 'info');
}
return false;
}
applySavedDeviceState(matchingDevice, savedDeviceToken);
persistSavedDeviceRecord({
device: matchingDevice,
deviceToken: savedDeviceToken,
userId: sessionUserId
});
return true;
} catch (error) {
console.error('Failed to restore saved device', error);
if (showInvalidToast) {
pushToast('Unable to restore saved device right now.', 'error');
}
return false;
}
};
const setConnectedStreamSessionIds = () => { const setConnectedStreamSessionIds = () => {
setAppState({ connectedStreamSessionIds: Array.from(connectedPeers) }); setAppState({ connectedStreamSessionIds: Array.from(connectedPeers) });
}; };
@@ -1267,7 +1419,10 @@ const connectSocket = () => {
if (!deviceToken) return; if (!deviceToken) return;
if (socket) socket.disconnect(); if (socket) socket.disconnect();
socket = io({ auth: { token: deviceToken } }); socket = io(getBackendUrl(), {
auth: { token: deviceToken },
withCredentials: true
});
socket.on('connect', () => { socket.on('connect', () => {
setAppState({ socketConnected: true }); setAppState({ socketConnected: true });
@@ -1286,6 +1441,20 @@ const connectSocket = () => {
applyMotionDetectionReadiness(); applyMotionDetectionReadiness();
}); });
socket.on('connect_error', (error) => {
const message = error?.message || 'Realtime connection failed';
setAppState({ socketConnected: false });
addActivity('System', `Realtime connection failed: ${message}`);
if (INVALID_DEVICE_TOKEN_ERRORS.has(message)) {
void invalidateSavedDevice('Saved device is invalid for this account. Please register this browser again.');
return;
}
pushToast(message, 'error');
applyMotionDetectionReadiness();
});
socket.on('command:received', async (payload) => { socket.on('command:received', async (payload) => {
addActivity('Command', `Received ${payload.commandType}`); addActivity('Command', `Received ${payload.commandType}`);
@@ -1519,6 +1688,17 @@ const cleanupConnectionState = async () => {
requestedStreams.clear(); requestedStreams.clear();
}; };
const invalidateSavedDevice = async (message, options = {}) => {
const { showToast = true } = options;
clearSavedDeviceRecord();
await cleanupConnectionState();
clearDeviceState();
if (showToast) {
pushToast(message || 'Saved device is no longer valid. Please register this browser again.', 'error');
}
navigateToScreen('onboarding', { replace: true });
};
const enforceRouteForSession = () => { const enforceRouteForSession = () => {
const state = getAppState(); const state = getAppState();
const page = pageFromPath(window.location.pathname); const page = pageFromPath(window.location.pathname);
@@ -1554,7 +1734,7 @@ const init = async () => {
if (initPromise) return initPromise; if (initPromise) return initPromise;
initPromise = (async () => { initPromise = (async () => {
setAppState({ page: pageFromPath(window.location.pathname) }); setAppState({ page: pageFromPath(window.location.pathname), loading: true });
if (navigator.mediaDevices?.addEventListener) { if (navigator.mediaDevices?.addEventListener) {
navigator.mediaDevices.addEventListener('devicechange', onMediaDeviceChange); navigator.mediaDevices.addEventListener('devicechange', onMediaDeviceChange);
} }
@@ -1562,39 +1742,24 @@ const init = async () => {
document.addEventListener('visibilitychange', onVisibilityChange); document.addEventListener('visibilitychange', onVisibilityChange);
} }
const saved = localStorage.getItem('mobileSimDevice');
if (saved) {
try {
const parsed = JSON.parse(saved);
setAppState({
device: parsed.device,
deviceToken: parsed.deviceToken,
onboardingForm: {
...getAppState().onboardingForm,
name: parsed.device?.name ?? '',
role: parsed.device?.role ?? 'client'
}
});
} catch (error) {
console.error('Failed to load saved device', error);
}
}
setAppState({ motionDetection: loadMotionDetectionSettings() }); setAppState({ motionDetection: loadMotionDetectionSettings() });
try { try {
const session = await api.auth.getSession(); const session = await api.auth.getSession();
if (session?.session) { if (session?.session) {
setAppState({ session }); setAppState({ session });
if (getAppState().deviceToken) { const restoredSavedDevice = await restoreSavedDeviceForSession(session);
if (restoredSavedDevice) {
connectSocket(); connectSocket();
startPolling(); startPolling();
} }
} else { } else {
setAppState({ session: null }); setAppState({ session: null });
clearDeviceState();
} }
} catch { } catch {
setAppState({ session: null }); setAppState({ session: null });
clearDeviceState();
} }
enforceRouteForSession(); enforceRouteForSession();
@@ -1606,10 +1771,10 @@ const init = async () => {
}); });
initialized = true; initialized = true;
})() })().finally(() => {
.finally(() => { setAppState({ loading: false });
initPromise = null; initPromise = null;
}); });
return initPromise; return initPromise;
}; };
@@ -1667,7 +1832,8 @@ const actions = {
setAppState({ session, authForm: { ...state.authForm, password: '' } }); setAppState({ session, authForm: { ...state.authForm, password: '' } });
pushToast(`Welcome, ${session.user.name}`, 'success'); pushToast(`Welcome, ${session.user.name}`, 'success');
if (getAppState().deviceToken) { const restoredSavedDevice = await restoreSavedDeviceForSession(session);
if (restoredSavedDevice) {
connectSocket(); connectSocket();
startPolling(); startPolling();
navigateToScreen('home', { role: getAppState().device?.role }); navigateToScreen('home', { role: getAppState().device?.role });
@@ -1704,8 +1870,12 @@ const actions = {
} }
const result = await api.devices.register(payload); const result = await api.devices.register(payload);
setAppState({ device: result.device, deviceToken: result.deviceToken }); applySavedDeviceState(result.device, result.deviceToken);
localStorage.setItem('mobileSimDevice', JSON.stringify({ device: result.device, deviceToken: result.deviceToken })); persistSavedDeviceRecord({
device: result.device,
deviceToken: result.deviceToken,
userId: getAppState().session?.user?.id ?? null
});
pushToast('Device Registered', 'success'); pushToast('Device Registered', 'success');
connectSocket(); connectSocket();
@@ -1717,27 +1887,17 @@ const actions = {
}, },
loadSavedDevice() { loadSavedDevice() {
const saved = localStorage.getItem('mobileSimDevice'); const session = getAppState().session;
if (!saved) { if (!session) {
pushToast('No saved device found', 'info'); pushToast('Please sign in before loading a saved device', 'error');
return; return;
} }
try { void restoreSavedDeviceForSession(session, { showMissingToast: true, showInvalidToast: true }).then((restored) => {
const parsed = JSON.parse(saved); if (restored) {
setAppState({ pushToast('Loaded saved device', 'success');
device: parsed.device, }
deviceToken: parsed.deviceToken, });
onboardingForm: {
...getAppState().onboardingForm,
name: parsed.device?.name ?? '',
role: parsed.device?.role ?? 'client'
}
});
pushToast('Loaded saved device', 'success');
} catch {
pushToast('Saved device is invalid', 'error');
}
}, },
async signOut() { async signOut() {
@@ -1748,7 +1908,7 @@ const actions = {
} }
await cleanupConnectionState(); await cleanupConnectionState();
localStorage.removeItem('mobileSimDevice'); clearSavedDeviceRecord();
const keep = { page: 'auth', toasts: [] }; const keep = { page: 'auth', toasts: [] };
resetAppState(keep); resetAppState(keep);
pushToast('Signed Out', 'info'); pushToast('Signed Out', 'info');

View File

@@ -23,7 +23,7 @@ export const createInitialState = () => ({
activityLog: [], activityLog: [],
cameraSessions: {}, cameraSessions: {},
connectedStreamSessionIds: [], connectedStreamSessionIds: [],
loading: false, loading: true,
isRegistering: false, isRegistering: false,
authForm: { authForm: {
email: '', email: '',

View File

@@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
const backendTarget = process.env.BACKEND_URL ?? 'http://localhost:3000'; const backendTarget = process.env.BACKEND_URL ?? 'http://localhost:3000';
const enableProxy = process.env.USE_VITE_PROXY === 'true';
const proxiedPaths = [ const proxiedPaths = [
'/api', '/api',
@@ -31,7 +32,7 @@ const proxy = Object.fromEntries(
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), sveltekit()],
server: { server: {
proxy proxy: enableProxy ? proxy : undefined
}, },
test: { test: {
expect: { requireAssertions: true }, expect: { requireAssertions: true },