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
4 changes: 4 additions & 0 deletions .aiignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.env
.env.*
keys/
secrets.json
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,9 @@ RAZORPAY_KEY_ID=rzp_testx_xxxx
RAZORPAY_KEY_SECRET=xxxx
RAZORPAY_PLAN_ID=xxxx
RAZORPAY_WEBHOOK_SECRET=xxxx

# ── Internal Microservices (AI & Python) ──────────────────────────────────────
# Required for AI Query Builder and Python microservice communication
PYTHON_SERVICE_URL=http://localhost:8000
INTERNAL_SECRET=generate_a_random_32_char_secret_here
GROQ_API_KEY=gsk_your_groq_api_key_here
12 changes: 5 additions & 7 deletions .github/workflows/weekly-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,11 @@ jobs:

# Create monthly file if it doesn't exist yet
if [ ! -f "$FILE" ]; then
cat > "$FILE" << MDXEOF
---
title: Changelog
description: What's new in urBackend β€” new features, improvements, and fixes.
---

MDXEOF
echo "---" > "$FILE"
echo "title: Changelog" >> "$FILE"
echo "description: What's new in urBackend β€” new features, improvements, and fixes." >> "$FILE"
echo "---" >> "$FILE"
echo "" >> "$FILE"
fi

# Insert new <Update> entry right after the closing --- of frontmatter
Expand Down
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,12 @@ coverage/
.turbo/
plan.md
/.kiroo
/xtemp/
/xtemp/

# Python
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
venv/
env/
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
{
"git.ignoreLimitWarning": true
}
62 changes: 62 additions & 0 deletions apps/dashboard-api/src/__tests__/internalPythonClient.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const axios = require('axios');
const crypto = require('crypto');
const { forwardToPythonService } = require('../utils/internalPythonClient');

describe('internalPythonClient', () => {
let originalEnv;

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

// Mock Date.now to freeze timestamp for assertions
jest.spyOn(Date, 'now').mockImplementation(() => 1609459200000); // 2021-01-01T00:00:00.000Z
jest.spyOn(axios, 'post').mockResolvedValue({ data: { success: true } });
});

afterEach(() => {
process.env = originalEnv;
jest.restoreAllMocks();
});

test('throws error if INTERNAL_SECRET is missing', async () => {
delete process.env.INTERNAL_SECRET;

await expect(forwardToPythonService('/test', {}))
.rejects
.toThrow("INTERNAL_SECRET is not defined in environment");
});

test('generates correct HMAC signature and calls axios', async () => {
process.env.INTERNAL_SECRET = 'test-secret';
process.env.PYTHON_SERVICE_URL = 'http://test-python.local';

const path = '/ai/query-builder';
const payload = { prompt: "test prompt" };
const payloadString = JSON.stringify(payload);
const timestamp = "1609459200000";

// Calculate expected signature manually
const expectedSignature = crypto
.createHmac('sha256', 'test-secret')
.update(`${timestamp}.${payloadString}`)
.digest('hex');

const result = await forwardToPythonService(path, payload);

expect(axios.post).toHaveBeenCalledTimes(1);
expect(axios.post).toHaveBeenCalledWith(
`http://test-python.local${path}`,
payloadString,
{
headers: {
'X-Internal-Signature': expectedSignature,
'X-Timestamp': timestamp,
'Content-Type': 'application/json'
}
}
);
expect(result).toEqual({ success: true });
});
});
2 changes: 2 additions & 0 deletions apps/dashboard-api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,11 @@ const analyticsRoute = require('./routes/analytics');
const billingRoute = require('./routes/billing');
const eventsRoute = require('./routes/events');
const adminMetricsRoute = require('./routes/admin.metrics');
const aiRoute = require('./routes/ai.routes');

app.use('/api/auth', authRoute);
app.use('/api/projects', dashboardLimiter, projectRoute);
app.use('/api/projects/:projectId/ai', dashboardLimiter, aiRoute);
app.use('/api/projects', dashboardLimiter, webhookRoute);
app.use('/api/releases', releaseRoute);
app.use('/api/analytics', dashboardLimiter, analyticsRoute);
Expand Down
124 changes: 124 additions & 0 deletions apps/dashboard-api/src/controllers/ai.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
const { Project } = require('@urbackend/common/src/models');
const { forwardToPythonService } = require('../utils/internalPythonClient');
const { AppError } = require('@urbackend/common');

/**
* Controller to handle AI Query Builder requests.
*/
const queryBuilder = async (req, res, next) => {
try {
const { projectId } = req.params;
const { collectionName, prompt } = req.body;

const mongoose = require('mongoose');
if (!mongoose.Types.ObjectId.isValid(projectId)) {
throw new AppError(400, "Invalid project ID");
}

if (typeof collectionName !== 'string' || typeof prompt !== 'string') {
throw new AppError(400, "Collection name and prompt must be strings");
}

const safeCollectionName = collectionName.trim();
const safePrompt = prompt.trim();

if (!safeCollectionName || !safePrompt) {
throw new AppError(400, "Collection name and prompt are required");
}

if (safeCollectionName === 'users') {
throw new AppError(403, "Cannot query the users collection via AI");
}

// 1. Fetch the project and specifically the requested collection schema
const project = await Project.findOne(
{ _id: projectId, owner: req.user._id, "collections.name": safeCollectionName },
{ "collections.$": 1 }
);

if (!project || !project.collections || project.collections.length === 0) {
throw new AppError(404, "Collection not found or access denied");
}

const collection = project.collections[0];
const allowedFields = new Set([
...collection.model.map(field => field.key),
'_id',
'createdAt',
'updatedAt'
]);

// 2. Extract simplified schema fields for the LLM
// We only send key and type to save tokens and prevent confusion
const schemaFields = collection.model.map(field => ({
key: field.key,
type: field.type
}));

// Add implicit MongoDB fields
schemaFields.push(
{ key: "_id", type: "OBJECTID" },
{ key: "createdAt", type: "DATE" },
{ key: "updatedAt", type: "DATE" }
);

// 3. Forward request to Python Service
const aiResponse = await forwardToPythonService('/ai/query-builder', {
prompt: safePrompt,
schema_fields: schemaFields
});

// 4. Return the structured JSON to the frontend
// Ensure filters is always an array to prevent frontend crash
const rawFilters = Array.isArray(aiResponse.filters) ? aiResponse.filters : [];
const allowedOperators = new Set(['=', '_gt', '_lt', '_gte', '_lte', '_ne', '_regex']);
const safeFilters = rawFilters.filter(f => {
const isPrimitiveValue = ['string', 'number', 'boolean'].includes(typeof f?.value);
return (
f &&
typeof f.field === 'string' &&
typeof f.operator === 'string' &&
allowedFields.has(f.field) &&
allowedOperators.has(f.operator) &&
isPrimitiveValue
);
});

res.status(200).json({
success: true,
data: {
filters: safeFilters,
sort: typeof aiResponse.sort === 'string' ? aiResponse.sort : '-createdAt'
},
message: "Query built successfully"
});

} catch (error) {
// Forward expected AppErrors
if (error instanceof AppError) {
return next(error);
}

// Wrap Python/Axios errors
if (error.response && error.response.data) {
console.error("AI Service returned error:", error.response.status, error.response.data);

let errorMessage = "AI Service Error";
if (typeof error.response.data === 'string') {
errorMessage = error.response.data;
} else if (error.response.data.detail) {
errorMessage = typeof error.response.data.detail === 'string' ? error.response.data.detail : JSON.stringify(error.response.data.detail);
} else {
errorMessage = JSON.stringify(error.response.data);
}

return next(new AppError(error.response.status || 500, errorMessage));
}

next(new AppError(500, "Failed to build query via AI"));
}
};

module.exports = {
queryBuilder
};
16 changes: 16 additions & 0 deletions apps/dashboard-api/src/routes/ai.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const express = require('express');
const router = express.Router({ mergeParams: true }); // mergeParams is crucial to access :projectId
const aiController = require('../controllers/ai.controller');
const authMiddleware = require('../middlewares/authMiddleware');

// All AI routes require the user to be authenticated
router.use(authMiddleware);

/**
* @route POST /api/projects/:projectId/ai/query-builder
* @desc Generate MongoDB filters from natural language
* @access Private
*/
router.post('/query-builder', aiController.queryBuilder);

module.exports = router;
42 changes: 42 additions & 0 deletions apps/dashboard-api/src/utils/internalPythonClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const crypto = require('crypto');
const axios = require('axios');

/**
* Forwards a request to the internal Python microservice with HMAC-SHA256 signature.
* @param {string} path - The path on the Python service (e.g., "/ai/query-builder")
* @param {object} payload - The JSON payload to send
* @returns {Promise<any>} The response data from Python service
*/
const forwardToPythonService = async (path, payload) => {
const pythonUrl = process.env.PYTHON_SERVICE_URL || 'http://localhost:8000';
const secret = process.env.INTERNAL_SECRET;

if (!secret) {
throw new Error("INTERNAL_SECRET is not defined in environment");
}

const payloadString = JSON.stringify(payload);
const timestamp = Date.now().toString();

// Generate HMAC-SHA256 signature
const signature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payloadString}`)
.digest('hex');

try {
const response = await axios.post(`${pythonUrl}${path}`, payloadString, {
headers: {
'X-Internal-Signature': signature,
'X-Timestamp': timestamp,
'Content-Type': 'application/json'
}
});
return response.data;
} catch (error) {
console.error("Error communicating with Python Service:", error.response?.data || error.message);
throw error;
}
};

module.exports = { forwardToPythonService };
37 changes: 37 additions & 0 deletions apps/dashboard-api/test_python.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const axios = require('axios');
const crypto = require('crypto');

const run = async () => {
const timestamp = Date.now().toString();
const payload = JSON.stringify({ prompt: "test", schema_fields: [] });

const secret = process.env.TEST_SECRET || process.env.INTERNAL_SECRET;
const apiUrl = process.env.AI_API_URL || 'http://127.0.0.1:8000/ai/query-builder';

if (!secret) {
console.error("Missing TEST_SECRET or INTERNAL_SECRET in environment variables.");
process.exit(1);
}

const signature = crypto.createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');

try {
const res = await axios.post(apiUrl, payload, {
headers: {
'X-Internal-Signature': signature,
'X-Timestamp': timestamp,
'Content-Type': 'application/json'
}
});
console.log("Success:", res.data);
} catch (e) {
if (e.response) {
console.log("Error status:", e.response.status);
console.log("Error data:", e.response.data);
} else {
console.log("No response:", e.message);
}
}
};

run();
12 changes: 12 additions & 0 deletions apps/python-service/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# ─────────────────────────────────────────────────────────────────────────────
# Python Microservice β€” Environment Variables
# Copy this file to .env and fill in your values
# ─────────────────────────────────────────────────────────────────────────────

# ── Security ─────────────────────────────────────────────────────────────────
# Must match the INTERNAL_SECRET in the root Dashboard API .env
INTERNAL_SECRET=generate_a_random_32_char_secret_here

# ── AI APIs ──────────────────────────────────────────────────────────────────
# Required for AI Query Builder
GROQ_API_KEY=gsk_your_groq_api_key_here
11 changes: 11 additions & 0 deletions apps/python-service/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
INTERNAL_SECRET: str = Field(min_length=32)
GROQ_API_KEY: str = "" # Default empty, can be supplied via BYOK later
REDIS_URL: str = "redis://localhost:6379"

model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

settings = Settings()
Loading
Loading