From 9533526ffe13fc94ceedb0ba9f8bfe5f0907cb80 Mon Sep 17 00:00:00 2001 From: Ayush Date: Sat, 11 Jul 2026 06:21:14 +0530 Subject: [PATCH 1/9] added ub push in cli --- sdks/urbackend-cli/src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sdks/urbackend-cli/src/index.ts b/sdks/urbackend-cli/src/index.ts index 061d9286..c24a33b2 100644 --- a/sdks/urbackend-cli/src/index.ts +++ b/sdks/urbackend-cli/src/index.ts @@ -11,6 +11,7 @@ import { statusCommand } from "./commands/status/index.js"; import { doctorCommand } from "./commands/doctor/index.js"; import { initCommand } from "./commands/init/index.js"; import { pullCommand } from "./commands/pull/index.js"; +import { pushCommand } from "./commands/push/index.js"; import { generateCommand } from "./commands/generate/index.js"; const program = new Command(); @@ -108,6 +109,11 @@ program .description("Fetch the latest schemas for the linked project") .action(pullCommand); +program + .command("push") + .description("Push local schemas to the remote project") + .action(pushCommand); + program .command("generate") .description("Generate TypeScript definitions from local schemas") From 9f17e02f816fec1db673f74f0c029cd9a79f94cc Mon Sep 17 00:00:00 2001 From: Ayush Date: Sat, 11 Jul 2026 06:22:00 +0530 Subject: [PATCH 2/9] created the ub push command --- sdks/urbackend-cli/src/commands/push/index.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 sdks/urbackend-cli/src/commands/push/index.ts diff --git a/sdks/urbackend-cli/src/commands/push/index.ts b/sdks/urbackend-cli/src/commands/push/index.ts new file mode 100644 index 00000000..fed124ee --- /dev/null +++ b/sdks/urbackend-cli/src/commands/push/index.ts @@ -0,0 +1,80 @@ +import { loadWorkspaceConfig, getLocalSchemas } from "../../core/workspace.js"; +import { syncSchema } from "../../services/project.service.js"; +import { getToken } from "../../core/config.js"; +import { APIError } from "../../core/errors.js"; +import { logger } from "../../core/logger.js"; +import { label } from "../../utils/format.js"; + +export async function pushCommand(): Promise { + const token = getToken(); + if (!token) { + logger.error("You are not logged in. Run 'ub login' first."); + process.exitCode = 1; + return; + } + + const workspaceConfig = loadWorkspaceConfig(); + if (!workspaceConfig || !workspaceConfig.projectId) { + logger.error( + "No project linked to this directory. Run 'ub init' to link a project first." + ); + process.exitCode = 1; + return; + } + + const { projectId, projectName } = workspaceConfig; + + // Read all .json files from .ub/schemas/ + const localSchemas = getLocalSchemas(); + + if (localSchemas.length === 0) { + logger.error( + "No schema files found in .ub/schemas/. Run 'ub pull' first to fetch schemas, or create them manually." + ); + process.exitCode = 1; + return; + } + + // Build the payload from local schema files + const collections = localSchemas.map((entry) => ({ + name: entry.schema.name ?? entry.name, + model: entry.schema.model ?? [], + })); + + logger.info( + `Pushing ${collections.length} schema(s) to ${projectName ? projectName + " " : ""}(${projectId})...` + ); + + try { + const result = await syncSchema(projectId, collections); + + logger.success( + `Successfully synced ${result.synced} collection schema(s) to remote.` + ); + console.log(); + for (const name of result.collections) { + console.log(` ${label("synced")} ${name}`); + } + console.log( + `\nRemote project is now up to date with your local schemas.` + ); + } catch (error) { + if (error instanceof APIError) { + if (error.status === 401) { + logger.error( + "Token is invalid or expired. Run 'ub login' to re-authenticate." + ); + } else if (error.status === 403) { + logger.error(error.message || "You do not have permission to sync schemas for this project."); + } else if (error.status === 400 || error.status === 422) { + logger.error(`Schema validation failed: ${error.message}`); + } else { + logger.error(error.message); + } + process.exitCode = 1; + return; + } + logger.error("Unable to connect to the urBackend API."); + process.exitCode = 1; + } +} From 0a81c4ec673e7eda4c0bfcf007d69dd6a8cdfea6 Mon Sep 17 00:00:00 2001 From: Ayush Date: Sat, 11 Jul 2026 06:26:14 +0530 Subject: [PATCH 3/9] function send collection payload --- .../src/services/project.service.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/sdks/urbackend-cli/src/services/project.service.ts b/sdks/urbackend-cli/src/services/project.service.ts index 3b8aa1f6..589eed33 100644 --- a/sdks/urbackend-cli/src/services/project.service.ts +++ b/sdks/urbackend-cli/src/services/project.service.ts @@ -42,4 +42,23 @@ export async function deleteProject(projectId: string): Promise { return apiFetch(`/projects/${projectId}`, { method: "DELETE", }); +} + +export interface SyncSchemaResult { + synced: number; + collections: string[]; +} + +export async function syncSchema( + projectId: string, + collections: { name: string; model: any[] }[], +): Promise { + const res = await apiFetch>( + `/projects/${projectId}/sync-schema`, + { + method: "PUT", + body: JSON.stringify({ collections }), + }, + ); + return res.data; } \ No newline at end of file From 9e9507be1e54d0de1e7e1ffe75b527d21a6e6d24 Mon Sep 17 00:00:00 2001 From: Ayush Date: Sat, 11 Jul 2026 06:34:28 +0530 Subject: [PATCH 4/9] created & added the syncschemapayload --- packages/common/src/index.js | 2 ++ packages/common/src/utils/input.validation.js | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/common/src/index.js b/packages/common/src/index.js index d6c7e575..4c983981 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -91,6 +91,7 @@ const { createWebhookSchema, updateWebhookSchema, sendMailSchema, + syncSchemaPayload, sanitizeObjectId, sanitizeNonEmptyString, } = require("./utils/input.validation"); @@ -176,6 +177,7 @@ module.exports = { createWebhookSchema, updateWebhookSchema, sendMailSchema, + syncSchemaPayload, sanitizeObjectId, sanitizeNonEmptyString, garbageCollect, diff --git a/packages/common/src/utils/input.validation.js b/packages/common/src/utils/input.validation.js index 70487791..8353175a 100755 --- a/packages/common/src/utils/input.validation.js +++ b/packages/common/src/utils/input.validation.js @@ -234,6 +234,37 @@ module.exports.createCollectionSchema = z.object({ schema: z.array(fieldSchemaZod).optional(), }); +// SYNC SCHEMA (CLI) +// Validates the full collections array sent by `ub push`. +// Each entry carries a name + model (field definitions). +// RLS is optional but the controller preserves existing RLS for +// known collections and applies safe defaults for new ones. +const syncCollectionEntrySchema = z.object({ + name: z + .string() + .min(1, "Collection name is required") + .max(64, "Collection name is too long") + .regex( + /^[a-zA-Z_][a-zA-Z0-9_-]*$/, + "Collection name must start with a letter or underscore and contain only alphanumeric characters, hyphens, or underscores", + ), + model: z.array(fieldSchemaZod).default([]), +}); + +module.exports.syncSchemaPayload = z.object({ + collections: z + .array(syncCollectionEntrySchema) + .min(1, "At least one collection is required") + .max(100, "Cannot sync more than 100 collections at once") + .refine( + (cols) => { + const names = cols.map((c) => c.name); + return new Set(names).size === names.length; + }, + { message: "Duplicate collection names are not allowed" }, + ), +}); + // SCHEMA - CREATE COLLECTION (API) const buildApiFieldSchemaZod = (depth = 1) => { const base = z From b2ece8fbfd9fee2702df7609445e26c6cd727a02 Mon Sep 17 00:00:00 2001 From: Ayush Date: Sat, 11 Jul 2026 07:12:27 +0530 Subject: [PATCH 5/9] controller for schema synching --- .../src/controllers/syncSchema.controller.js | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 apps/dashboard-api/src/controllers/syncSchema.controller.js diff --git a/apps/dashboard-api/src/controllers/syncSchema.controller.js b/apps/dashboard-api/src/controllers/syncSchema.controller.js new file mode 100644 index 00000000..97ddb1cc --- /dev/null +++ b/apps/dashboard-api/src/controllers/syncSchema.controller.js @@ -0,0 +1,230 @@ +const { z } = require("zod"); +const { + Project, + AppError, + ApiResponse, + syncSchemaPayload, + resolveEffectivePlan, + getPlanLimits, + deleteProjectById, + deleteProjectByApiKeyCache, + setProjectById, + getProjectAccessQuery, +} = require("@urbackend/common"); + +// ── Helpers (mirrored from project.controller.js) ─────────────────────────── + +const normalizeFieldKey = (key) => + String(key || "") + .replace(/\uFEFF/g, "") + .trim(); + +const normalizeFieldType = (type) => + String(type || "") + .trim() + .toLowerCase(); + +const isRequiredField = (required) => + required === true || + required === 1 || + String(required).trim().toLowerCase() === "true" || + String(required).trim() === "1"; + +const toPlainObject = (value) => { + if (!value || typeof value !== "object") return value; + if (typeof value.toObject === "function") { + return value.toObject({ depopulate: true }); + } + if (value._doc && typeof value._doc === "object") { + return { ...value._doc }; + } + return value; +}; + +const sanitizeSchemaFields = (schema = []) => { + if (!Array.isArray(schema)) return []; + return schema + .map((rawField) => { + const field = toPlainObject(rawField); + if (!field || typeof field !== "object") return null; + + const normalizedKey = normalizeFieldKey(field.key); + if (!normalizedKey) return null; + + const next = { ...field, key: normalizedKey }; + if (field.default !== undefined) { + next.default = field.default; + } + + if (Array.isArray(field.fields)) { + next.fields = sanitizeSchemaFields(field.fields); + } + + if (field.items && typeof field.items === "object") { + next.items = { ...field.items }; + if (Array.isArray(field.items.fields)) { + next.items.fields = sanitizeSchemaFields(field.items.fields); + } + } + + return next; + }) + .filter(Boolean); +}; + +const getDefaultRlsForCollection = (collectionName, schema = []) => { + const normalizedName = String(collectionName || "").toLowerCase(); + const keys = sanitizeSchemaFields(schema).map((f) => f.key); + + let ownerField = "userId"; + if (normalizedName === "users") { + ownerField = "_id"; + } else if (keys.includes("userId")) { + ownerField = "userId"; + } else if (keys.includes("ownerId")) { + ownerField = "ownerId"; + } + + return { + enabled: false, + mode: "public-read", + ownerField, + requireAuthForWrite: true, + }; +}; + +/** + * Validates that a users collection schema contains the required + * `email` (String, required) and `password` (String, required) fields. + */ +const validateUsersSchema = (schema) => { + if (!Array.isArray(schema)) return false; + const sanitized = sanitizeSchemaFields(schema); + + const hasEmail = sanitized.find( + (f) => + normalizeFieldKey(f.key).toLowerCase() === "email" && + normalizeFieldType(f.type) === "string" && + isRequiredField(f.required), + ); + + const hasPassword = sanitized.find( + (f) => + normalizeFieldKey(f.key).toLowerCase() === "password" && + normalizeFieldType(f.type) === "string" && + isRequiredField(f.required), + ); + + return !!(hasEmail && hasPassword); +}; + +// ── Controller ────────────────────────────────────────────────────────────── + +/** + * PUT /projects/:projectId/sync-schema + * + * Atomically replaces the project's collection schema definitions. + * + * Behaviour: + * - Preserves RLS settings for collections that already exist. + * - Applies safe defaults for newly introduced collections. + * - Enforces plan-based collection limits. + * - Validates the `users` collection contract (email + password required). + * - Does NOT drop underlying MongoDB collections — only updates config. + */ +module.exports.syncSchema = async (req, res, next) => { + try { + // 1. Validate payload + const { collections: incoming } = syncSchemaPayload.parse(req.body); + + const { projectId } = req.params; + + // 2. Load project (authorizeProject middleware already attached req.project) + const project = req.project; + if (!project) { + return next(new AppError(404, "Project not found or access denied")); + } + + // 3. Plan enforcement — check collection count limit + if (req.developer) { + const effectivePlan = resolveEffectivePlan(req.developer); + const limits = getPlanLimits({ + plan: effectivePlan, + customLimits: project.customLimits, + }); + + if (limits.maxCollections !== -1 && incoming.length > limits.maxCollections) { + return next( + new AppError( + 403, + `Schema sync would create ${incoming.length} collections, but your plan allows ${limits.maxCollections}. Please upgrade your plan.`, + ), + ); + } + } + + // 4. Build a lookup of existing collections for RLS preservation + const existingByName = new Map(); + for (const col of project.collections || []) { + const plain = toPlainObject(col); + existingByName.set(plain.name, plain); + } + + // 5. Merge: sanitize fields, preserve RLS, validate users contract + const merged = []; + for (const entry of incoming) { + const sanitizedModel = sanitizeSchemaFields(entry.model || []); + + // Enforce users schema contract + if (entry.name === "users") { + if (!validateUsersSchema(sanitizedModel)) { + return next( + new AppError( + 422, + "The 'users' collection must have required 'email' and 'password' String fields.", + ), + ); + } + } + + const existing = existingByName.get(entry.name); + + merged.push({ + name: entry.name, + model: sanitizedModel, + rls: existing?.rls || getDefaultRlsForCollection(entry.name, sanitizedModel), + }); + } + + // 6. Atomic update — single $set, no partial states + const updated = await Project.findOneAndUpdate( + { _id: projectId, ...getProjectAccessQuery(req.user._id) }, + { $set: { collections: merged } }, + { new: true }, + ); + + if (!updated) { + return next(new AppError(404, "Project not found or access denied")); + } + + // 7. Invalidate caches so subsequent reads are fresh + await deleteProjectById(projectId); + await setProjectById(projectId, updated.toObject()); + await deleteProjectByApiKeyCache(updated.publishableKey); + await deleteProjectByApiKeyCache(updated.secretKey); + + // 8. Respond + return new ApiResponse( + { + synced: merged.length, + collections: merged.map((c) => c.name), + }, + `Successfully synced ${merged.length} collection schema(s).`, + ).send(res); + } catch (err) { + if (err instanceof z.ZodError) { + return next(new AppError(400, err.issues[0]?.message || "Invalid schema payload")); + } + next(err); + } +}; From 26942d092118527ce1ef7547974f39bab866230d Mon Sep 17 00:00:00 2001 From: Ayush Date: Sat, 11 Jul 2026 07:13:40 +0530 Subject: [PATCH 6/9] added route for sync schema --- apps/dashboard-api/src/routes/projects.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/dashboard-api/src/routes/projects.js b/apps/dashboard-api/src/routes/projects.js index 7dc8988f..336d0d8a 100644 --- a/apps/dashboard-api/src/routes/projects.js +++ b/apps/dashboard-api/src/routes/projects.js @@ -59,6 +59,7 @@ const { const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listAdminUsers, deleteAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller'); const exportController = require('../controllers/dbExport.controller'); +const { syncSchema } = require('../controllers/syncSchema.controller'); // POST REQ FOR CREATE PROJECT router.post('/', authMiddleware, planEnforcement.attachDeveloper, planEnforcement.checkProjectLimit, planEnforcement.checkDeveloperCapability('createProject'), createProject); @@ -173,4 +174,7 @@ router.delete('/:projectId/admin/users/:userId/sessions/:tokenId', authMiddlewar // POST req for DB EXPORT router.post('/:projectId/collections/:collectionName/export', authMiddleware, authorizeProject(), exportController.dbExportHandler); +// PUT REQ FOR SCHEMA SYNC (CLI) +router.put('/:projectId/sync-schema', authFlexible, authorizeProject('admin'), planEnforcement.attachDeveloper, syncSchema); + module.exports = router; From b957cc30bf4a1df7f46091f6a442561482c9f108 Mon Sep 17 00:00:00 2001 From: Ayush Date: Sat, 11 Jul 2026 07:17:17 +0530 Subject: [PATCH 7/9] added the restriction for sync schema --- apps/dashboard-api/src/app.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/dashboard-api/src/app.js b/apps/dashboard-api/src/app.js index 77c7c95a..4d99074e 100644 --- a/apps/dashboard-api/src/app.js +++ b/apps/dashboard-api/src/app.js @@ -81,7 +81,13 @@ app.use((req, res, next) => { // Exclude CLI routes — CLI authenticates via Bearer PAT, not cookies const isCliRoute = req.path === '/api/user/cli' || req.path.startsWith('/api/user/cli/'); - if (req.path === '/api/billing/webhook' || isCliRoute) { + + // Bearer PAT requests are immune to CSRF by design (no cookies involved), + // so skip CSRF validation for any request carrying a PAT token. + const authHeader = req.headers.authorization || ''; + const isPATRequest = authHeader.startsWith('Bearer ubpat_'); + + if (req.path === '/api/billing/webhook' || isCliRoute || isPATRequest) { return next(); } csrfProtection(req, res, next); From 88c05dbd37360801776b28c07a11835fe34009fc Mon Sep 17 00:00:00 2001 From: Ayush Date: Sat, 11 Jul 2026 08:52:35 +0530 Subject: [PATCH 8/9] fix(sdk): resolve eslint any type and unused var errors --- sdks/urbackend-sdk/src/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdks/urbackend-sdk/src/client.ts b/sdks/urbackend-sdk/src/client.ts index 86b01710..501bd084 100644 --- a/sdks/urbackend-sdk/src/client.ts +++ b/sdks/urbackend-sdk/src/client.ts @@ -121,7 +121,7 @@ export class UrBackendClient { if (response.status === 401 && path !== '/api/userAuth/refresh-token' && !options.token) { try { const refreshRes = await this.auth.refreshToken(); - const newToken = refreshRes.token || (refreshRes as any).accessToken; + const newToken = refreshRes.token || (refreshRes as { accessToken?: string }).accessToken; if (newToken) { headers['Authorization'] = `Bearer ${newToken}`; if (typeof window !== 'undefined') { @@ -134,7 +134,7 @@ export class UrBackendClient { credentials: options.credentials, }); } - } catch (e) { + } catch { // If refresh fails, fall through to throw original 401 } } From a80ae5ebce2e489576dd9d832feca0da37d0da78 Mon Sep 17 00:00:00 2001 From: Ayush Date: Sat, 11 Jul 2026 21:03:26 +0530 Subject: [PATCH 9/9] Fix for code rabbit AI review --- apps/dashboard-api/src/controllers/syncSchema.controller.js | 2 +- sdks/urbackend-cli/src/commands/push/index.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/dashboard-api/src/controllers/syncSchema.controller.js b/apps/dashboard-api/src/controllers/syncSchema.controller.js index 97ddb1cc..e167ae44 100644 --- a/apps/dashboard-api/src/controllers/syncSchema.controller.js +++ b/apps/dashboard-api/src/controllers/syncSchema.controller.js @@ -176,7 +176,7 @@ module.exports.syncSchema = async (req, res, next) => { const sanitizedModel = sanitizeSchemaFields(entry.model || []); // Enforce users schema contract - if (entry.name === "users") { + if (entry.name.toLowerCase() === "users") { if (!validateUsersSchema(sanitizedModel)) { return next( new AppError( diff --git a/sdks/urbackend-cli/src/commands/push/index.ts b/sdks/urbackend-cli/src/commands/push/index.ts index fed124ee..ae1d12c4 100644 --- a/sdks/urbackend-cli/src/commands/push/index.ts +++ b/sdks/urbackend-cli/src/commands/push/index.ts @@ -74,7 +74,8 @@ export async function pushCommand(): Promise { process.exitCode = 1; return; } - logger.error("Unable to connect to the urBackend API."); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Unable to connect to the urBackend API. Error: ${errorMessage}`); process.exitCode = 1; } }