Skip to content
Merged
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
28 changes: 28 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/bin/bash
# Pre-commit hook to block commits containing production credentials, hostnames, or secrets.

# 1. Block database or redis URLs with production hostnames in env/config files
STAGED_ENV_FILES=$(git diff --cached --name-only | grep -E "(\.env|\.env\.example)$")
if [ -n "$STAGED_ENV_FILES" ]; then
for file in $STAGED_ENV_FILES; do
if grep -Ei "DATABASE_URL.*(supabase|render|aws|rds)" "$file" >/dev/null 2>&1; then
echo "CRITICAL: DATABASE_URL with production hostnames (supabase, render, aws, rds) detected in $file! Blocking commit."
exit 1
fi
if grep -Ei "REDIS_URL.*(upstash|render|aws|redis-cloud)" "$file" >/dev/null 2>&1; then
echo "CRITICAL: REDIS_URL with production hostnames (upstash, render, aws, redis-cloud) detected in $file! Blocking commit."
exit 1
fi
done
fi

# 2. Block potential OAuth client secrets or refresh tokens, excluding test and hook files
STAGED_CODE_FILES=$(git diff --cached --name-only | grep -vE "(\.test\.|__tests__|pre-commit)")
if [ -n "$STAGED_CODE_FILES" ]; then
if echo "$STAGED_CODE_FILES" | xargs grep -E "(GOCSPX-[A-Za-z0-9_-]{24,}|1\/\/[A-Za-z0-9_-]{20,})" >/dev/null 2>&1; then
echo "CRITICAL: Staged changes contain patterns matching Google OAuth client secrets or refresh tokens! Blocking commit."
exit 1
fi
fi

exit 0
22 changes: 22 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-yaml
- id: check-json
- id: end-of-file-fixer
- id: trailing-whitespace
- id: detect-private-key

- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.2
hooks:
- id: gitleaks

- repo: local
hooks:
- id: block-secrets-and-prod-urls
name: Block secrets and prod URLs
entry: .githooks/pre-commit
language: script
stages: [commit]
10 changes: 6 additions & 4 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
PORT=8000
NODE_ENV=development # Set to 'production' in prod (enables strict TLS for IMAP)

# ─── Environment Configuration ────────────────────────────────────────────────
ENVIRONMENT=local # 'local' | 'staging' | 'production'
MOCK_GMAIL=true # Set to 'true' to enable mock Gmail flow in local mode

# ─── Database ─────────────────────────────────────────────────────────────────
# SQLite (local dev):
DATABASE_URL="file:../apps/api/inboxos.db"
# PostgreSQL (production): Uncomment and fill in below, comment out SQLite line above
# DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/inboxos?schema=public"
# Local Docker PostgreSQL instance (Default):
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/inboxos?schema=public"

# ─── Authentication ───────────────────────────────────────────────────────────
# Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Expand Down
28 changes: 28 additions & 0 deletions backend/fixtures/gmail/message_detail.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"id": "mock-message-id-1",
"threadId": "mock-thread-id-1",
"payload": {
"headers": [
{ "name": "Subject", "value": "Welcome to local InboxOS development!" },
{ "name": "From", "value": "InboxOS Team <team@inboxos.dev>" },
{ "name": "To", "value": "mock-contributor@inboxos.dev" }
],
"mimeType": "multipart/mixed",
"parts": [
{
"mimeType": "text/plain",
"body": {
"data": "SGVsbG8hIFRoaXMgaXMgYSBtb2NrIGVtYWlsIGJvZHkgZm9yIGxvY2FsIGRldmVsb3BtZW50LiBJZiB5b3UncmUgc2VlaW5nIHRoaXMsIG1vY2sgR21haWwgbW9kZSBpcyB3b3JraW5nIHBlcmZlY3RseSEgQ2hlY2sgb3V0IGh0dHBzOi8vZ2l0aHViLmNvbS9Db2RlTGFic0FJMjkvSW5ib3hfT1MgdG8gY29udHJpYnV0ZS4="
}
},
{
"mimeType": "application/pdf",
"filename": "contributors_guide.pdf",
"body": {
"attachmentId": "mock-attachment-id-12345",
"size": 512
}
}
]
}
}
13 changes: 13 additions & 0 deletions backend/fixtures/gmail/messages_list.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"messages": [
{
"id": "mock-message-id-1",
"threadId": "mock-thread-id-1"
},
{
"id": "mock-message-id-2",
"threadId": "mock-thread-id-2"
}
],
"resultSizeEstimate": 2
}
7 changes: 7 additions & 0 deletions backend/fixtures/gmail/oauth_token.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"access_token": "mock-access-token-12345",
"refresh_token": "mock-refresh-token-67890",
"scope": "https://mail.google.com/ https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile",
"token_type": "Bearer",
"expiry_date": 1999999999000
}
6 changes: 6 additions & 0 deletions backend/fixtures/gmail/profile.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"emailAddress": "mock-contributor@inboxos.dev",
"messagesTotal": 10,
"threadsTotal": 5,
"historyId": "12345"
}
7 changes: 7 additions & 0 deletions backend/fixtures/gmail/push_notification.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"sender": "alerts@inboxos.dev",
"recipient": "mock-contributor@inboxos.dev",
"subject": "Mock Webhook Ingestion Event",
"body": "This is a mock push notification email webhook payload designed to test local webhook ingestion pipelines.",
"messageId": "mock-webhook-message-id-999"
}
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"lint": "eslint \"src/**/*.ts\"",
"format": "prettier --write \"src/**/*.ts\"",
"test": "node --max-old-space-size=4096 ../node_modules/jest/bin/jest.js --runInBand",
"test:coverage": "node --max-old-space-size=4096 ../node_modules/jest/bin/jest.js --coverage --runInBand"
"test:coverage": "node --max-old-space-size=4096 ../node_modules/jest/bin/jest.js --coverage --runInBand",
"validate": "npm run lint && npm run typecheck && npm test && npm run build"
},
"dependencies": {
"@google/genai": "^2.10.0",
Expand Down
2 changes: 2 additions & 0 deletions backend/render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ services:
envVars:
- key: NODE_ENV
value: production
- key: ENVIRONMENT
value: production
- key: PORT
value: "8000"

Expand Down
79 changes: 79 additions & 0 deletions backend/src/__tests__/mock-gmail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/* eslint-disable @typescript-eslint/no-var-requires */

describe('Three-Tier Environment System & Mock Gmail Client', () => {
const originalEnv = process.env;

beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});

afterAll(() => {
process.env = originalEnv;
});

it('should successfully boot in mock mode when MOCK_GMAIL=true and ENVIRONMENT=local', () => {
process.env.MOCK_GMAIL = 'true';
process.env.ENVIRONMENT = 'local';

// Dynamically import environment config to check logic
const {
IS_MOCK,
ENVIRONMENT,
MOCK_GMAIL,
} = require('../config/environment');
expect(IS_MOCK).toBe(true);
expect(ENVIRONMENT).toBe('local');
expect(MOCK_GMAIL).toBe(true);

const {
gmailClient: activeClient,
} = require('../services/gmail/gmail-client');
expect(activeClient.constructor.name).toBe('MockGmailClient');
});

it('should serve fixture data in mock mode', async () => {
process.env.MOCK_GMAIL = 'true';
process.env.ENVIRONMENT = 'local';
const {
gmailClient: activeClient,
} = require('../services/gmail/gmail-client');

const profile = await activeClient.getProfile({}, 'http://localhost');
expect(profile.emailAddress).toBe('mock-contributor@inboxos.dev');

const msgList = await activeClient.listMessages({}, 'http://localhost', {
maxResults: 10,
});
expect(msgList.messages).toBeDefined();
expect(msgList.messages.length).toBeGreaterThan(0);

const message = await activeClient.getMessage(
{},
'http://localhost',
'mock-msg-1'
);
expect(message.data.id).toBe('mock-msg-1');
expect(message.data.payload.parts).toBeDefined();
});

it('should refuse to boot and throw error if MOCK_GMAIL=true but ENVIRONMENT=production', () => {
process.env.MOCK_GMAIL = 'true';
process.env.ENVIRONMENT = 'production';

expect(() => {
require('../config/environment');
}).toThrow(/MOCK_GMAIL is set to true, but ENVIRONMENT is "production"/);
});

it('should refuse to boot and throw error if local database URL points to a production host', () => {
process.env.MOCK_GMAIL = 'true';
process.env.ENVIRONMENT = 'local';
process.env.DATABASE_URL =
'postgresql://user:pass@my-supabase-prod.supabase.co:5432/db';

expect(() => {
require('../config/environment');
}).toThrow(/connect to a production\/staging hostname/);
});
});
38 changes: 38 additions & 0 deletions backend/src/config/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { logger } from '../utils/logger';

// Load variables
export const ENVIRONMENT = process.env.ENVIRONMENT || 'local';
export const MOCK_GMAIL = process.env.MOCK_GMAIL === 'true';

// Validate values
const validEnvironments = ['local', 'staging', 'production'];
if (!validEnvironments.includes(ENVIRONMENT)) {
const errMsg = `CRITICAL CONFIG FAILURE: Invalid ENVIRONMENT value "${ENVIRONMENT}". Must be one of: ${validEnvironments.join(', ')}`;
logger.error(errMsg);
throw new Error(errMsg);
}

// 1. Mock mode requires BOTH MOCK_GMAIL=true AND ENVIRONMENT=local
export const IS_MOCK = MOCK_GMAIL && ENVIRONMENT === 'local';

// 3. Fail loudly at startup if MOCK_GMAIL=true but ENVIRONMENT != local
if (MOCK_GMAIL && ENVIRONMENT !== 'local') {
const errMsg = `CRITICAL SECURITY FAILURE: MOCK_GMAIL is set to true, but ENVIRONMENT is "${ENVIRONMENT}". Mock mode is strictly restricted to local environments to prevent mock data leakage in production/staging.`;
logger.error(errMsg);
throw new Error(errMsg);
}

// 7. Refuse to boot in local mode if DATABASE_URL or REDIS_URL resolve to a known production/staging hostname pattern
if (ENVIRONMENT === 'local') {
const dbUrl = process.env.DATABASE_URL || '';
const redisUrl = process.env.REDIS_URL || '';

const isProdDb = /supabase|upstash|render|aws|rds/i.test(dbUrl);
const isProdRedis = /supabase|upstash|render|aws|redis-cloud/i.test(redisUrl);

if (isProdDb || isProdRedis) {
const errMsg = `CRITICAL SECURITY FAILURE: Local development mode is configured to connect to a production/staging hostname (DATABASE_URL=${dbUrl}, REDIS_URL=${redisUrl}). Refusing to boot.`;
logger.error(errMsg);
throw new Error(errMsg);
}
}
1 change: 1 addition & 0 deletions backend/src/reminder-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as path from 'path';

dotenv.config({ path: path.resolve(__dirname, '../../.env') });

import './config/environment';
import './utils/redis-patch';
import { ReminderSchedulerService } from './services/actions/reminder-scheduler.service';
import { logger } from './utils/logger';
Expand Down
Loading
Loading