Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9eb0c20
feat: setup consumer server
renganathc May 18, 2026
045117e
feat: configure Express server with CORS, CSRF, rate limiting, and gr…
renganathc May 18, 2026
20d9e7d
refractor: reuse existing auth middleware
renganathc May 18, 2026
282ac44
feat: implement handler in project controller
renganathc May 18, 2026
13bd9e5
feat: create project route and add db export endpoint
renganathc May 18, 2026
4e9cbc2
feat: add project route to express app
renganathc May 18, 2026
30bf4c4
feat: add BullMQ queue for db export jobs
renganathc May 18, 2026
c2e37a4
refactor: move db export job creation to dashboard API
renganathc May 19, 2026
5ef25f8
refactor: move export endpoint to projects router and remove dbExport…
renganathc May 19, 2026
0c6f88a
feat: implement project ownership verifcation and queuing logic in ex…
renganathc May 19, 2026
5fd7eb1
feat: implement plan based rate limiting
renganathc May 19, 2026
d47dd37
feat: cache project lookup in Redis before MongoDB fallback
renganathc May 19, 2026
c9c86c9
feat: implement the DB export worker logic
renganathc May 20, 2026
12cc3f0
feat: add export email job handling to email worker
renganathc May 20, 2026
99c722a
feat: implement DB export worker completion and failure handling and …
renganathc May 20, 2026
a6f3555
feat: add sendExportReadyEmail function to emailService.js
renganathc May 20, 2026
9cea150
fix: modify emailQueue.js to use sendExportReadyEmail fn
renganathc May 20, 2026
dce298a
fix (package.json): modify dev script to also run consumer server
renganathc May 20, 2026
aa5547a
refactor: add dedicated consumer entrypoint and graceful shutdown
renganathc May 21, 2026
18ab734
chore: add Dockerfile
renganathc May 21, 2026
d717649
feat(storage): Implement function to return unified S3Client for all …
renganathc May 24, 2026
9c14503
fix (storage): Remove bucket from getS3Storage return body, rename am…
renganathc May 24, 2026
414fe0a
feat: update export worker to use getS3CompatibleStorage function, st…
renganathc May 26, 2026
a18a81b
fix: CodeQL format string warning in export logger
renganathc May 27, 2026
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
20 changes: 20 additions & 0 deletions apps/consumer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM node:22-alpine

WORKDIR /app

# Copy root package files
COPY package.json package-lock.json ./

# Copy workspace package.json files
COPY packages/common/package.json ./packages/common/
COPY apps/consumer/package.json ./apps/consumer/

RUN npm ci

# Copy actual source code
COPY packages/common ./packages/common
COPY apps/consumer ./apps/consumer

WORKDIR /app/apps/consumer

CMD ["npm", "run", "start"]
Comment on lines +1 to +20
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Run the consumer container as a non-root user.

The image currently runs as root, which is an avoidable security risk.

Proposed change
 FROM node:22-alpine
 
 WORKDIR /app
@@
 COPY packages/common ./packages/common
 COPY apps/consumer ./apps/consumer
 
 WORKDIR /app/apps/consumer
+RUN chown -R node:node /app
+USER node
 
 CMD ["npm", "run", "start"]
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
FROM node:22-alpine
WORKDIR /app
# Copy root package files
COPY package.json package-lock.json ./
# Copy workspace package.json files
COPY packages/common/package.json ./packages/common/
COPY apps/consumer/package.json ./apps/consumer/
RUN npm ci
# Copy actual source code
COPY packages/common ./packages/common
COPY apps/consumer ./apps/consumer
WORKDIR /app/apps/consumer
CMD ["npm", "run", "start"]
FROM node:22-alpine
WORKDIR /app
# Copy root package files
COPY package.json package-lock.json ./
# Copy workspace package.json files
COPY packages/common/package.json ./packages/common/
COPY apps/consumer/package.json ./apps/consumer/
RUN npm ci
# Copy actual source code
COPY packages/common ./packages/common
COPY apps/consumer ./apps/consumer
WORKDIR /app/apps/consumer
RUN chown -R node:node /app
USER node
CMD ["npm", "run", "start"]
🧰 Tools
πŸͺ› Trivy (0.69.3)

[error] 1-1: Image user should not be 'root'

Specify at least 1 USER command in Dockerfile with non-root user as argument

Rule: DS-0002

Learn more

(IaC/Dockerfile)

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/consumer/Dockerfile` around lines 1 - 20, The Dockerfile is creating and
running the container as root; modify it to create a non-root user, chown the
app directory to that user, and switch to that user before CMD so the container
runs unprivileged: add a user creation step (e.g., addgroup/adduser or useradd)
after the workspace and package copy steps, run chown -R on /app (the WORKDIR
used by RUN npm ci and subsequent COPYs) to give ownership to the new user, and
add a USER instruction near the end so CMD ["npm","run","start"] runs as that
non-root user.

15 changes: 15 additions & 0 deletions apps/consumer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "consumer",
"version": "1.0.0",
"description": "",
"main": "src/app.js",
"scripts": {
"dev": "node src/index.js",
"start": "node src/index.js",
"test": "jest --testPathPatterns=src/"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}
Comment on lines +1 to +15
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show consumer manifest
echo "---- apps/consumer/package.json ----"
cat -n apps/consumer/package.json

# Identify import/require usage in consumer src
echo "---- imports in apps/consumer/src ----"
rg -n --no-heading "(require\\(|from\\s+['\"]|import\\s+['\"])\\s*('dotenv'|\"dotenv\"|dotenv)|\\bbullmq\\b|`@urbackend/common`" apps/consumer/src || true

# Show consumer src key files referenced
echo "---- list apps/consumer/src ----"
find apps/consumer/src -maxdepth 2 -type f -print

# Inspect monorepo root package.json for workspace/hoisting rules
echo "---- root package.json ----"
ls -1
if [ -f package.json ]; then cat -n package.json; fi

# If a package manager config exists (pnpm/yarn/npm), show workspace relevant sections
for f in pnpm-workspace.yaml yarn.lock package-lock.json pnpm-lock.yaml .npmrc .yarnrc.yml; do
  if [ -e "$f" ]; then
    echo "---- $f ----"
    head -n 80 "$f" || true
  fi
done

Repository: geturbackend/urBackend

Length of output: 4313


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show consumer manifest
echo "---- apps/consumer/package.json ----"
cat -n apps/consumer/package.json

# Identify import/require usage in consumer src
echo "---- imports in apps/consumer/src ----"
rg -n --no-heading "(require\\(|from\\s+['\"]|import\\s+['\"])\\s*('dotenv'|\"dotenv\"|dotenv)|\\bbullmq\\b|`@urbackend/common`" apps/consumer/src || true

# Show consumer src key files referenced
echo "---- list apps/consumer/src ----"
find apps/consumer/src -maxdepth 2 -type f -print

# Inspect monorepo root package.json for workspace/hoisting rules
echo "---- root package.json ----"
ls -1
if [ -f package.json ]; then cat -n package.json; fi

# If a package manager config exists (pnpm/yarn/npm), show workspace relevant sections
for f in pnpm-workspace.yaml yarn.lock package-lock.json pnpm-lock.yaml .npmrc .yarnrc.yml; do
  if [ -e "$f" ]; then
    echo "---- $f ----"
    head -n 80 "$f" || true
  fi
done

Repository: geturbackend/urBackend

Length of output: 4313


Declare consumer runtime dependencies in apps/consumer/package.json.

apps/consumer imports dotenv, @urbackend/common, and bullmq, but its package.json declares none (and package-lock.json shows no dependencies recorded for apps/consumer). This can break module resolution when installing/running the consumer workspace in isolation. Match the versions/protocol used by the other workspaces in this monorepo (e.g., dotenv ^17.2.3, bullmq ^5.70.1, @urbackend/common *).

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/consumer/package.json` around lines 1 - 15, Add a "dependencies" section
to apps/consumer/package.json declaring the runtime packages the consumer
imports: add "dotenv": "^17.2.3", "bullmq": "^5.70.1", and "`@urbackend/common`":
"*" (to match the monorepo protocol/versions used elsewhere), then run the
workspace install to update the lockfile; update any existing script or import
usage if package names differ. Ensure the "dependencies" key is present
alongside "scripts" and uses the exact versions listed so module resolution
works when the consumer workspace is installed/run in isolation.

35 changes: 35 additions & 0 deletions apps/consumer/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const dotenv = require('dotenv');
dotenv.config({ path: require('path').join(__dirname, '../../../.env') });

const { validateEnv } = require('@urbackend/common');

if (process.env.NODE_ENV !== 'test') {
validateEnv();
}

const { initExportWorker } = require('./workers/export.worker');

const { connectDB } = require('@urbackend/common');

(async () => {
try {
await connectDB();

const worker = initExportWorker();

console.log('[CONSUMER] Export worker started and listening for jobs...');

const shutdown = async () => {
console.log('Shutting down worker...');
await worker.close();
process.exit(0);
};

process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);

} catch (err) {
console.error('Failed to start worker:', err);
process.exit(1);
}
})();
103 changes: 103 additions & 0 deletions apps/consumer/src/workers/export.worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const { Worker } = require('bullmq');
const { PassThrough } = require('stream');
const { Upload } = require('@aws-sdk/lib-storage');
const { GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');

const {
redis,
exportQueue,
emailQueue,
Project,
getConnection,
getCompiledModel,
getS3CompatibleStorage,
getBucket
} = require('@urbackend/common');

const initExportWorker = () => {
const worker = new Worker(exportQueue.name, async (job) => {
const { projectId, userId, email } = job.data;
console.log(`[ExportWorker] Starting export for project ${projectId} requested by ${email}`);

const project = await Project.findById(projectId);
if (!project) throw new Error('Project not found');

const connection = await getConnection(projectId);

console.log(`[ExportWorker] Preparing streaming upload to storage...`);

const { s3Client } = await getS3CompatibleStorage(project);
const bucket = await getBucket(project);
const storagePath = `${projectId}/exports/db_export_${Date.now()}.json`;

const passThrough = new PassThrough();

const upload = new Upload({
client: s3Client,
params: {
Bucket: bucket,
Key: storagePath,
Body: passThrough,
ContentType: 'application/json'
}
});

// Start the upload promise in parallel
const uploadPromise = upload.done();

try {
passThrough.write('{\n');

for (let i = 0; i < project.collections.length; i++) {
const col = project.collections[i];
const Model = getCompiledModel(connection, col, projectId, project.resources.db.isExternal);

passThrough.write(` "${col.name}": [\n`);

// use a mongoose cursor to stream documents one by one
const cursor = Model.find().lean().cursor();
let first = true;

for await (const doc of cursor) {
if (!first) passThrough.write(',\n');
passThrough.write(` ${JSON.stringify(doc)}`);
first = false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

passThrough.write('\n ]');
if (i < project.collections.length - 1) passThrough.write(',\n');
}

passThrough.write('\n}\n');
passThrough.end();

console.log(`[ExportWorker] Database stream ended. Awaiting final storage upload chunks...`);
await uploadPromise;

// create a signed URL valid for 24 hrs (86400 seconds)
const command = new GetObjectCommand({ Bucket: bucket, Key: storagePath });
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn: 86400 });

// queue the email to be sent to the user
await emailQueue.add('send-export-email', { email, downloadUrl: signedUrl, projectName: project.name });
console.log(`[ExportWorker] Export completed! Email queued for ${email}`);

} catch (error) {
passThrough.destroy(error);
throw error;
}
}, { connection: redis, concurrency: 2 });

worker.on('completed', (job) => {
console.log(`[ExportWorker] Job ${job.id} for project ${job.data.projectId} completed.`);
});

worker.on('failed', (job, err) => {
console.error(`[ExportWorker] Job ${job?.id} for project ${job?.data?.projectId} failed:`, err.message);
});

return worker;
};

module.exports = { initExportWorker };
2 changes: 2 additions & 0 deletions apps/dashboard-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"transform": {}
},
"dependencies": {
"@bull-board/api": "^7.1.5",
"@bull-board/express": "^7.1.5",
"@kiroo/sdk": "^0.1.2",
"@supabase/supabase-js": "^2.84.0",
"@urbackend/common": "*",
Expand Down
1 change: 0 additions & 1 deletion apps/dashboard-api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ app.use('/api/admin/metrics', dashboardLimiter, adminMetricsRoute);




app.get('/api/server-ip', async (req, res) => {
const ip = await getPublicIp();
res.json({ ip });
Expand Down
60 changes: 60 additions & 0 deletions apps/dashboard-api/src/controllers/dbExport.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const { AppError } = require('@urbackend/common');
const { Developer } = require('@urbackend/common');
const { Project } = require('@urbackend/common');
const { exportQueue } = require('@urbackend/common');
const { redis } = require('@urbackend/common');
const { getProjectById, setProjectById } = require('@urbackend/common');

module.exports.dbExportHandler = async (req, res, next) => {
try {
const { projectId } = req.params;
const { _id: userId } = req.user;

let project = await getProjectById(projectId);
if (!project) {
project = await Project.findById(projectId).lean();
if (!project) {
return next(new AppError(404, "Project not found."));
}
await setProjectById(projectId, project);
}

if (project.owner.toString() !== userId.toString()) {
return next(new AppError(403, "Access denied. You are not the owner of this project."));
}


const developer = await Developer.findById(userId).select('email plan').lean();
if (!developer) {
return next(new AppError(404, "Authenticated developer not found."));
}
const { email, plan = 'free' } = developer;

console.log(`[Dashboard API] Received export request for project ${projectId} from user ${userId} (${email})`);


const maxExports = plan === 'pro' ? 5 : 1;
const today = new Date().toISOString().split('T')[0];
const key = `project:${projectId}:export_limit:${today}`;

const currentCount = await redis.get(key);
if (currentCount && Number(currentCount) >= maxExports) {
return next(new AppError(429, `Daily export limit reached (${maxExports}/${maxExports}). Please try again tomorrow.`));
}

const newCount = await redis.incr(key);
if (newCount === 1) {
await redis.expire(key, 86400); // Set expiry to 24 hours
}

await exportQueue.add('export-database', { projectId, userId, email });
Comment on lines +40 to +50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Make rate-limit increment/check atomic.

get + incr is race-prone; parallel requests can exceed the daily limit before either request is rejected.

Proposed change
-        const currentCount = await redis.get(key);
-        if (currentCount && Number(currentCount) >= maxExports) {
-            return next(new AppError(429, `Daily export limit reached (${maxExports}/${maxExports}). Please try again tomorrow.`));
-        }
-
         const newCount = await redis.incr(key);
         if (newCount === 1) {
             await redis.expire(key, 86400); // Set expiry to 24 hours
         }
+        if (newCount > maxExports) {
+            return next(new AppError(429, `Daily export limit reached (${maxExports}/${maxExports}). Please try again tomorrow.`));
+        }
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard-api/src/controllers/dbExport.controller.js` around lines 40 -
50, The current get + incr sequence is race-prone; replace it with a single
atomic Redis operation (e.g. an EVAL Lua script or a Redis multi/transaction)
that increments the key, sets the 24h expiry when the counter becomes 1, and
returns the new count in one call; then check the returned count against
maxExports and call next(new AppError(...)) if it exceeds the limit before
calling exportQueue.add. Target the existing symbols key, redis.get/redis.incr,
maxExports, and exportQueue.add and ensure the atomic script does: INCR key, if
value == 1 then EXPIRE key 86400, return value.


return res.status(202).json({
message: `Database export request received. You will receive an email with a download link shortly. Usage today: ${newCount}/${maxExports}.`,
});
Comment on lines +52 to +54
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Return the standardized API response shape.

This controller should return { success, data, message } instead of a message-only payload.

Proposed change
-        return res.status(202).json({
-            message: `Database export request received. You will receive an email with a download link shortly. Usage today: ${newCount}/${maxExports}.`,
-        });
+        return res.status(202).json({
+            success: true,
+            data: {
+                usage: {
+                    used: newCount,
+                    limit: maxExports,
+                    remaining: Math.max(0, maxExports - newCount),
+                },
+            },
+            message: `Database export request received. You will receive an email with a download link shortly.`,
+        });
As per coding guidelines, `**/src/controllers/**/*.{js,ts}` requires all API endpoints to return `{ success: bool, data: {}, message: "" }`.
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard-api/src/controllers/dbExport.controller.js` around lines 52 -
54, The response currently returns a message-only payload; update the
res.status(202).json call in the db export controller to follow the standardized
shape { success, data, message } β€” e.g. return res.status(202).json({ success:
true, data: { usageToday: `${newCount}/${maxExports}`, newCount, maxExports },
message: "Database export request received. You will receive an email with a
download link shortly." }); ensure you modify the existing return in the export
handler (the res.status(202).json(...) statement) so callers receive success,
data, and message fields.


} catch (err) {
console.error("[Dashboard API] Error handling export request for project - ", req.params.projectId, ": ", err);
return next(new AppError(500, err.message || "Failed to initiate database export."));
}
};
4 changes: 4 additions & 0 deletions apps/dashboard-api/src/routes/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const {

const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller');

const exportController = require('../controllers/dbExport.controller');

// POST REQ FOR CREATE PROJECT
router.post('/', authMiddleware, verifyEmail, planEnforcement.checkProjectLimit, createProject);
Expand Down Expand Up @@ -152,4 +153,7 @@ router.put('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmi
router.get('/:projectId/admin/users/:userId/sessions', authMiddleware, loadProjectForAdmin, checkAuthEnabled, listUserSessions);
router.delete('/:projectId/admin/users/:userId/sessions/:tokenId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, revokeUserSession);

// POST req for DB EXPORT
router.post('/:projectId/export', authMiddleware, exportController.dbExportHandler);

module.exports = router;
Loading
Loading