Skip to content
Merged
5 changes: 0 additions & 5 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 78 additions & 0 deletions backend/src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
isValidStellarPublicKey,
createPortfolioSchema,
rebalancePortfolioSchema,
updatePortfolioSchema,
} from "./validation.js";
import { validateRequest } from "../middleware/validate.js";
import { getPortfolioCheckQueue } from "../queue/queues.js";
Expand Down Expand Up @@ -173,6 +174,83 @@ router.get("/portfolio/:id", async (req, res) => {
}
});

// Update a portfolio's allocations or threshold. Ownership check ensures
// only the user who created the portfolio can modify it.
router.put(
"/portfolio/:id",
portfolioWriteRateLimiter,
validateRequest(updatePortfolioSchema),
async (req, res) => {
try {
const { id } = req.params;

const existing = portfolioStorage.getPortfolio(id);
if (!existing) {
return res.status(404).json({ error: "Portfolio not found" });
}

// Ownership check — the caller must be the portfolio owner
const callerAddress = req.headers["x-public-key"] as string | undefined;
if (!callerAddress) {
return res.status(401).json({ error: "X-Public-Key header is required" });
}
if (existing.userAddress !== callerAddress) {
return res.status(403).json({ error: "Not authorized to modify this portfolio" });
}

const updates: Record<string, unknown> = {};
if (req.body.allocations !== undefined) updates.allocations = req.body.allocations;
if (req.body.threshold !== undefined) updates.threshold = req.body.threshold;

portfolioStorage.updatePortfolio(id, updates);

const updated = portfolioStorage.getPortfolio(id);
res.json({ success: true, portfolio: updated });
} catch (error) {
console.error("[ERROR] Failed to update portfolio:", error);
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
},
);

// Delete a portfolio. Ownership check ensures only the creator can remove it.
// Returns 204 No Content on success, 404 if not found, 403 if not the owner.
router.delete(
"/portfolio/:id",
portfolioWriteRateLimiter,
async (req, res) => {
try {
const { id } = req.params;

const existing = portfolioStorage.getPortfolio(id);
if (!existing) {
return res.status(404).json({ error: "Portfolio not found" });
}

// Ownership check
const callerAddress = req.headers["x-public-key"] as string | undefined;
if (!callerAddress) {
return res.status(401).json({ error: "X-Public-Key header is required" });
}
if (existing.userAddress !== callerAddress) {
return res.status(403).json({ error: "Not authorized to delete this portfolio" });
}

portfolioStorage.deletePortfolio(id);
res.status(204).send();
} catch (error) {
console.error("[ERROR] Failed to delete portfolio:", error);
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
},
);

// Trigger a rebalance via stellarService.executeRebalance, which already
// handles the risk checks, cooldown, circuit breakers and DEX execution.
// Only slippageOverrides is wired through — simulateOnly and
Expand Down
17 changes: 17 additions & 0 deletions backend/src/api/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,22 @@ export const recordRebalanceEventSchema = z.object({
isSimulated: strictBoolean.optional()
}).strict();

// Schema for PUT /portfolio/:id — partial updates, at least one field required
export const updatePortfolioSchema = z.object({
allocations: z.record(z.string(), z.number().min(0).max(100)).refine(
(allocations) => {
const total = Object.values(allocations).reduce((sum, val) => sum + val, 0);
return Math.abs(total - 100) <= 0.01;
},
{
message: "Allocations must sum to 100%",
}
).optional(),
threshold: z.number().min(1, "Threshold must be between 1% and 50%").max(50, "Threshold must be between 1% and 50%").optional(),
}).strict().refine(
(data) => data.allocations !== undefined || data.threshold !== undefined,
{ message: "At least one of allocations or threshold must be provided" }
);

// Auto-Rebalancer control schemas (must be entirely empty payloads)
export const autoRebalancerControlSchema = z.object({}).strict();
Loading
Loading