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
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ jest.mock('../controllers/project.controller', () => {
updateMemberRole: jest.fn(ok),
removeMember: jest.fn(ok),
updateCollection: jest.fn(ok),
updateProjectByok: jest.fn(ok),
};
});

Expand Down
1 change: 1 addition & 0 deletions apps/dashboard-api/src/__tests__/routes.user.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jest.mock('../controllers/auth.controller', () => ({
},
message: 'Onboarding updated successfully',
})),
updateByok: jest.fn((_req, res) => res.json({ success: true, data: { hasGroqKey: true }, message: '' })),
}));

jest.mock('../controllers/pat.controller', () => ({
Expand Down
63 changes: 52 additions & 11 deletions apps/dashboard-api/src/controllers/ai.controller.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
const { Project } = require('@urbackend/common/src/models');
const { Project, Developer } = require('@urbackend/common/src/models');
const { forwardToPythonService } = require('../utils/internalPythonClient');
const { AppError, ApiResponse, getProjectAccessQuery } = require('@urbackend/common');
const { AppError, ApiResponse, getProjectAccessQuery, resolveEffectivePlan } = require('@urbackend/common');
const { decrypt } = require('@urbackend/common/src/utils/encryption');
const { encryptForTransit } = require('../utils/transitEncryption');

/**
* Controller to handle AI Query Builder requests.
Expand Down Expand Up @@ -33,18 +35,26 @@ const queryBuilder = async (req, res, next) => {
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(
{ ...getProjectAccessQuery(req.user._id), _id: projectId, "collections.name": safeCollectionName },
{ "collections.$": 1 }
// 1. Load collection and project details
const project = await Project.findOne({
_id: projectId,
...getProjectAccessQuery(req.user._id)
});

if (!project) {
throw new AppError(404, "Project not found or access denied");
}

const collection = project.collections.find(
col => col.name.toLowerCase() === safeCollectionName.toLowerCase()
);

if (!project || !project.collections || project.collections.length === 0) {
throw new AppError(404, "Collection not found or access denied");
if (!collection) {
throw new AppError(404, `Collection '${safeCollectionName}' not found in this project`);
}

const collection = project.collections[0];
const allowedFields = new Set([
// Add index checking fields
const schemaFieldsCheck = new Set([
...collection.model.map(field => field.key),
'_id',
'createdAt',
Expand All @@ -65,10 +75,41 @@ const queryBuilder = async (req, res, next) => {
{ key: "updatedAt", type: "DATE" }
);

// ── Hierarchical BYOK Resolution ──
let resolvedKey = null;

// 1. Check project-level BYOK
const projectByok = await Project.findOne({
_id: projectId,
...getProjectAccessQuery(req.user._id)
})
.select('+byok.groqKey.encrypted +byok.groqKey.iv +byok.groqKey.tag')
.lean();
if (projectByok?.byok?.groqKey?.encrypted) {
resolvedKey = decrypt(projectByok.byok.groqKey);
}

// 2. Fallback to developer-level BYOK
const dev = await Developer.findById(req.user._id).select('+byok');
if (!resolvedKey && dev?.byok?.groqKey?.encrypted) {
resolvedKey = decrypt(dev.byok.groqKey);
}

// 3. Encrypt for secure transit to Python
const encryptedByok = resolvedKey
? { groqKey: encryptForTransit(resolvedKey) }
: null;

// Resolve effective plan dynamically based on DB (handling subscription expiry safely)
const effectivePlan = dev ? resolveEffectivePlan(dev) : 'free';

// 3. Forward request to Python Service
const aiResponse = await forwardToPythonService('/ai/query-builder', {
prompt: safePrompt,
schema_fields: schemaFields
schema_fields: schemaFields,
developer_id: req.user._id.toString(),
plan: effectivePlan,
encrypted_byok: encryptedByok
});

// 4. Return the structured JSON to the frontend
Expand Down
41 changes: 41 additions & 0 deletions apps/dashboard-api/src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -672,3 +672,44 @@ module.exports.updateOnboarding = async (req, res, next) => {
next(err);
}
};

const { encrypt, resolveEffectivePlan, getPlanLimits } = require("@urbackend/common");

module.exports.updateByok = async (req, res, next) => {
try {
const { groqKey } = req.body;

// Allow null to clear the key
if (groqKey === null) {
await Developer.findByIdAndUpdate(req.user._id, { $set: { byok: null } });
return new ApiResponse({ hasGroqKey: false }, "BYOK key cleared").send(res);
}

const effectivePlan = resolveEffectivePlan(req.user);
const limits = getPlanLimits({ plan: effectivePlan });
if (!limits.aiByokEnabled) {
throw new AppError(403, "Bring Your Own AI Key (Groq) is a Pro feature. Please upgrade to continue.");
}

if (typeof groqKey !== 'string' || !groqKey.startsWith('gsk_') || groqKey.length > 200) {
throw new AppError(400, "Invalid Groq API key format. Key must start with 'gsk_'.");
}

const encryptedKey = encrypt(groqKey);

await Developer.findByIdAndUpdate(req.user._id, {
$set: {
'byok.groqKey': {
iv: encryptedKey.iv,
encrypted: encryptedKey.encrypted,
tag: encryptedKey.tag
}
}
});

return new ApiResponse({ hasGroqKey: true }, "BYOK key saved successfully").send(res);
} catch (err) {
if (err instanceof AppError) return next(err);
next(new AppError(500, "Internal server error during BYOK configuration"));
}
};
67 changes: 67 additions & 0 deletions apps/dashboard-api/src/controllers/project.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3427,4 +3427,71 @@ module.exports.removeMember = async (req, res, next) => {
}
};

module.exports.updateProjectByok = async (req, res, next) => {
try {
const { projectId } = req.params;
const { groqKey } = req.body;

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

const projectCheck = await Project.findOne({
_id: projectId,
...getProjectAccessQuery(req.user._id)
});

if (!projectCheck) {
return next(new AppError(404, "Project not found or access denied"));
}

// Clearing the key should always be allowed (even if plan downgraded)
if (groqKey === null) {
await Project.findByIdAndUpdate(projectId, { $set: { 'byok.groqKey': null } });
await deleteProjectById(projectId);
return res.json({
success: true,
data: { hasGroqKey: false },
message: "Project BYOK key cleared"
});
}

Comment thread
yash-pouranik marked this conversation as resolved.
// Enforce plan gate for saving a key (supports enterprise customLimits overrides)
const { resolveEffectivePlan, getPlanLimits } = require('@urbackend/common');
const effectivePlan = resolveEffectivePlan(req.developer || req.user);
const limits = getPlanLimits({ plan: effectivePlan, customLimits: projectCheck.customLimits || null });
if (!limits.aiByokEnabled) {
return next(new AppError(403, 'Bring Your Own AI Key (Groq) is a Pro feature. Please upgrade to continue.'));
}
if (typeof groqKey !== 'string' || !groqKey.startsWith('gsk_') || groqKey.length > 200) {
return next(new AppError(400, "Invalid Groq API key format. Key must start with 'gsk_'."));
}

const encryptedKey = encrypt(groqKey);

await Project.findByIdAndUpdate(projectId, {
$set: {
'byok.groqKey': {
encrypted: encryptedKey.encrypted,
iv: encryptedKey.iv,
tag: encryptedKey.tag
}
}
});

await deleteProjectById(projectId);

return res.json({
success: true,
data: { hasGroqKey: true },
message: "Project BYOK key saved successfully"
});
} catch (err) {
if (err instanceof AppError) return next(err);
next(new AppError(500, "Internal server error during BYOK configuration"));
}
};



6 changes: 5 additions & 1 deletion apps/dashboard-api/src/routes/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ const {
inviteMember,
updateMemberRole,
removeMember,
updateCollection
updateCollection,
updateProjectByok
} = require("../controllers/project.controller");

const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listAdminUsers, deleteAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller');
Expand Down Expand Up @@ -108,6 +109,9 @@ router.delete('/:projectId', authMiddleware, authorizeProject('owner'), verifyEm
// PATCH REQ FOR UPDATE PROJECT
router.patch('/:projectId', authMiddleware, authorizeProject('admin'), planEnforcement.attachDeveloper, planEnforcement.checkByokGate, updateProject);

// BYOK Project Save Endpoint
router.put('/:projectId/byok', authMiddleware, authorizeProject('admin'), planEnforcement.attachDeveloper, updateProjectByok);

// MAIL TEMPLATES (Phase 2)
router.get('/:projectId/mail/templates', authMiddleware, authorizeProject(), listMailTemplates);
router.get('/:projectId/mail/templates/global', authMiddleware, authorizeProject(), listGlobalMailTemplates);
Expand Down
3 changes: 2 additions & 1 deletion apps/dashboard-api/src/routes/user.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
const express = require('express');
const router = express.Router();
const authorization = require('../middlewares/authMiddleware');
const { getMe, updateOnboarding } = require('../controllers/auth.controller');
const { getMe, updateOnboarding, updateByok } = require('../controllers/auth.controller');
const { createPAT, listPATs, revokePAT } = require('../controllers/pat.controller');
const authenticateCLI = require("../middlewares/authenticateCLI");
const {getCLIProfile,} = require("../controllers/cli.controller");

router.get("/cli/me", authenticateCLI, getCLIProfile);
router.get('/me', authorization, getMe);
router.patch('/onboarding', authorization, updateOnboarding);
router.put('/me/byok', authorization, updateByok);

router.post('/pats', authorization, createPAT);
router.get('/pats', authorization, listPATs);
Expand Down
27 changes: 27 additions & 0 deletions apps/dashboard-api/src/utils/transitEncryption.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const crypto = require('crypto');

/**
* Encrypts a plaintext string for secure transit to the Python service.
* Uses AES-256-GCM keyed by INTERNAL_PAYLOAD_KEY.
* @param {string} plainText - The secret to encrypt
* @returns {{ iv: string, encryptedData: string, authTag: string }}
*/
function encryptForTransit(plainText) {
const key = process.env.INTERNAL_PAYLOAD_KEY;
if (!key) throw new Error("INTERNAL_PAYLOAD_KEY is not defined in environment");

const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(key, 'hex'), iv);

let encrypted = cipher.update(plainText, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag();

return {
iv: iv.toString('hex'),
encryptedData: encrypted,
authTag: tag.toString('hex')
};
}

module.exports = { encryptForTransit };
4 changes: 4 additions & 0 deletions apps/python-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
# Must match the INTERNAL_SECRET in the root Dashboard API .env
INTERNAL_SECRET=generate_a_random_32_char_secret_here

# Must match INTERNAL_PAYLOAD_KEY in Dashboard API .env
# Used for decrypting BYOK keys in transit (AES-256-GCM)
INTERNAL_PAYLOAD_KEY=generate_a_64_char_hex_string_here

# ── AI APIs ──────────────────────────────────────────────────────────────────
# Required for AI Query Builder
GROQ_API_KEY=gsk_your_groq_api_key_here
1 change: 1 addition & 0 deletions apps/python-service/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

class Settings(BaseSettings):
INTERNAL_SECRET: str = Field(min_length=32)
INTERNAL_PAYLOAD_KEY: str = Field(default="", pattern=r"^$|^[0-9a-fA-F]{64}$") # 64-char hex for transit decryption
GROQ_API_KEY: str = "" # Default empty, can be supplied via BYOK later
REDIS_URL: str = "redis://localhost:6379"

Expand Down
1 change: 1 addition & 0 deletions apps/python-service/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ pydantic==2.7.4
pydantic-settings==2.2.1
python-dotenv==1.0.1
redis==5.0.3
pycryptodome==3.22.0
Loading