From d51bac5a66353ed83acd258a5c17e096b218f152 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Fri, 9 Jan 2026 10:30:00 +0000 Subject: [PATCH] feat(devices): add registration, heartbeat, linking, and device tokens --- Backend/index.ts | 4 + Backend/middleware/device-auth.ts | 30 ++++ Backend/routes/device-links.ts | 117 ++++++++++++++++ Backend/routes/devices.ts | 219 ++++++++++++++++++++++++++++++ Backend/types/express.d.ts | 6 + Backend/utils/device-token.ts | 86 ++++++++++++ 6 files changed, 462 insertions(+) create mode 100644 Backend/middleware/device-auth.ts create mode 100644 Backend/routes/device-links.ts create mode 100644 Backend/routes/devices.ts create mode 100644 Backend/utils/device-token.ts diff --git a/Backend/index.ts b/Backend/index.ts index 0f1d507..cc1962e 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -6,6 +6,8 @@ import { auth } from './auth'; import { buildOpenApiDocument } from './docs/openapi'; import videosRoutes from './routes/videos'; import adminRoutes from './routes/admin'; +import devicesRoutes from './routes/devices'; +import deviceLinksRoutes from './routes/device-links'; import { ensureMinioBucket } from './utils/minio'; const app = express(); @@ -26,6 +28,8 @@ app.all('/api/auth/*splat', toNodeHandler(auth)); app.use(express.json()); app.use('/videos', videosRoutes); app.use('/admin', adminRoutes); +app.use('/devices', devicesRoutes); +app.use('/device-links', deviceLinksRoutes); app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error(err); diff --git a/Backend/middleware/device-auth.ts b/Backend/middleware/device-auth.ts new file mode 100644 index 0000000..053cba3 --- /dev/null +++ b/Backend/middleware/device-auth.ts @@ -0,0 +1,30 @@ +import type { NextFunction, Request, Response } from 'express'; + +import { verifyDeviceToken } from '../utils/device-token'; + +const extractBearerToken = (authorizationHeader?: string): string | null => { + if (!authorizationHeader || !authorizationHeader.startsWith('Bearer ')) { + return null; + } + + return authorizationHeader.slice('Bearer '.length).trim(); +}; + +export const requireDeviceAuth = (req: Request, res: Response, next: NextFunction): void => { + const token = extractBearerToken(req.headers.authorization); + + if (!token) { + res.status(401).json({ message: 'Missing bearer device token' }); + return; + } + + const payload = verifyDeviceToken(token); + + if (!payload) { + res.status(401).json({ message: 'Invalid device token' }); + return; + } + + req.deviceAuth = payload; + next(); +}; diff --git a/Backend/routes/device-links.ts b/Backend/routes/device-links.ts new file mode 100644 index 0000000..630d04d --- /dev/null +++ b/Backend/routes/device-links.ts @@ -0,0 +1,117 @@ +import { and, desc, eq } from 'drizzle-orm'; +import { Router } from 'express'; +import { z } from 'zod'; + +import { db } from '../db/client'; +import { deviceLinks, devices } from '../db/schema'; +import { requireAuth } from '../middleware/auth'; + +const router = Router(); + +const createLinkSchema = z.object({ + cameraDeviceId: z.string().uuid(), + clientDeviceId: z.string().uuid(), +}); + +router.use(requireAuth); + +router.post('/', async (req, res) => { + const parsed = createLinkSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const authSession = req.auth; + + if (!authSession?.user?.id) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + if (parsed.data.cameraDeviceId === parsed.data.clientDeviceId) { + res.status(400).json({ message: 'cameraDeviceId and clientDeviceId must be different' }); + return; + } + + const [camera, client] = await Promise.all([ + db.query.devices.findFirst({ + where: and(eq(devices.id, parsed.data.cameraDeviceId), eq(devices.userId, authSession.user.id)), + }), + db.query.devices.findFirst({ + where: and(eq(devices.id, parsed.data.clientDeviceId), eq(devices.userId, authSession.user.id)), + }), + ]); + + if (!camera || !client) { + res.status(400).json({ message: 'Both devices must exist and belong to the authenticated user' }); + return; + } + + if (camera.role !== 'camera') { + res.status(400).json({ message: 'cameraDeviceId must belong to a camera role device' }); + return; + } + + if (client.role !== 'client') { + res.status(400).json({ message: 'clientDeviceId must belong to a client role device' }); + return; + } + + try { + const [created] = await db + .insert(deviceLinks) + .values({ + ownerUserId: authSession.user.id, + cameraDeviceId: parsed.data.cameraDeviceId, + clientDeviceId: parsed.data.clientDeviceId, + status: 'active', + }) + .returning(); + + res.status(201).json({ message: 'Device link created', link: created }); + } catch (error) { + console.error('Failed to create device link', error); + res.status(409).json({ message: 'Device link already exists' }); + } +}); + +router.get('/', async (req, res) => { + const authSession = req.auth; + + if (!authSession?.user?.id) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const links = await db.query.deviceLinks.findMany({ + where: eq(deviceLinks.ownerUserId, authSession.user.id), + orderBy: [desc(deviceLinks.updatedAt)], + }); + + res.json({ count: links.length, links }); +}); + +router.delete('/:linkId', async (req, res) => { + const authSession = req.auth; + + if (!authSession?.user?.id) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const [deleted] = await db + .delete(deviceLinks) + .where(and(eq(deviceLinks.id, req.params.linkId), eq(deviceLinks.ownerUserId, authSession.user.id))) + .returning({ id: deviceLinks.id }); + + if (!deleted) { + res.status(404).json({ message: 'Link not found' }); + return; + } + + res.json({ message: 'Device link removed', linkId: deleted.id }); +}); + +export default router; diff --git a/Backend/routes/devices.ts b/Backend/routes/devices.ts new file mode 100644 index 0000000..912f64c --- /dev/null +++ b/Backend/routes/devices.ts @@ -0,0 +1,219 @@ +import { and, desc, eq } from 'drizzle-orm'; +import { Router } from 'express'; +import { z } from 'zod'; + +import { db } from '../db/client'; +import { devices } from '../db/schema'; +import { requireAuth } from '../middleware/auth'; +import { requireDeviceAuth } from '../middleware/device-auth'; +import { createDeviceToken } from '../utils/device-token'; + +const router = Router(); + +const roleSchema = z.enum(['camera', 'client']); + +const registerSchema = z.object({ + name: z.string().trim().min(1).max(255).optional(), + role: roleSchema, + platform: z.string().trim().min(1).max(32).optional(), + appVersion: z.string().trim().min(1).max(64).optional(), + pushToken: z.string().trim().min(1).optional(), +}); + +const updateSchema = z.object({ + name: z.string().trim().min(1).max(255).optional(), + role: roleSchema.optional(), + platform: z.string().trim().min(1).max(32).optional(), + appVersion: z.string().trim().min(1).max(64).optional(), + pushToken: z.string().trim().min(1).optional(), + status: z.enum(['online', 'offline']).optional(), +}); + +const heartbeatSchema = z.object({ + status: z.enum(['online', 'offline']).default('online'), +}); +const deviceParamSchema = z.object({ + deviceId: z.string().uuid(), +}); + +router.post('/register', requireAuth, async (req, res) => { + const parsed = registerSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const authSession = req.auth; + + if (!authSession?.user?.id) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const now = new Date(); + const [device] = await db + .insert(devices) + .values({ + userId: authSession.user.id, + name: parsed.data.name, + role: parsed.data.role, + isCamera: parsed.data.role === 'camera', + platform: parsed.data.platform, + appVersion: parsed.data.appVersion, + pushToken: parsed.data.pushToken, + status: 'online', + lastSeenAt: now, + updatedAt: now, + }) + .returning({ + id: devices.id, + userId: devices.userId, + name: devices.name, + role: devices.role, + status: devices.status, + platform: devices.platform, + appVersion: devices.appVersion, + lastSeenAt: devices.lastSeenAt, + createdAt: devices.createdAt, + updatedAt: devices.updatedAt, + }); + + if (!device) { + res.status(500).json({ message: 'Unable to register device' }); + return; + } + + const deviceToken = createDeviceToken({ + userId: device.userId, + deviceId: device.id, + role: device.role as 'camera' | 'client', + }); + + res.status(201).json({ + message: 'Device registered', + device, + deviceToken, + }); +}); + +router.get('/', requireAuth, async (req, res) => { + const authSession = req.auth; + + if (!authSession?.user?.id) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const result = await db.query.devices.findMany({ + where: eq(devices.userId, authSession.user.id), + orderBy: [desc(devices.updatedAt)], + }); + + res.json({ count: result.length, devices: result }); +}); + +router.patch('/:deviceId', requireAuth, async (req, res) => { + const parsedParams = deviceParamSchema.safeParse(req.params); + if (!parsedParams.success) { + res.status(400).json({ message: 'Invalid device id', errors: parsedParams.error.flatten() }); + return; + } + + const parsed = updateSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const authSession = req.auth; + + if (!authSession?.user?.id) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const existing = await db.query.devices.findFirst({ + where: and(eq(devices.id, parsedParams.data.deviceId), eq(devices.userId, authSession.user.id)), + }); + + if (!existing) { + res.status(404).json({ message: 'Device not found' }); + return; + } + + const role = parsed.data.role ?? existing.role; + const now = new Date(); + + const [updated] = await db + .update(devices) + .set({ + name: parsed.data.name ?? existing.name, + role, + isCamera: role === 'camera', + platform: parsed.data.platform ?? existing.platform, + appVersion: parsed.data.appVersion ?? existing.appVersion, + pushToken: parsed.data.pushToken ?? existing.pushToken, + status: parsed.data.status ?? existing.status, + updatedAt: now, + lastSeenAt: parsed.data.status === 'online' ? now : existing.lastSeenAt, + }) + .where(eq(devices.id, existing.id)) + .returning(); + + res.json({ message: 'Device updated', device: updated }); +}); + +router.post('/:deviceId/heartbeat', requireDeviceAuth, async (req, res) => { + const parsedParams = deviceParamSchema.safeParse(req.params); + if (!parsedParams.success) { + res.status(400).json({ message: 'Invalid device id', errors: parsedParams.error.flatten() }); + return; + } + + const parsed = heartbeatSchema.safeParse(req.body ?? {}); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const deviceAuth = req.deviceAuth; + + if (!deviceAuth) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + if (deviceAuth.deviceId !== parsedParams.data.deviceId) { + res.status(403).json({ message: 'Device token does not match target device' }); + return; + } + + const now = new Date(); + + const [updated] = await db + .update(devices) + .set({ + status: parsed.data.status, + lastSeenAt: now, + updatedAt: now, + }) + .where(and(eq(devices.id, parsedParams.data.deviceId), eq(devices.userId, deviceAuth.userId))) + .returning({ + id: devices.id, + status: devices.status, + lastSeenAt: devices.lastSeenAt, + updatedAt: devices.updatedAt, + }); + + if (!updated) { + res.status(404).json({ message: 'Device not found' }); + return; + } + + res.json({ message: 'Heartbeat recorded', device: updated }); +}); + +export default router; diff --git a/Backend/types/express.d.ts b/Backend/types/express.d.ts index 6636ed3..a33045a 100644 --- a/Backend/types/express.d.ts +++ b/Backend/types/express.d.ts @@ -12,6 +12,12 @@ declare global { userId: string; }; }; + deviceAuth?: { + userId: string; + deviceId: string; + role: 'camera' | 'client'; + exp: number; + }; } } } diff --git a/Backend/utils/device-token.ts b/Backend/utils/device-token.ts new file mode 100644 index 0000000..8d37f3b --- /dev/null +++ b/Backend/utils/device-token.ts @@ -0,0 +1,86 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +type DeviceRole = 'camera' | 'client'; + +export type DeviceTokenPayload = { + userId: string; + deviceId: string; + role: DeviceRole; + exp: number; +}; + +const secret = process.env.BETTER_AUTH_SECRET; + +if (!secret) { + throw new Error('BETTER_AUTH_SECRET is required for device token signing'); +} + +const base64UrlEncode = (input: string): string => Buffer.from(input, 'utf8').toString('base64url'); +const base64UrlDecode = (input: string): string => Buffer.from(input, 'base64url').toString('utf8'); + +const sign = (data: string): string => createHmac('sha256', secret).update(data).digest('base64url'); + +export const createDeviceToken = ( + payload: Omit, + ttlSeconds = 60 * 60 * 24 * 30, +): string => { + const body: DeviceTokenPayload = { + ...payload, + exp: Math.floor(Date.now() / 1000) + ttlSeconds, + }; + + const encodedPayload = base64UrlEncode(JSON.stringify(body)); + const signature = sign(encodedPayload); + + return `${encodedPayload}.${signature}`; +}; + +export const verifyDeviceToken = (token: string): DeviceTokenPayload | null => { + const [encodedPayload, providedSignature] = token.split('.'); + + if (!encodedPayload || !providedSignature) { + return null; + } + + const expectedSignature = sign(encodedPayload); + + const providedBuffer = Buffer.from(providedSignature, 'utf8'); + const expectedBuffer = Buffer.from(expectedSignature, 'utf8'); + + if (providedBuffer.length !== expectedBuffer.length) { + return null; + } + + if (!timingSafeEqual(providedBuffer, expectedBuffer)) { + return null; + } + + let parsedPayload: unknown; + + try { + parsedPayload = JSON.parse(base64UrlDecode(encodedPayload)); + } catch { + return null; + } + + if (!parsedPayload || typeof parsedPayload !== 'object') { + return null; + } + + const payload = parsedPayload as Partial; + + if ( + typeof payload.userId !== 'string' || + typeof payload.deviceId !== 'string' || + (payload.role !== 'camera' && payload.role !== 'client') || + typeof payload.exp !== 'number' + ) { + return null; + } + + if (payload.exp <= Math.floor(Date.now() / 1000)) { + return null; + } + + return payload as DeviceTokenPayload; +};