Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.docker
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# Database
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=deskive_dev
POSTGRES_DB=arina_dev
POSTGRES_PORT=5432

# Redis
Expand All @@ -16,7 +16,8 @@ QDRANT_PORT=6333

# Backend
BACKEND_PORT=3002
JWT_SECRET=deskive_dev_jwt_secret_change_in_production_min32chars
# REQUIRED: replace with output of `openssl rand -base64 48` before production boot.
JWT_SECRET=replace_with_a_unique_random_secret_of_at_least_32_characters

# Frontend
FRONTEND_PORT=5175
Expand Down
19 changes: 12 additions & 7 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# =====================================================
# DESKIVE BACKEND CONFIGURATION
# ARINA BACKEND CONFIGURATION
# =====================================================
# Copy this file to .env and update with your values
# =====================================================
Expand All @@ -8,6 +8,10 @@
PORT=3002
NODE_ENV=development
API_PREFIX=api
PRODUCT_ID=arina
PRODUCT_NAME=ARINA
PRODUCT_DESCRIPTION="Bilingual business workspace and operations suite"
SWAGGER_ENABLED=true

# Application URLs
FRONTEND_URL=http://localhost:5175
Expand All @@ -17,7 +21,8 @@ CORS_ORIGIN=http://localhost:5175,http://localhost:5173,http://localhost:3000
# JWT CONFIGURATION
# =====================================================

JWT_SECRET=your_jwt_secret_min32chars_change_me
# Generate a unique value, for example: openssl rand -base64 48
JWT_SECRET=replace_with_a_unique_random_secret_of_at_least_32_characters
JWT_EXPIRES_IN=7d

# =====================================================
Expand All @@ -44,7 +49,7 @@ SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM="Deskive <noreply@example.com>"
SMTP_FROM="ARINA <noreply@example.com>"
SMTP_SECURE=false

# =====================================================
Expand All @@ -53,7 +58,7 @@ SMTP_SECURE=false

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=deskive_dev
DATABASE_NAME=arina_dev
DATABASE_USER=postgres
DATABASE_PASSWORD=your_db_password
DATABASE_POOL_MIN=2
Expand Down Expand Up @@ -86,7 +91,7 @@ LOCAL_FS_PUBLIC_URL=/uploads
LOCAL_FS_SIGNING_KEY=change_me_to_a_long_random_string

# --- S3-compatible (s3 / r2 / minio / b2) ---
STORAGE_BUCKET=deskive-dev
STORAGE_BUCKET=arina-dev
STORAGE_REGION=auto
STORAGE_ACCESS_KEY_ID=
STORAGE_SECRET_ACCESS_KEY=
Expand All @@ -106,8 +111,8 @@ AZURE_STORAGE_CONNECTION_STRING=
R2_ACCOUNT_ID=your_r2_account_id
R2_ACCESS_KEY_ID=your_r2_access_key_id
R2_SECRET_ACCESS_KEY=your_r2_secret_access_key
R2_BUCKET_NAME=deskive-dev
R2_PUBLIC_URL=https://cdn-dev.deskive.com
R2_BUCKET_NAME=arina-dev
R2_PUBLIC_URL=

# =====================================================
# =====================================================
Expand Down
4 changes: 2 additions & 2 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "deskive-backend",
"name": "arina-backend",
"version": "1.0.0",
"description": "Comprehensive Workspace Management Platform API",
"description": "ARINA bilingual business workspace and operations API",
"main": "index.js",
"directories": {
"test": "tests"
Expand Down
4 changes: 2 additions & 2 deletions backend/scripts/migrate.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

/**
* Database migration runner for Deskive.
* Database migration runner for ARINA.
* Tracks applied migrations in a _migrations table.
* Usage: node scripts/migrate.js
*/
Expand All @@ -14,7 +14,7 @@ require('dotenv').config();
const pool = new Pool({
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '5432'),
database: process.env.DATABASE_NAME || 'deskive_dev',
database: process.env.DATABASE_NAME || 'arina_dev',
user: process.env.DATABASE_USER || 'postgres',
password: process.env.DATABASE_PASSWORD || 'postgres',
});
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,14 @@ import { BlogModule } from './modules/blog/blog.module';
import { FormsModule } from './modules/forms/forms.module';
import { WorkflowsModule } from './modules/workflows/workflows.module';
import { FeedbackModule } from './modules/feedback/feedback.module';
import { validateEnvironment } from './config/runtime-config';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
validate: validateEnvironment,
}),
WebSocketModule,
CommonModule,
Expand Down
9 changes: 4 additions & 5 deletions backend/src/common/gateways/app.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Logger, UseGuards, Inject, forwardRef } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { ChatService } from '../../modules/chat/chat.service';
import { corsOriginDelegate } from '../../config/runtime-config';

export interface AuthenticatedSocket extends Socket {
userId?: string;
Expand All @@ -38,7 +39,7 @@ export interface RealtimeEvent {

@WebSocketGateway({
cors: {
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000'],
origin: corsOriginDelegate,
credentials: true,
},
namespace: '/',
Expand Down Expand Up @@ -801,10 +802,8 @@ export class AppGateway implements OnGatewayInit, OnGatewayConnection, OnGateway

private async validateToken(token: string): Promise<any> {
try {
// Decode database JWT without verification
// We trust database's signature - just extract the payload
// The token is already signed by database backend
const payload = this.jwtService.decode(token) as any;
// Verify the signature before using identity or workspace claims.
const payload = (await this.jwtService.verifyAsync(token)) as any;

if (!payload) {
this.logger.warn('Token validation failed: Invalid token format');
Expand Down
48 changes: 48 additions & 0 deletions backend/src/common/guards/auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from './auth.guard';

const JWT_SECRET = 'arina-test-secret-that-is-at-least-32-characters';

function contextFor(token?: string): { context: ExecutionContext; request: Record<string, any> } {
const request: Record<string, any> = {
headers: token ? { authorization: `Bearer ${token}` } : {},
};
return {
request,
context: {
switchToHttp: () => ({ getRequest: () => request }),
getClass: () => AuthGuard,
getHandler: () => undefined,
} as unknown as ExecutionContext,
};
}

describe('AuthGuard', () => {
const jwtService = new JwtService({ secret: JWT_SECRET });
const guard = new AuthGuard(jwtService);

it('accepts a correctly signed token and maps the subject', async () => {
const token = await jwtService.signAsync({ userId: 'user-1', email: 'user@arina.test' });
const { context, request } = contextFor(token);

await expect(guard.canActivate(context)).resolves.toBe(true);
expect(request.user).toMatchObject({ sub: 'user-1', email: 'user@arina.test' });
});

it('rejects a token signed by another key', async () => {
const forged = await new JwtService({ secret: 'attacker-secret' }).signAsync({
userId: 'admin',
});
const { context } = contextFor(forged);

await expect(guard.canActivate(context)).rejects.toBeInstanceOf(UnauthorizedException);
});

it('rejects an expired token', async () => {
const expired = await jwtService.signAsync({ userId: 'user-1' }, { expiresIn: -1 });
const { context } = contextFor(expired);

await expect(guard.canActivate(context)).rejects.toBeInstanceOf(UnauthorizedException);
});
});
7 changes: 3 additions & 4 deletions backend/src/common/guards/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ export class AuthGuard implements CanActivate {
}

try {
// Decode database JWT without verification
// We trust database's signature - just extract the payload
// The token is already signed by database backend
const payload = this.jwtService.decode(token) as any;
// Never trust claims until the signature and registered time claims have
// been verified. JwtService uses the secret configured by AuthModule.
const payload = (await this.jwtService.verifyAsync(token)) as any;

if (!payload) {
throw new UnauthorizedException('Invalid token format');
Expand Down
18 changes: 18 additions & 0 deletions backend/src/config/product.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ConfigService } from '@nestjs/config';

export interface ProductIdentity {
id: string;
name: string;
description: string;
}

export function getProductIdentity(config: ConfigService): ProductIdentity {
return {
id: config.get<string>('PRODUCT_ID', 'arina'),
name: config.get<string>('PRODUCT_NAME', 'ARINA'),
description: config.get<string>(
'PRODUCT_DESCRIPTION',
'Bilingual business workspace and operations suite',
),
};
}
46 changes: 46 additions & 0 deletions backend/src/config/runtime-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { corsOriginDelegate, parseAllowedOrigins, validateEnvironment } from './runtime-config';

describe('runtime configuration', () => {
it('provides local origins only outside production', () => {
expect(parseAllowedOrigins(undefined, 'development')).toContain('http://localhost:5173');
expect(parseAllowedOrigins(undefined, 'production')).toEqual([]);
});

it('normalizes and deduplicates configured origins', () => {
expect(parseAllowedOrigins(' https://arina.example,https://arina.example ')).toEqual([
'https://arina.example',
]);
});

it('rejects insecure production authentication configuration', () => {
expect(() =>
validateEnvironment({
NODE_ENV: 'production',
JWT_SECRET: 'change-me-in-production',
CORS_ORIGIN: 'https://arina.example',
}),
).toThrow('JWT_SECRET');
});

it('rejects wildcard production CORS', () => {
expect(() =>
validateEnvironment({
NODE_ENV: 'production',
JWT_SECRET: 'a-unique-production-secret-that-is-long-enough',
CORS_ORIGIN: '*',
}),
).toThrow('CORS_ORIGIN');
});

it('rejects unknown browser origins', () => {
const previous = process.env.CORS_ORIGIN;
process.env.CORS_ORIGIN = 'https://arina.example';
const callback = jest.fn();

corsOriginDelegate('https://attacker.example', callback);

expect(callback).toHaveBeenCalledWith(expect.any(Error));
if (previous === undefined) delete process.env.CORS_ORIGIN;
else process.env.CORS_ORIGIN = previous;
});
});
67 changes: 67 additions & 0 deletions backend/src/config/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const DEVELOPMENT_ORIGINS = [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:5175',
];

const INSECURE_JWT_SECRETS = new Set([
'change-me-in-production',
'your-secret-key',
'your_jwt_secret_min32chars_change_me_in_production',
'deskive_dev_jwt_secret_change_in_production_min32chars',
]);

export function parseAllowedOrigins(
value: string | undefined,
environment = process.env.NODE_ENV || 'development',
): string[] {
const configured = value
?.split(',')
.map((origin) => origin.trim())
.filter(Boolean);

if (configured?.length) {
return [...new Set(configured)];
}

return environment === 'production' ? [] : DEVELOPMENT_ORIGINS;
}

export function isOriginAllowed(origin: string | undefined): boolean {
// Non-browser clients do not send Origin. Their authentication is handled by
// the same signed-token boundary as browser clients.
if (!origin) return true;
return parseAllowedOrigins(process.env.CORS_ORIGIN).includes(origin);
}

export function corsOriginDelegate(
origin: string | undefined,
callback: (error: Error | null, allowed?: boolean) => void,
): void {
if (isOriginAllowed(origin)) {
callback(null, true);
return;
}
callback(new Error('Origin is not allowed by CORS policy'));
}

export function validateEnvironment(config: Record<string, unknown>): Record<string, unknown> {
const environment = String(config.NODE_ENV || 'development');
if (environment !== 'production') return config;

const jwtSecret = String(config.JWT_SECRET || '');
if (
jwtSecret.length < 32 ||
INSECURE_JWT_SECRETS.has(jwtSecret) ||
/(change|replace|example|your[_-]|deskive)/i.test(jwtSecret)
) {
throw new Error('JWT_SECRET must be a unique production secret of at least 32 characters');
}

const origins = parseAllowedOrigins(String(config.CORS_ORIGIN || ''), environment);
if (!origins.length || origins.includes('*')) {
throw new Error('CORS_ORIGIN must contain an explicit production origin allowlist');
}

return config;
}
Loading