From f6d66c3650c60347c4c86ae45be1eda21d6c2115 Mon Sep 17 00:00:00 2001
From: Matiss Jurevics
Date: Sat, 24 Jan 2026 18:45:00 +0000
Subject: [PATCH] feat(security): add phase8 hardening with rate limits, audit
logs, and auth-first simulator flow
---
Backend/db/schema.ts | 13 +++
Backend/drizzle/0011_security_audit_logs.sql | 14 +++
Backend/drizzle/meta/_journal.json | 7 ++
Backend/index.ts | 26 +++++-
Backend/middleware/security.ts | 31 +++++++
Backend/public/mobile-sim.html | 98 ++++++++++++++++++++
Backend/routes/audit.ts | 66 +++++++++++++
Backend/routes/events.ts | 21 +++++
Backend/routes/recordings.ts | 21 +++++
Backend/routes/streams.ts | 41 ++++++++
Backend/services/audit.ts | 22 +++++
11 files changed, 355 insertions(+), 5 deletions(-)
create mode 100644 Backend/drizzle/0011_security_audit_logs.sql
create mode 100644 Backend/middleware/security.ts
create mode 100644 Backend/routes/audit.ts
create mode 100644 Backend/services/audit.ts
diff --git a/Backend/db/schema.ts b/Backend/db/schema.ts
index 0216851..b43e6e0 100644
--- a/Backend/db/schema.ts
+++ b/Backend/db/schema.ts
@@ -150,6 +150,18 @@ export const pushNotifications = pgTable('push_notifications', {
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
+export const auditLogs = pgTable('audit_logs', {
+ id: uuid('id').defaultRandom().primaryKey(),
+ ownerUserId: uuid('owner_user_id').notNull().references(() => users.id),
+ actorDeviceId: uuid('actor_device_id').references(() => devices.id),
+ action: varchar('action', { length: 128 }).notNull(),
+ targetType: varchar('target_type', { length: 64 }).notNull(),
+ targetId: varchar('target_id', { length: 255 }).notNull(),
+ metadata: jsonb('metadata').$type | null>().default(null),
+ ipAddress: text('ip_address'),
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
+});
+
export const accounts = pgTable('account', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id),
@@ -197,6 +209,7 @@ export const schema = {
videos,
notifications,
pushNotifications,
+ auditLogs,
accounts,
sessions,
verifications,
diff --git a/Backend/drizzle/0011_security_audit_logs.sql b/Backend/drizzle/0011_security_audit_logs.sql
new file mode 100644
index 0000000..6648ee7
--- /dev/null
+++ b/Backend/drizzle/0011_security_audit_logs.sql
@@ -0,0 +1,14 @@
+CREATE TABLE "audit_logs" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "owner_user_id" uuid NOT NULL,
+ "actor_device_id" uuid,
+ "action" varchar(128) NOT NULL,
+ "target_type" varchar(64) NOT NULL,
+ "target_id" varchar(255) NOT NULL,
+ "metadata" jsonb DEFAULT 'null'::jsonb,
+ "ip_address" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_device_id_devices_id_fk" FOREIGN KEY ("actor_device_id") REFERENCES "public"."devices"("id") ON DELETE no action ON UPDATE no action;
diff --git a/Backend/drizzle/meta/_journal.json b/Backend/drizzle/meta/_journal.json
index d851cfa..893a40c 100644
--- a/Backend/drizzle/meta/_journal.json
+++ b/Backend/drizzle/meta/_journal.json
@@ -78,6 +78,13 @@
"when": 1770417956419,
"tag": "0010_push_notifications_queue",
"breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "7",
+ "when": 1770418956419,
+ "tag": "0011_security_audit_logs",
+ "breakpoints": true
}
]
}
diff --git a/Backend/index.ts b/Backend/index.ts
index f7edd61..541138a 100644
--- a/Backend/index.ts
+++ b/Backend/index.ts
@@ -1,6 +1,8 @@
import express from 'express';
import { createServer } from 'http';
import { toNodeHandler } from 'better-auth/node';
+import cors from 'cors';
+import helmet from 'helmet';
import swaggerUi from 'swagger-ui-express';
import { auth } from './auth';
@@ -14,6 +16,8 @@ import eventsRoutes from './routes/events';
import streamsRoutes from './routes/streams';
import recordingsRoutes from './routes/recordings';
import pushNotificationsRoutes from './routes/push-notifications';
+import auditRoutes from './routes/audit';
+import { rateLimit } from './middleware/security';
import { setupRealtimeGateway } from './realtime/gateway';
import { ensureMinioBucket } from './utils/minio';
import { startRecordingsWorker } from './workers/recordings';
@@ -21,6 +25,9 @@ import { startPushWorker } from './services/push';
const app = express();
const openApiDocument = buildOpenApiDocument();
+const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
+ ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
+ : [];
app.get('/', (_req, res) => {
res.send('API is running');
@@ -34,17 +41,26 @@ app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));
app.all('/api/auth/*splat', toNodeHandler(auth));
+app.use(helmet());
+app.use(
+ cors({
+ origin: trustedOrigins.length > 0 ? trustedOrigins : true,
+ credentials: true,
+ }),
+);
+app.use(rateLimit({ keyPrefix: 'global', windowMs: 60_000, max: 400 }));
app.use(express.json());
app.use('/sim', express.static('public'));
app.use('/videos', videosRoutes);
app.use('/admin', adminRoutes);
-app.use('/devices', devicesRoutes);
+app.use('/devices', rateLimit({ keyPrefix: 'devices', windowMs: 60_000, max: 120 }), devicesRoutes);
app.use('/device-links', deviceLinksRoutes);
-app.use('/commands', commandsRoutes);
-app.use('/events', eventsRoutes);
-app.use('/streams', streamsRoutes);
-app.use('/recordings', recordingsRoutes);
+app.use('/commands', rateLimit({ keyPrefix: 'commands', windowMs: 60_000, max: 120 }), commandsRoutes);
+app.use('/events', rateLimit({ keyPrefix: 'events', windowMs: 60_000, max: 120 }), eventsRoutes);
+app.use('/streams', rateLimit({ keyPrefix: 'streams', windowMs: 60_000, max: 120 }), streamsRoutes);
+app.use('/recordings', rateLimit({ keyPrefix: 'recordings', windowMs: 60_000, max: 120 }), recordingsRoutes);
app.use('/push-notifications', pushNotificationsRoutes);
+app.use('/audit', auditRoutes);
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
diff --git a/Backend/middleware/security.ts b/Backend/middleware/security.ts
new file mode 100644
index 0000000..519ff2e
--- /dev/null
+++ b/Backend/middleware/security.ts
@@ -0,0 +1,31 @@
+import type { NextFunction, Request, Response } from 'express';
+
+type Bucket = {
+ count: number;
+ windowStart: number;
+};
+
+const buckets = new Map();
+
+export const rateLimit = (options: { keyPrefix: string; windowMs: number; max: number }) => {
+ return (req: Request, res: Response, next: NextFunction): void => {
+ const key = `${options.keyPrefix}:${req.ip ?? 'unknown'}`;
+ const now = Date.now();
+ const current = buckets.get(key);
+
+ if (!current || now - current.windowStart > options.windowMs) {
+ buckets.set(key, { count: 1, windowStart: now });
+ next();
+ return;
+ }
+
+ if (current.count >= options.max) {
+ res.status(429).json({ message: 'Rate limit exceeded. Try again later.' });
+ return;
+ }
+
+ current.count += 1;
+ buckets.set(key, current);
+ next();
+ };
+};
diff --git a/Backend/public/mobile-sim.html b/Backend/public/mobile-sim.html
index d9b429f..3d48dd5 100644
--- a/Backend/public/mobile-sim.html
+++ b/Backend/public/mobile-sim.html
@@ -123,6 +123,25 @@
+
+
Device Bootstrap
@@ -186,6 +205,7 @@