Skip to content
Open
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
3 changes: 1 addition & 2 deletions server/src/config/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ export function isOriginAllowed(
* Dynamically checks each request's origin against the allowlist.
*/
export function buildCorsOptions(): CorsOptions {
const allowedOrigins = getAllowedOrigins();
return {
origin: (
origin: string | undefined,
Expand All @@ -55,7 +54,7 @@ export function buildCorsOptions(): CorsOptions {
return;
}

if (isOriginAllowed(origin, allowedOrigins)) {
if (isOriginAllowed(origin, getAllowedOrigins())) {
callback(null, true);
} else {
callback(
Expand Down
50 changes: 30 additions & 20 deletions server/src/controllers/controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
validateListingMetadata,
} from "../services/listingValidation";
import { cacheGet, cacheSet, cacheDel, cacheDelPattern, CACHE_KEYS } from "../services/cacheService";
import { getCircuitBreaker } from "../services/circuitBreaker";
import { getCircuitBreaker, CircuitBreakerOpenError } from "../services/circuitBreaker";
import { isValidAdminToken } from "../services/adminAuth";
import { AppError } from "../lib/AppError";
import { asyncRoute } from "../lib/asyncRoute";
Expand All @@ -28,29 +28,39 @@ export const ImproveProxy = asyncRoute(async (req, res) => {

console.log("Improve prompt request: ", promptText);

const response = await improveProxyBreaker.execute(() =>
fetch(`${API_BASE_URL}/api/improve-prompt`, {
method: "POST",
headers: {
"Content-Type": "text/plain",
Accept: "application/json",
},
body: promptText,
signal: AbortSignal.timeout(10_000),
}),
);
try {
const response = await improveProxyBreaker.execute(() =>
fetch(`${API_BASE_URL}/api/improve-prompt`, {
method: "POST",
headers: {
"Content-Type": "text/plain",
Accept: "application/json",
},
body: promptText,
signal: AbortSignal.timeout(10_000),
}),
);

const responseData = await response.json().catch(() => {});
const responseText = await response.text().catch(() => {});
const responseData = await response.json().catch(() => {});
const responseText = await response.text().catch(() => {});

console.log("Improve prompt response status:", response.status);
console.log("Improve prompt response data:", responseData || responseText);
console.log("Improve prompt response status:", response.status);
console.log("Improve prompt response data:", responseData || responseText);

if (!response.ok) {
throw new AppError("API Error", response.status);
}
if (!response.ok) {
throw new AppError("API Error", response.status);
}

res.json(responseData);
res.json(responseData);
} catch (error) {
if (error instanceof CircuitBreakerOpenError) {
throw new AppError("Service Unavailable", 503, "CIRCUIT_OPEN");
}
if (error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted"))) {
throw new AppError("Gateway Timeout", 504, "GATEWAY_TIMEOUT");
}
throw error;
}
});

/* PROMPTS CONTROLLERS */
Expand Down
145 changes: 92 additions & 53 deletions server/src/controllers/versioningControllers.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import type { Request, Response } from "express";
import type { Request } from "express";
import crypto from "crypto";
import connectDb from "../db/connectDb";
import Prompt from "../models/Prompt";
import PromptVersion from "../models/PromptVersion";
import Purchase from "../models/Purchase";
import User from "../models/User";
import LicenseTerm from "../models/LicenseTerm";
import { AppError } from "../lib/AppError";
import { asyncRoute } from "../lib/asyncRoute";
import { recordMarketplaceTransaction } from "../services/transactionHistoryService";

export const PostPromptUpdate = asyncRoute(async (req, res) => {
import { enqueuePromptUpdateNotifications } from "../services/notificationService";

function getWalletAddress(req: Request): string | null {
Expand Down Expand Up @@ -48,9 +47,7 @@ export const PublishPromptVersion = asyncRoute(async (req, res) => {

const prompt = await Prompt.findById(promptId);
if (!prompt) throw new AppError("Prompt not found.", 404, "NOT_FOUND");
// debug: log identities when running tests to diagnose mock shapes
if (process.env.NODE_ENV === "test") {
// eslint-disable-next-line no-console
console.debug("debug-owner-check", { promptOwner: prompt.owner, userId: user._id, walletAddress });
}
const isOwner = String(prompt.owner) === String(user._id) ||
Expand Down Expand Up @@ -111,7 +108,6 @@ export const ListPromptVersions = asyncRoute(async (req, res) => {
throw new AppError("walletAddress is required to list versions.", 401, "UNAUTHENTICATED");
}

const prompt = await Prompt.findById(promptId).populate("owner", "walletAddress");
const user = await User.findOne({ walletAddress });
if (!user) throw new AppError("User not found.", 404, "NOT_FOUND");

Expand All @@ -123,20 +119,6 @@ export const ListPromptVersions = asyncRoute(async (req, res) => {
throw new AppError("Unauthorized to view prompt version history.", 403, "FORBIDDEN");
}

const termsVersion = prompt.termsVersion ?? 1;
const licenseTerm = await LicenseTerm.findOne({ version: termsVersion });

const purchase = await Purchase.create({
promptId,
buyerWallet: buyerWallet.toLowerCase(),
versionIndex: prompt.currentVersionIndex ?? 1,
txHash: txHash ?? "",
termsSnapshot: {
termsVersion,
termsTitle: licenseTerm?.title ?? "Standard License",
termsContent: licenseTerm?.content ?? "Standard marketplace license terms.",
acceptedAt: new Date(),
},
const versions = await PromptVersion.find(
{ promptId },
"versionIndex changelog createdAt contentHash",
Expand All @@ -163,21 +145,6 @@ export const GetPromptVersionDetail = asyncRoute(async (req, res) => {
throw new AppError("walletAddress is required to view version details.", 401, "UNAUTHENTICATED");
}

const termsVersion = prompt.termsVersion ?? 1;
const licenseTerm = await LicenseTerm.findOne({ version: termsVersion });

const purchase = await Purchase.create({
promptId,
buyerWallet: buyerWallet.toLowerCase(),
versionIndex: prompt.currentVersionIndex ?? 1,
txHash: txHash ?? "",
termsSnapshot: {
termsVersion,
termsTitle: licenseTerm?.title ?? "Standard License",
termsContent: licenseTerm?.content ?? "Standard marketplace license terms.",
acceptedAt: new Date(),
},
});
if (!Number.isInteger(versionIndex) || versionIndex < 1) {
throw new AppError("versionIndex must be a positive integer.", 400, "INVALID_VERSION");
}
Expand Down Expand Up @@ -208,42 +175,114 @@ export const GetPromptVersionDetail = asyncRoute(async (req, res) => {
});
});

export const PostPromptUpdate = asyncRoute(async (req, res) => {
await connectDb();
const promptId = String(req.params.id);
const walletAddress = getWalletAddress(req);
const { changelog = "" } = req.body;

if (!walletAddress) {
throw new AppError("walletAddress is required.", 401, "UNAUTHENTICATED");
}

const user = await User.findOne({ walletAddress });
if (!user) throw new AppError("User not found.", 404, "NOT_FOUND");

const prompt = await Prompt.findById(promptId);
if (!prompt) throw new AppError("Prompt not found.", 404, "NOT_FOUND");

const isOwner = String(prompt.owner) === String(user._id) ||
String(prompt.owner).toLowerCase() === String(walletAddress).toLowerCase();
if (!isOwner) {
throw new AppError("Prompt not found or not owned by this wallet.", 403, "FORBIDDEN");
}

const latestVersion = await PromptVersion.findOne({ promptId }, undefined, { sort: { versionIndex: -1 } });
const nextVersion = (latestVersion?.versionIndex ?? 0) + 1;

const createdVersion = await PromptVersion.create({
promptId,
versionIndex: nextVersion,
contentHash: computeContentHash(`update-${Date.now()}`),
encryptedPayloadRef: "",
changelog,
createdBy: walletAddress,
});

await Prompt.findByIdAndUpdate(promptId, { currentVersionIndex: nextVersion });

res.status(201).json({
id: String(createdVersion._id),
versionNumber: createdVersion.versionIndex,
changelog: createdVersion.changelog,
createdAt: createdVersion.createdAt,
});
});

export const GetPromptVersions = asyncRoute(async (req, res) => {
await connectDb();
const promptId = String(req.params.promptId || req.params.id);
if (!promptId) throw new AppError("promptId is required.", 400, "MISSING_FIELDS");

const ownerWallet =
prompt.owner && typeof prompt.owner === "object" && "walletAddress" in prompt.owner
? String((prompt.owner as { walletAddress?: string }).walletAddress ?? "")
: "";
const versions = await PromptVersion.find(
{ promptId },
"versionIndex changelog createdAt contentHash",
{ sort: { versionIndex: 1 } },
);

res.json(
versions.map((version) => ({
...version.toObject(),
versionNumber: version.versionIndex,
})),
);
});

export const RecordPurchase = asyncRoute(async (req, res) => {
await connectDb();
const { promptId, walletAddress, txHash = "" } = req.body;

if (!promptId || !walletAddress) {
throw new AppError("promptId and walletAddress are required.", 400, "MISSING_FIELDS");
}

const prompt = await Prompt.findById(promptId);
if (!prompt) throw new AppError("Prompt not found.", 404, "NOT_FOUND");

const termsVersion = prompt.termsVersion ?? 1;
const licenseTerm = await LicenseTerm.findOne({ version: termsVersion });

const purchase = await Purchase.create({
promptId,
buyerWallet: walletAddress.toLowerCase(),
versionIndex: prompt.currentVersionIndex ?? 1,
txHash,
termsSnapshot: {
termsVersion,
termsTitle: licenseTerm?.title ?? "Standard License",
termsContent: licenseTerm?.content ?? "Standard marketplace license terms.",
acceptedAt: new Date(),
},
});

const ownerWallet = typeof prompt.owner === "object" && prompt.owner !== null && "walletAddress" in prompt.owner
? String((prompt.owner as { walletAddress?: string }).walletAddress ?? "")
: "";

if (ownerWallet) {
await recordMarketplaceTransaction({
promptOnChainId: prompt.onChainId ?? String(prompt._id),
promptMongoId: String(prompt._id),
promptTitle: prompt.title,
buyerWallet: buyerWallet.toLowerCase(),
buyerWallet: walletAddress.toLowerCase(),
creatorWallet: ownerWallet,
priceStroops: Math.round(Number(prompt.price) * 10_000_000),
txHash: txHash ?? "",
txHash,
occurredAt: purchase.createdAt ?? new Date(),
});
}

res.status(201).json({ message: "Purchase recorded.", versionIndex: purchase.versionIndex });
const versions = await PromptVersion.find(
{ promptId },
"versionIndex changelog createdAt contentHash",
{ sort: { versionIndex: 1 } },
);

res.json(
versions.map((version) => ({
...version,
versionNumber: version.versionIndex,
})),
);
});

export const GetBuyerVersion = asyncRoute(async (req, res) => {
Expand Down
4 changes: 2 additions & 2 deletions server/src/lib/asyncRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ export function asyncRoute(fn: AsyncHandler) {
const status = (error && (error as any).httpStatus) || 500;
const code = (error && (error as any).code) || "INTERNAL_ERROR";
const message = (error && (error as any).message) || String(error);
(res as Response).status(status).json({ message, code });
(res as Response).status(status).json({ error: message, code });
return Promise.resolve();
}
} catch (e) {
} catch {
// fallthrough to rethrow
}
return Promise.reject(error);
Expand Down
9 changes: 3 additions & 6 deletions server/src/middleware/securityHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
// @vitest-environment node

import { Request, Response, NextFunction } from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { securityHeaders } from "./securityHeaders";

describe("securityHeaders middleware", () => {
Expand All @@ -15,13 +12,13 @@ describe("securityHeaders middleware", () => {
headers: {},
};
mockRes = {
setHeader: vi.fn(),
setHeader: jest.fn(),
};
mockNext = vi.fn();
mockNext = jest.fn();
});

afterEach(() => {
vi.clearAllMocks();
jest.clearAllMocks();
});

it("should set X-Content-Type-Options to nosniff", () => {
Expand Down
3 changes: 3 additions & 0 deletions server/src/models/MarketplaceTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ marketplaceTransactionSchema.index(
{ buyerWallet: 1, promptOnChainId: 1, txHash: 1 },
{ unique: true, sparse: true },
);
marketplaceTransactionSchema.index({ buyerWallet: 1, occurredAt: -1 });
marketplaceTransactionSchema.index({ creatorWallet: 1, occurredAt: -1 });
marketplaceTransactionSchema.index({ promptOnChainId: 1, occurredAt: -1 });

const MarketplaceTransaction =
mongoose.models.MarketplaceTransaction ||
Expand Down
12 changes: 12 additions & 0 deletions server/src/models/Prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ const promptSchema = new mongoose.Schema(
);
promptSchema.index({ title: 1 });

// Marketplace query patterns — compound indexes for common filters + sort
promptSchema.index({ listingStatus: 1, isActive: 1, createdAt: -1 });
promptSchema.index({ listingStatus: 1, isActive: 1, category: 1, createdAt: -1 });
promptSchema.index({ listingStatus: 1, isActive: 1, tags: 1, createdAt: -1 });
promptSchema.index({ listingStatus: 1, isActive: 1, price: 1, createdAt: -1 });
promptSchema.index({ listingStatus: 1, isActive: 1, rating: 1, createdAt: -1 });
promptSchema.index({ owner: 1, createdAt: -1 });
promptSchema.index({ owner: 1, listingStatus: 1, updatedAt: -1 });
promptSchema.index({ savedPrompts: 1, createdAt: -1 });
promptSchema.index({ owner: 1, previewCount: -1 });
promptSchema.index({ onChainId: 1, isActive: 1 });

// Check if the model exists before creating it
const Prompt = mongoose.models.Prompt || mongoose.model("Prompt", promptSchema);

Expand Down
2 changes: 2 additions & 0 deletions server/src/models/Purchase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const purchaseSchema = new mongoose.Schema(
);

purchaseSchema.index({ promptId: 1, buyerWallet: 1 });
purchaseSchema.index({ buyerWallet: 1, createdAt: -1 });
purchaseSchema.index({ promptId: 1, createdAt: -1 });

const Purchase = mongoose.models.Purchase || mongoose.model("Purchase", purchaseSchema);
export default Purchase;
4 changes: 2 additions & 2 deletions server/src/tests/apiKeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe("generateApiKey", () => {
it("produces a pm_<prefix>_<secret> plaintext and a matching hash", () => {
const key = generateApiKey();
expect(key.plaintext.startsWith("pm_")).toBe(true);
expect(key.plaintext.split("_")).toHaveLength(3);
expect(key.plaintext.split("_").length).toBeGreaterThanOrEqual(3);
expect(parseKeyPrefix(key.plaintext)).toBe(key.prefix);
expect(hashApiKey(key.plaintext)).toBe(key.hash);
});
Expand Down Expand Up @@ -82,7 +82,7 @@ describe("scopes and tiers", () => {

describe("InMemoryRateLimiter", () => {
it("allows up to the limit then blocks within the window", () => {
let now = 1000;
const now = 1000;
const limiter = new InMemoryRateLimiter(60_000, () => now);
expect(limiter.check("k", 2).allowed).toBe(true);
expect(limiter.check("k", 2).allowed).toBe(true);
Expand Down
2 changes: 0 additions & 2 deletions server/src/tests/categoryService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { describe, it, expect, beforeEach, vi } from "vitest";

describe("Category Service", () => {
const mockCategories = [
{
Expand Down
Loading