Skip to content

Commit e7b1e5a

Browse files
Merge pull request #383 from geturbackend/feat/byok-ai
feat: implement BYOK AI key resolution and rate limits
2 parents 50e759c + f83a6ab commit e7b1e5a

19 files changed

Lines changed: 517 additions & 28 deletions

File tree

‎apps/dashboard-api/src/__tests__/routes.projects.storage.test.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ jest.mock('../controllers/project.controller', () => {
9292
updateMemberRole: jest.fn(ok),
9393
removeMember: jest.fn(ok),
9494
updateCollection: jest.fn(ok),
95+
updateProjectByok: jest.fn(ok),
9596
};
9697
});
9798

‎apps/dashboard-api/src/__tests__/routes.user.test.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jest.mock('../controllers/auth.controller', () => ({
4040
},
4141
message: 'Onboarding updated successfully',
4242
})),
43+
updateByok: jest.fn((_req, res) => res.json({ success: true, data: { hasGroqKey: true }, message: '' })),
4344
}));
4445

4546
jest.mock('../controllers/pat.controller', () => ({

‎apps/dashboard-api/src/controllers/ai.controller.js‎

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
const { Project } = require('@urbackend/common/src/models');
1+
const { Project, Developer } = require('@urbackend/common/src/models');
22
const { forwardToPythonService } = require('../utils/internalPythonClient');
3-
const { AppError, ApiResponse, getProjectAccessQuery } = require('@urbackend/common');
3+
const { AppError, ApiResponse, getProjectAccessQuery, resolveEffectivePlan } = require('@urbackend/common');
4+
const { decrypt } = require('@urbackend/common/src/utils/encryption');
5+
const { encryptForTransit } = require('../utils/transitEncryption');
46

57
/**
68
* Controller to handle AI Query Builder requests.
@@ -33,18 +35,26 @@ const queryBuilder = async (req, res, next) => {
3335
throw new AppError(403, "Cannot query the users collection via AI");
3436
}
3537

36-
// 1. Fetch the project and specifically the requested collection schema
37-
const project = await Project.findOne(
38-
{ ...getProjectAccessQuery(req.user._id), _id: projectId, "collections.name": safeCollectionName },
39-
{ "collections.$": 1 }
38+
// 1. Load collection and project details
39+
const project = await Project.findOne({
40+
_id: projectId,
41+
...getProjectAccessQuery(req.user._id)
42+
});
43+
44+
if (!project) {
45+
throw new AppError(404, "Project not found or access denied");
46+
}
47+
48+
const collection = project.collections.find(
49+
col => col.name.toLowerCase() === safeCollectionName.toLowerCase()
4050
);
4151

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

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

78+
// ── Hierarchical BYOK Resolution ──
79+
let resolvedKey = null;
80+
81+
// 1. Check project-level BYOK
82+
const projectByok = await Project.findOne({
83+
_id: projectId,
84+
...getProjectAccessQuery(req.user._id)
85+
})
86+
.select('+byok.groqKey.encrypted +byok.groqKey.iv +byok.groqKey.tag')
87+
.lean();
88+
if (projectByok?.byok?.groqKey?.encrypted) {
89+
resolvedKey = decrypt(projectByok.byok.groqKey);
90+
}
91+
92+
// 2. Fallback to developer-level BYOK
93+
const dev = await Developer.findById(req.user._id).select('+byok');
94+
if (!resolvedKey && dev?.byok?.groqKey?.encrypted) {
95+
resolvedKey = decrypt(dev.byok.groqKey);
96+
}
97+
98+
// 3. Encrypt for secure transit to Python
99+
const encryptedByok = resolvedKey
100+
? { groqKey: encryptForTransit(resolvedKey) }
101+
: null;
102+
103+
// Resolve effective plan dynamically based on DB (handling subscription expiry safely)
104+
const effectivePlan = dev ? resolveEffectivePlan(dev) : 'free';
105+
68106
// 3. Forward request to Python Service
69107
const aiResponse = await forwardToPythonService('/ai/query-builder', {
70108
prompt: safePrompt,
71-
schema_fields: schemaFields
109+
schema_fields: schemaFields,
110+
developer_id: req.user._id.toString(),
111+
plan: effectivePlan,
112+
encrypted_byok: encryptedByok
72113
});
73114

74115
// 4. Return the structured JSON to the frontend

‎apps/dashboard-api/src/controllers/auth.controller.js‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,3 +672,44 @@ module.exports.updateOnboarding = async (req, res, next) => {
672672
next(err);
673673
}
674674
};
675+
676+
const { encrypt, resolveEffectivePlan, getPlanLimits } = require("@urbackend/common");
677+
678+
module.exports.updateByok = async (req, res, next) => {
679+
try {
680+
const { groqKey } = req.body;
681+
682+
// Allow null to clear the key
683+
if (groqKey === null) {
684+
await Developer.findByIdAndUpdate(req.user._id, { $set: { byok: null } });
685+
return new ApiResponse({ hasGroqKey: false }, "BYOK key cleared").send(res);
686+
}
687+
688+
const effectivePlan = resolveEffectivePlan(req.user);
689+
const limits = getPlanLimits({ plan: effectivePlan });
690+
if (!limits.aiByokEnabled) {
691+
throw new AppError(403, "Bring Your Own AI Key (Groq) is a Pro feature. Please upgrade to continue.");
692+
}
693+
694+
if (typeof groqKey !== 'string' || !groqKey.startsWith('gsk_') || groqKey.length > 200) {
695+
throw new AppError(400, "Invalid Groq API key format. Key must start with 'gsk_'.");
696+
}
697+
698+
const encryptedKey = encrypt(groqKey);
699+
700+
await Developer.findByIdAndUpdate(req.user._id, {
701+
$set: {
702+
'byok.groqKey': {
703+
iv: encryptedKey.iv,
704+
encrypted: encryptedKey.encrypted,
705+
tag: encryptedKey.tag
706+
}
707+
}
708+
});
709+
710+
return new ApiResponse({ hasGroqKey: true }, "BYOK key saved successfully").send(res);
711+
} catch (err) {
712+
if (err instanceof AppError) return next(err);
713+
next(new AppError(500, "Internal server error during BYOK configuration"));
714+
}
715+
};

‎apps/dashboard-api/src/controllers/project.controller.js‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3427,4 +3427,71 @@ module.exports.removeMember = async (req, res, next) => {
34273427
}
34283428
};
34293429

3430+
module.exports.updateProjectByok = async (req, res, next) => {
3431+
try {
3432+
const { projectId } = req.params;
3433+
const { groqKey } = req.body;
3434+
3435+
const mongoose = require('mongoose');
3436+
if (!mongoose.Types.ObjectId.isValid(projectId)) {
3437+
return next(new AppError(400, "Invalid project ID"));
3438+
}
3439+
3440+
const projectCheck = await Project.findOne({
3441+
_id: projectId,
3442+
...getProjectAccessQuery(req.user._id)
3443+
});
3444+
3445+
if (!projectCheck) {
3446+
return next(new AppError(404, "Project not found or access denied"));
3447+
}
3448+
3449+
// Clearing the key should always be allowed (even if plan downgraded)
3450+
if (groqKey === null) {
3451+
await Project.findByIdAndUpdate(projectId, { $set: { 'byok.groqKey': null } });
3452+
await deleteProjectById(projectId);
3453+
return res.json({
3454+
success: true,
3455+
data: { hasGroqKey: false },
3456+
message: "Project BYOK key cleared"
3457+
});
3458+
}
3459+
3460+
// Enforce plan gate for saving a key (supports enterprise customLimits overrides)
3461+
const { resolveEffectivePlan, getPlanLimits } = require('@urbackend/common');
3462+
const effectivePlan = resolveEffectivePlan(req.developer || req.user);
3463+
const limits = getPlanLimits({ plan: effectivePlan, customLimits: projectCheck.customLimits || null });
3464+
if (!limits.aiByokEnabled) {
3465+
return next(new AppError(403, 'Bring Your Own AI Key (Groq) is a Pro feature. Please upgrade to continue.'));
3466+
}
3467+
if (typeof groqKey !== 'string' || !groqKey.startsWith('gsk_') || groqKey.length > 200) {
3468+
return next(new AppError(400, "Invalid Groq API key format. Key must start with 'gsk_'."));
3469+
}
3470+
3471+
const encryptedKey = encrypt(groqKey);
3472+
3473+
await Project.findByIdAndUpdate(projectId, {
3474+
$set: {
3475+
'byok.groqKey': {
3476+
encrypted: encryptedKey.encrypted,
3477+
iv: encryptedKey.iv,
3478+
tag: encryptedKey.tag
3479+
}
3480+
}
3481+
});
3482+
3483+
await deleteProjectById(projectId);
3484+
3485+
return res.json({
3486+
success: true,
3487+
data: { hasGroqKey: true },
3488+
message: "Project BYOK key saved successfully"
3489+
});
3490+
} catch (err) {
3491+
if (err instanceof AppError) return next(err);
3492+
next(new AppError(500, "Internal server error during BYOK configuration"));
3493+
}
3494+
};
3495+
3496+
34303497

‎apps/dashboard-api/src/routes/projects.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ const {
5454
inviteMember,
5555
updateMemberRole,
5656
removeMember,
57-
updateCollection
57+
updateCollection,
58+
updateProjectByok
5859
} = require("../controllers/project.controller");
5960

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

112+
// BYOK Project Save Endpoint
113+
router.put('/:projectId/byok', authMiddleware, authorizeProject('admin'), planEnforcement.attachDeveloper, updateProjectByok);
114+
111115
// MAIL TEMPLATES (Phase 2)
112116
router.get('/:projectId/mail/templates', authMiddleware, authorizeProject(), listMailTemplates);
113117
router.get('/:projectId/mail/templates/global', authMiddleware, authorizeProject(), listGlobalMailTemplates);

‎apps/dashboard-api/src/routes/user.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
const express = require('express');
22
const router = express.Router();
33
const authorization = require('../middlewares/authMiddleware');
4-
const { getMe, updateOnboarding } = require('../controllers/auth.controller');
4+
const { getMe, updateOnboarding, updateByok } = require('../controllers/auth.controller');
55
const { createPAT, listPATs, revokePAT } = require('../controllers/pat.controller');
66
const authenticateCLI = require("../middlewares/authenticateCLI");
77
const {getCLIProfile,} = require("../controllers/cli.controller");
88

99
router.get("/cli/me", authenticateCLI, getCLIProfile);
1010
router.get('/me', authorization, getMe);
1111
router.patch('/onboarding', authorization, updateOnboarding);
12+
router.put('/me/byok', authorization, updateByok);
1213

1314
router.post('/pats', authorization, createPAT);
1415
router.get('/pats', authorization, listPATs);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const crypto = require('crypto');
2+
3+
/**
4+
* Encrypts a plaintext string for secure transit to the Python service.
5+
* Uses AES-256-GCM keyed by INTERNAL_PAYLOAD_KEY.
6+
* @param {string} plainText - The secret to encrypt
7+
* @returns {{ iv: string, encryptedData: string, authTag: string }}
8+
*/
9+
function encryptForTransit(plainText) {
10+
const key = process.env.INTERNAL_PAYLOAD_KEY;
11+
if (!key) throw new Error("INTERNAL_PAYLOAD_KEY is not defined in environment");
12+
13+
const iv = crypto.randomBytes(16);
14+
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(key, 'hex'), iv);
15+
16+
let encrypted = cipher.update(plainText, 'utf8', 'hex');
17+
encrypted += cipher.final('hex');
18+
const tag = cipher.getAuthTag();
19+
20+
return {
21+
iv: iv.toString('hex'),
22+
encryptedData: encrypted,
23+
authTag: tag.toString('hex')
24+
};
25+
}
26+
27+
module.exports = { encryptForTransit };

‎apps/python-service/.env.example‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
# Must match the INTERNAL_SECRET in the root Dashboard API .env
88
INTERNAL_SECRET=generate_a_random_32_char_secret_here
99

10+
# Must match INTERNAL_PAYLOAD_KEY in Dashboard API .env
11+
# Used for decrypting BYOK keys in transit (AES-256-GCM)
12+
INTERNAL_PAYLOAD_KEY=generate_a_64_char_hex_string_here
13+
1014
# ── AI APIs ──────────────────────────────────────────────────────────────────
1115
# Required for AI Query Builder
1216
GROQ_API_KEY=gsk_your_groq_api_key_here

‎apps/python-service/config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

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

0 commit comments

Comments
 (0)