Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
8 changes: 7 additions & 1 deletion apps/dashboard-api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
230 changes: 230 additions & 0 deletions apps/dashboard-api/src/controllers/syncSchema.controller.js
Original file line number Diff line number Diff line change
@@ -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") {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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);
}
};
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 @@ -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);
Expand Down Expand Up @@ -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;
2 changes: 2 additions & 0 deletions packages/common/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const {
createWebhookSchema,
updateWebhookSchema,
sendMailSchema,
syncSchemaPayload,
sanitizeObjectId,
sanitizeNonEmptyString,
} = require("./utils/input.validation");
Expand Down Expand Up @@ -176,6 +177,7 @@ module.exports = {
createWebhookSchema,
updateWebhookSchema,
sendMailSchema,
syncSchemaPayload,
sanitizeObjectId,
sanitizeNonEmptyString,
garbageCollect,
Expand Down
31 changes: 31 additions & 0 deletions packages/common/src/utils/input.validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions sdks/urbackend-cli/src/commands/push/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Loading
Loading