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
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.toLowerCase() === "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);
}
};
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
81 changes: 81 additions & 0 deletions sdks/urbackend-cli/src/commands/push/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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;
}
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Unable to connect to the urBackend API. Error: ${errorMessage}`);
process.exitCode = 1;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Loading
Loading