diff --git a/.changeset/cuddly-terms-travel.md b/.changeset/cuddly-terms-travel.md new file mode 100644 index 000000000..ba2665512 --- /dev/null +++ b/.changeset/cuddly-terms-travel.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds reviews and ratings to the marketplace. Users can submit star ratings (1-5) with optional text reviews. Plugin owners can reply to reviews. Ratings are shown on plugin detail pages after 3+ reviews. diff --git a/packages/marketplace/src/app.ts b/packages/marketplace/src/app.ts index 05ab3f4f8..1d256d343 100644 --- a/packages/marketplace/src/app.ts +++ b/packages/marketplace/src/app.ts @@ -10,6 +10,7 @@ import { authorRoutes } from "./routes/author.js"; import { devRoutes } from "./routes/dev.js"; import { imageRoutes } from "./routes/images.js"; import { publicRoutes } from "./routes/public.js"; +import { reviewRoutes } from "./routes/reviews.js"; import { statsRoutes } from "./routes/stats.js"; import { themeRoutes } from "./routes/themes.js"; @@ -28,6 +29,7 @@ app.get("/health", (c) => c.json({ status: "ok" })); app.route("/api/v1", publicRoutes); app.route("/api/v1", authorRoutes); +app.route("/api/v1", reviewRoutes); app.route("/api/v1", themeRoutes); app.route("/api/v1", statsRoutes); app.route("/api/v1", imageRoutes); diff --git a/packages/marketplace/src/db/queries.ts b/packages/marketplace/src/db/queries.ts index dfd12926c..076b522ab 100644 --- a/packages/marketplace/src/db/queries.ts +++ b/packages/marketplace/src/db/queries.ts @@ -6,6 +6,7 @@ import type { PluginSearchResult, PluginVersionRow, PluginWithAuthor, + ReviewWithAuthor, SearchOptions, ThemeRow, ThemeSearchOptions, @@ -97,6 +98,10 @@ export async function searchPlugins( case "updated": orderBy = "p.updated_at DESC"; break; + case "rating": + orderBy = + "CASE WHEN p.rating_count >= 3 THEN p.rating_avg END DESC, p.rating_count DESC, p.created_at DESC"; + break; case "installs": default: orderBy = "p.install_count DESC, p.created_at DESC"; @@ -198,6 +203,201 @@ export async function getPluginVersion( .first(); } +// ── Review queries ────────────────────────────────────────────── + +/** Strip HTML/script tags from review text. Plain text only. */ +const HTML_TAG_RE = /<[^>]*>/g; + +function sanitizeReviewText(text: string): string { + return text.replace(HTML_TAG_RE, "").trim(); +} + +export async function getPluginReviews( + db: D1Database, + pluginId: string, + opts?: { cursor?: string; limit?: number }, +): Promise<{ items: ReviewWithAuthor[]; nextCursor?: string }> { + const limit = clampLimit(opts?.limit); + const offset = decodeCursor(opts?.cursor); + + const result = await db + .prepare( + `SELECT r.*, a.name AS author_name, a.avatar_url AS author_avatar_url + FROM reviews r + JOIN authors a ON a.id = r.author_id + WHERE r.plugin_id = ? + ORDER BY r.created_at DESC + LIMIT ? OFFSET ?`, + ) + .bind(pluginId, limit + 1, offset) + .all(); + + const items = result.results ?? []; + let nextCursor: string | undefined; + if (items.length > limit) { + items.pop(); + nextCursor = encodeCursor(offset + limit); + } + + return { items, nextCursor }; +} + +export async function createReview( + db: D1Database, + data: { pluginId: string; authorId: string; rating: number; body?: string }, +): Promise { + const id = generateId(); + const sanitizedBody = data.body ? sanitizeReviewText(data.body) : null; + + await db + .prepare( + `INSERT INTO reviews (id, plugin_id, author_id, rating, body) + VALUES (?, ?, ?, ?, ?)`, + ) + .bind(id, data.pluginId, data.authorId, data.rating, sanitizedBody) + .run(); + + // Atomically recalculate rating on the plugins table + await recalculatePluginRating(db, data.pluginId); + + return (await db + .prepare( + `SELECT r.*, a.name AS author_name, a.avatar_url AS author_avatar_url + FROM reviews r JOIN authors a ON a.id = r.author_id + WHERE r.id = ?`, + ) + .bind(id) + .first())!; +} + +export async function updateReview( + db: D1Database, + reviewId: string, + authorId: string, + data: { rating?: number; body?: string }, + pluginId?: string, +): Promise { + const existing = await db + .prepare("SELECT * FROM reviews WHERE id = ? AND author_id = ?") + .bind(reviewId, authorId) + .first<{ id: string; plugin_id: string }>(); + + if (!existing) return null; + if (pluginId && existing.plugin_id !== pluginId) return null; + + const sets: string[] = []; + const bindings: unknown[] = []; + + if (data.rating !== undefined) { + sets.push("rating = ?"); + bindings.push(data.rating); + } + if (data.body !== undefined) { + sets.push("body = ?"); + bindings.push(sanitizeReviewText(data.body)); + } + + if (sets.length === 0) { + return db + .prepare( + `SELECT r.*, a.name AS author_name, a.avatar_url AS author_avatar_url + FROM reviews r JOIN authors a ON a.id = r.author_id WHERE r.id = ?`, + ) + .bind(reviewId) + .first(); + } + + sets.push("updated_at = datetime('now')"); + bindings.push(reviewId); + + await db + .prepare(`UPDATE reviews SET ${sets.join(", ")} WHERE id = ?`) + .bind(...bindings) + .run(); + + await recalculatePluginRating(db, existing.plugin_id); + + return db + .prepare( + `SELECT r.*, a.name AS author_name, a.avatar_url AS author_avatar_url + FROM reviews r JOIN authors a ON a.id = r.author_id WHERE r.id = ?`, + ) + .bind(reviewId) + .first(); +} + +export async function deleteReview( + db: D1Database, + reviewId: string, + authorId: string, + pluginId?: string, +): Promise<{ deleted: boolean; pluginId?: string }> { + const existing = await db + .prepare("SELECT plugin_id FROM reviews WHERE id = ? AND author_id = ?") + .bind(reviewId, authorId) + .first<{ plugin_id: string }>(); + + if (!existing) return { deleted: false }; + if (pluginId && existing.plugin_id !== pluginId) return { deleted: false }; + + await db.prepare("DELETE FROM reviews WHERE id = ?").bind(reviewId).run(); + await recalculatePluginRating(db, existing.plugin_id); + + return { deleted: true, pluginId: existing.plugin_id }; +} + +export async function addPublisherReply( + db: D1Database, + reviewId: string, + pluginOwnerId: string, + reply: string, + pluginId?: string, +): Promise { + // Verify the review exists and the caller owns the plugin + const review = await db + .prepare( + `SELECT r.plugin_id FROM reviews r + JOIN plugins p ON p.id = r.plugin_id + WHERE r.id = ? AND p.author_id = ?`, + ) + .bind(reviewId, pluginOwnerId) + .first<{ plugin_id: string }>(); + + if (!review) return null; + if (pluginId && review.plugin_id !== pluginId) return null; + + const sanitizedReply = sanitizeReviewText(reply); + if (!sanitizedReply) return null; + + await db + .prepare( + "UPDATE reviews SET publisher_reply = ?, replied_at = datetime('now'), updated_at = datetime('now') WHERE id = ?", + ) + .bind(sanitizedReply, reviewId) + .run(); + + return db + .prepare( + `SELECT r.*, a.name AS author_name, a.avatar_url AS author_avatar_url + FROM reviews r JOIN authors a ON a.id = r.author_id WHERE r.id = ?`, + ) + .bind(reviewId) + .first(); +} + +/** Atomically recalculate rating_avg and rating_count from the reviews table. */ +async function recalculatePluginRating(db: D1Database, pluginId: string): Promise { + await db + .prepare( + `UPDATE plugins SET + rating_avg = COALESCE((SELECT AVG(CAST(rating AS REAL)) FROM reviews WHERE plugin_id = ?), 0), + rating_count = (SELECT COUNT(*) FROM reviews WHERE plugin_id = ?) + WHERE id = ?`, + ) + .bind(pluginId, pluginId, pluginId) + .run(); +} + // ── Install queries ───────────────────────────────────────────── export async function upsertInstall( diff --git a/packages/marketplace/src/db/schema.sql b/packages/marketplace/src/db/schema.sql index 8049b2256..3143248f3 100644 --- a/packages/marketplace/src/db/schema.sql +++ b/packages/marketplace/src/db/schema.sql @@ -20,6 +20,8 @@ CREATE TABLE IF NOT EXISTS plugins ( keywords TEXT, has_icon INTEGER DEFAULT 0, install_count INTEGER NOT NULL DEFAULT 0, + rating_avg REAL NOT NULL DEFAULT 0, + rating_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -87,6 +89,25 @@ CREATE TABLE IF NOT EXISTS installs ( ); CREATE INDEX IF NOT EXISTS idx_installs_plugin ON installs(plugin_id); +-- ── Reviews ───────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS reviews ( + id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL REFERENCES plugins(id), + author_id TEXT NOT NULL REFERENCES authors(id), + rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5), + body TEXT, + publisher_reply TEXT, + replied_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(plugin_id, author_id) +); +CREATE INDEX IF NOT EXISTS idx_reviews_plugin ON reviews(plugin_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(author_id); + +-- ── Themes ────────────────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS themes ( id TEXT PRIMARY KEY, name TEXT NOT NULL, diff --git a/packages/marketplace/src/db/types.ts b/packages/marketplace/src/db/types.ts index 2e45b0dea..3118ec4cf 100644 --- a/packages/marketplace/src/db/types.ts +++ b/packages/marketplace/src/db/types.ts @@ -20,10 +20,29 @@ export interface PluginRow { keywords: string | null; has_icon: number; install_count: number; + rating_avg: number; + rating_count: number; created_at: string; updated_at: string; } +export interface ReviewRow { + id: string; + plugin_id: string; + author_id: string; + rating: number; + body: string | null; + publisher_reply: string | null; + replied_at: string | null; + created_at: string; + updated_at: string; +} + +export interface ReviewWithAuthor extends ReviewRow { + author_name: string; + author_avatar_url: string | null; +} + export type VersionStatus = "pending" | "published" | "flagged" | "rejected"; export interface PluginVersionRow { @@ -94,7 +113,7 @@ export interface PluginSearchResult extends PluginWithAuthor { latest_audit_risk_score: number | null; } -export type SortOption = "installs" | "updated" | "created" | "name"; +export type SortOption = "installs" | "updated" | "created" | "name" | "rating"; export interface SearchOptions { q?: string; diff --git a/packages/marketplace/src/routes/public.ts b/packages/marketplace/src/routes/public.ts index a6e4651e1..ca0caf39c 100644 --- a/packages/marketplace/src/routes/public.ts +++ b/packages/marketplace/src/routes/public.ts @@ -32,11 +32,11 @@ publicRoutes.get("/plugins", async (c) => { const q = url.searchParams.get("q") ?? undefined; const capability = url.searchParams.get("capability") ?? undefined; const sortParam = url.searchParams.get("sort"); - const validSorts = new Set(["installs", "updated", "created", "name"]); - let sort: "installs" | "updated" | "created" | "name" | undefined; + const validSorts = new Set(["installs", "updated", "created", "name", "rating"]); + let sort: "installs" | "updated" | "created" | "name" | "rating" | undefined; if (sortParam && validSorts.has(sortParam)) { // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- validated by Set.has check above - sort = sortParam as "installs" | "updated" | "created" | "name"; + sort = sortParam as "installs" | "updated" | "created" | "name" | "rating"; } const cursor = url.searchParams.get("cursor") ?? undefined; const limitStr = url.searchParams.get("limit"); @@ -122,6 +122,10 @@ publicRoutes.get("/plugins/:id", async (c) => { hasIcon: plugin.has_icon === 1, iconUrl: `${baseUrl}/api/v1/plugins/${plugin.id}/icon`, installCount, + rating: + plugin.rating_count >= 3 + ? { average: Math.round(plugin.rating_avg * 10) / 10, count: plugin.rating_count } + : null, createdAt: plugin.created_at, updatedAt: plugin.updated_at, }; diff --git a/packages/marketplace/src/routes/reviews.ts b/packages/marketplace/src/routes/reviews.ts new file mode 100644 index 000000000..a6899cdee --- /dev/null +++ b/packages/marketplace/src/routes/reviews.ts @@ -0,0 +1,290 @@ +import type { Context, Next } from "hono"; +import { Hono } from "hono"; +import { jwtVerify } from "jose"; +import { z } from "zod"; + +import { + addPublisherReply, + createReview, + deleteReview, + getPlugin, + getPluginReviews, + updateReview, +} from "../db/queries.js"; +import type { AuthorRow } from "../db/types.js"; + +// ── Types ─────────────────────────────────────────────────────── + +type ReviewEnv = { Bindings: Env; Variables: { author: AuthorRow } }; + +export const reviewRoutes = new Hono(); + +// ── Auth middleware (JWT only, no seed token) ─────────────────── + +// eslint-disable-next-line typescript-eslint(no-redundant-type-constituents) -- Hono middleware returns Response | void +async function reviewAuthMiddleware(c: Context, next: Next): Promise { + const header = c.req.header("Authorization"); + if (!header?.startsWith("Bearer ")) { + return c.json({ error: "Authorization header required" }, 401); + } + + const token = header.slice(7); + + try { + const key = new TextEncoder().encode(c.env.GITHUB_CLIENT_SECRET); + const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"] }); + if (!payload.sub || typeof payload.sub !== "string") { + return c.json({ error: "Invalid token" }, 401); + } + + const author = await c.env.DB.prepare("SELECT * FROM authors WHERE id = ?") + .bind(payload.sub) + .first(); + + if (!author) { + return c.json({ error: "Author not found" }, 401); + } + + c.set("author", author); + return next(); + } catch { + return c.json({ error: "Invalid or expired token" }, 401); + } +} + +// Auth required for write operations +reviewRoutes.post("/plugins/:id/reviews", reviewAuthMiddleware); +reviewRoutes.put("/plugins/:id/reviews/*", reviewAuthMiddleware); +reviewRoutes.delete("/plugins/:id/reviews/*", reviewAuthMiddleware); +reviewRoutes.post("/plugins/:id/reviews/*/reply", reviewAuthMiddleware); + +// ── Rate limiting (simple in-memory, per author) ──────────────── + +const REVIEW_RATE_LIMIT_MS = 60_000; // 1 review per minute per author +const MAX_TRACKED_AUTHORS = 1000; +const recentReviews = new Map(); + +function pruneRecentReviews(now: number): void { + for (const [key, ts] of recentReviews) { + if (now - ts > REVIEW_RATE_LIMIT_MS) { + recentReviews.delete(key); + } else { + break; + } + } + // Hard cap: evict oldest entries if still over limit + while (recentReviews.size > MAX_TRACKED_AUTHORS) { + const oldest = recentReviews.keys().next().value; + if (oldest === undefined) break; + recentReviews.delete(oldest); + } +} + +function isRateLimited(authorId: string): boolean { + const now = Date.now(); + pruneRecentReviews(now); + const last = recentReviews.get(authorId); + return !!last && now - last < REVIEW_RATE_LIMIT_MS; +} + +function recordRateLimit(authorId: string): void { + recentReviews.delete(authorId); + recentReviews.set(authorId, Date.now()); +} + +/** Reset rate limiter state. Exported for tests only. */ +export function resetRateLimiter(): void { + recentReviews.clear(); +} + +// ── GET /plugins/:id/reviews — List reviews ───────────────────── + +reviewRoutes.get("/plugins/:id/reviews", async (c) => { + const pluginId = c.req.param("id"); + const url = new URL(c.req.url); + const cursor = url.searchParams.get("cursor") ?? undefined; + const limitStr = url.searchParams.get("limit"); + const limit = limitStr ? parseInt(limitStr, 10) : undefined; + + try { + const result = await getPluginReviews(c.env.DB, pluginId, { cursor, limit }); + + const items = result.items.map((r) => ({ + id: r.id, + pluginId: r.plugin_id, + author: { + id: r.author_id, + name: r.author_name, + avatarUrl: r.author_avatar_url, + }, + rating: r.rating, + body: r.body, + publisherReply: r.publisher_reply, + repliedAt: r.replied_at, + createdAt: r.created_at, + updatedAt: r.updated_at, + })); + + return c.json({ items, nextCursor: result.nextCursor }); + } catch (err) { + console.error("Failed to list reviews:", err); + return c.json({ error: "Internal server error" }, 500); + } +}); + +// ── POST /plugins/:id/reviews — Submit review ────────────────── + +const createReviewSchema = z.object({ + rating: z.number().int().min(1).max(5), + body: z.string().max(5000).optional(), +}); + +reviewRoutes.post("/plugins/:id/reviews", async (c) => { + const pluginId = c.req.param("id"); + const author = c.get("author"); + + let body: z.infer; + try { + const raw = await c.req.json(); + body = createReviewSchema.parse(raw); + } catch (err) { + if (err instanceof z.ZodError) { + return c.json({ error: "Validation error", details: err.errors }, 400); + } + return c.json({ error: "Invalid JSON" }, 400); + } + + if (isRateLimited(author.id)) { + return c.json({ error: "Too many reviews. Please wait before submitting another." }, 429); + } + + try { + // Verify plugin exists + const plugin = await getPlugin(c.env.DB, pluginId); + if (!plugin) return c.json({ error: "Plugin not found" }, 404); + + const review = await createReview(c.env.DB, { + pluginId, + authorId: author.id, + rating: body.rating, + body: body.body, + }); + + // Only record rate limit after successful creation + recordRateLimit(author.id); + + return c.json( + { + id: review.id, + pluginId: review.plugin_id, + rating: review.rating, + body: review.body, + createdAt: review.created_at, + }, + 201, + ); + } catch (err) { + // UNIQUE constraint violation = already reviewed + if (err instanceof Error && err.message.includes("UNIQUE")) { + return c.json({ error: "You have already reviewed this plugin" }, 409); + } + console.error("Failed to create review:", err); + return c.json({ error: "Internal server error" }, 500); + } +}); + +// ── PUT /plugins/:id/reviews/:reviewId — Update own review ───── + +const updateReviewSchema = z.object({ + rating: z.number().int().min(1).max(5).optional(), + body: z.string().max(5000).optional(), +}); + +reviewRoutes.put("/plugins/:id/reviews/:reviewId", async (c) => { + const pluginId = c.req.param("id"); + const reviewId = c.req.param("reviewId"); + const author = c.get("author"); + + let body: z.infer; + try { + const raw = await c.req.json(); + body = updateReviewSchema.parse(raw); + } catch (err) { + if (err instanceof z.ZodError) { + return c.json({ error: "Validation error", details: err.errors }, 400); + } + return c.json({ error: "Invalid JSON" }, 400); + } + + try { + const updated = await updateReview(c.env.DB, reviewId, author.id, body, pluginId); + if (!updated) return c.json({ error: "Review not found or not yours" }, 404); + + return c.json({ + id: updated.id, + rating: updated.rating, + body: updated.body, + updatedAt: updated.updated_at, + }); + } catch (err) { + console.error("Failed to update review:", err); + return c.json({ error: "Internal server error" }, 500); + } +}); + +// ── DELETE /plugins/:id/reviews/:reviewId — Delete own review ── + +reviewRoutes.delete("/plugins/:id/reviews/:reviewId", async (c) => { + const pluginId = c.req.param("id"); + const reviewId = c.req.param("reviewId"); + const author = c.get("author"); + + try { + const result = await deleteReview(c.env.DB, reviewId, author.id, pluginId); + if (!result.deleted) return c.json({ error: "Review not found or not yours" }, 404); + + return c.json({ ok: true }); + } catch (err) { + console.error("Failed to delete review:", err); + return c.json({ error: "Internal server error" }, 500); + } +}); + +// ── POST /plugins/:id/reviews/:reviewId/reply — Publisher reply ─ + +const replySchema = z.object({ + body: z.string().min(1).max(2000), +}); + +reviewRoutes.post("/plugins/:id/reviews/:reviewId/reply", async (c) => { + const pluginId = c.req.param("id"); + const reviewId = c.req.param("reviewId"); + const author = c.get("author"); + + let body: z.infer; + try { + const raw = await c.req.json(); + body = replySchema.parse(raw); + } catch (err) { + if (err instanceof z.ZodError) { + return c.json({ error: "Validation error", details: err.errors }, 400); + } + return c.json({ error: "Invalid JSON" }, 400); + } + + try { + const updated = await addPublisherReply(c.env.DB, reviewId, author.id, body.body, pluginId); + if (!updated) { + return c.json({ error: "Review not found or you don't own the plugin" }, 404); + } + + return c.json({ + id: updated.id, + publisherReply: updated.publisher_reply, + repliedAt: updated.replied_at, + }); + } catch (err) { + console.error("Failed to add publisher reply:", err); + return c.json({ error: "Internal server error" }, 500); + } +}); diff --git a/packages/marketplace/tests/reviews.test.ts b/packages/marketplace/tests/reviews.test.ts new file mode 100644 index 000000000..7914517b8 --- /dev/null +++ b/packages/marketplace/tests/reviews.test.ts @@ -0,0 +1,602 @@ +/** + * Tests for the reviews and ratings system. + * + * Tests review CRUD, rating denormalization, publisher replies, + * auth, rate limiting, and edge cases. + */ + +import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import Database from "better-sqlite3"; +import { SignJWT } from "jose"; +import { describe, it, expect, beforeEach } from "vitest"; + +// Polyfill crypto.subtle.timingSafeEqual (Workers API not in Node) +const subtle = crypto.subtle as unknown as Record; +if (!subtle.timingSafeEqual) { + subtle.timingSafeEqual = (a: ArrayBuffer, b: ArrayBuffer): boolean => { + return nodeTimingSafeEqual(Buffer.from(a), Buffer.from(b)); + }; +} + +import app from "../src/app.js"; +import { resetRateLimiter } from "../src/routes/reviews.js"; + +// ── D1 mock ───────────────────────────────────────────────────── + +function createD1Mock() { + const db = new Database(":memory:"); + const schemaPath = resolve(import.meta.dirname, "../src/db/schema.sql"); + const schema = readFileSync(schemaPath, "utf-8"); + db.exec(schema); + + return { + _db: db, + prepare(query: string) { + return { + _query: query, + _bindings: [] as unknown[], + bind(...args: unknown[]) { + this._bindings = args; + return this; + }, + async first(column?: string): Promise { + const stmt = db.prepare(this._query); + const row = stmt.get(...this._bindings) as Record | undefined; + if (!row) return null; + if (column) return (row[column] ?? null) as T; + return row as T; + }, + async all(): Promise<{ results: T[] }> { + const stmt = db.prepare(this._query); + const rows = stmt.all(...this._bindings) as T[]; + return { results: rows }; + }, + async run() { + const stmt = db.prepare(this._query); + const result = stmt.run(...this._bindings); + return { + success: true, + meta: { changes: result.changes, last_row_id: result.lastInsertRowid }, + }; + }, + }; + }, + async batch(statements: { _query: string; _bindings: unknown[] }[]) { + const results = []; + for (const stmt of statements) { + const s = db.prepare(stmt._query); + results.push(s.run(...stmt._bindings)); + } + return results; + }, + }; +} + +// ── Helpers ─────────────────────────────────────────────────── + +const JWT_SECRET = "test-jwt-secret"; + +function makeEnv(db: ReturnType) { + return { + DB: db, + R2: { + async put() {}, + async get() { + return null; + }, + async head() { + return null; + }, + }, + SEED_TOKEN: "test-seed", + GITHUB_CLIENT_ID: "test", + GITHUB_CLIENT_SECRET: JWT_SECRET, + AUDIT_ENFORCEMENT: "none", + }; +} + +async function makeJwt(authorId: string): Promise { + const key = new TextEncoder().encode(JWT_SECRET); + return new SignJWT({ sub: authorId }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(key); +} + +function seedAuthor(db: ReturnType["_db"], id: string, name: string) { + db.prepare( + "INSERT OR IGNORE INTO authors (id, github_id, name, verified) VALUES (?, ?, ?, 1)", + ).run(id, `gh-${id}`, name); +} + +function seedPlugin(db: ReturnType["_db"], id: string, authorId: string) { + const now = new Date().toISOString(); + db.prepare( + `INSERT OR IGNORE INTO plugins (id, name, author_id, capabilities, rating_avg, rating_count, created_at, updated_at) + VALUES (?, ?, ?, ?, 0, 0, ?, ?)`, + ).run(id, id, authorId, JSON.stringify(["hooks"]), now, now); + + db.prepare( + `INSERT INTO plugin_versions (id, plugin_id, version, bundle_key, bundle_size, checksum, capabilities, status, published_at) + VALUES (?, ?, '1.0.0', 'key', 1024, 'abc', ?, 'published', ?)`, + ).run(`${id}-v1`, id, JSON.stringify(["hooks"]), now); +} + +// ── Tests ───────────────────────────────────────────────────── + +describe("review CRUD", () => { + let d1: ReturnType; + let env: Record; + let reviewerToken: string; + + beforeEach(async () => { + resetRateLimiter(); + d1 = createD1Mock(); + env = makeEnv(d1); + + seedAuthor(d1._db, "publisher-1", "Plugin Author"); + seedAuthor(d1._db, "reviewer-1", "Reviewer"); + seedPlugin(d1._db, "test-plugin", "publisher-1"); + + reviewerToken = await makeJwt("reviewer-1"); + }); + + it("creates a review", async () => { + const res = await app.request( + "/api/v1/plugins/test-plugin/reviews", + { + method: "POST", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 4, body: "Great plugin!" }), + }, + env, + ); + + expect(res.status).toBe(201); + const body = (await res.json()) as { id: string; rating: number; body: string }; + expect(body.rating).toBe(4); + expect(body.body).toBe("Great plugin!"); + }); + + it("rejects duplicate review from same author", async () => { + await app.request( + "/api/v1/plugins/test-plugin/reviews", + { + method: "POST", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 4 }), + }, + env, + ); + + // Reset rate limiter so the second request reaches the DB constraint check + resetRateLimiter(); + + const res = await app.request( + "/api/v1/plugins/test-plugin/reviews", + { + method: "POST", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 5 }), + }, + env, + ); + + expect(res.status).toBe(409); + }); + + it("lists reviews for a plugin", async () => { + // Create a review first + await app.request( + "/api/v1/plugins/test-plugin/reviews", + { + method: "POST", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 5, body: "Excellent!" }), + }, + env, + ); + + const res = await app.request("/api/v1/plugins/test-plugin/reviews", {}, env); + expect(res.status).toBe(200); + const data = (await res.json()) as { items: { rating: number; body: string }[] }; + expect(data.items.length).toBe(1); + expect(data.items[0]!.rating).toBe(5); + }); + + it("updates own review", async () => { + const createRes = await app.request( + "/api/v1/plugins/test-plugin/reviews", + { + method: "POST", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 3 }), + }, + env, + ); + const created = (await createRes.json()) as { id: string }; + + const res = await app.request( + `/api/v1/plugins/test-plugin/reviews/${created.id}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 5, body: "Changed my mind!" }), + }, + env, + ); + + expect(res.status).toBe(200); + const updated = (await res.json()) as { rating: number; body: string }; + expect(updated.rating).toBe(5); + expect(updated.body).toBe("Changed my mind!"); + }); + + it("deletes own review", async () => { + const createRes = await app.request( + "/api/v1/plugins/test-plugin/reviews", + { + method: "POST", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 4 }), + }, + env, + ); + const created = (await createRes.json()) as { id: string }; + + const res = await app.request( + `/api/v1/plugins/test-plugin/reviews/${created.id}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${reviewerToken}` }, + }, + env, + ); + + expect(res.status).toBe(200); + + // Verify it's gone + const listRes = await app.request("/api/v1/plugins/test-plugin/reviews", {}, env); + const data = (await listRes.json()) as { items: unknown[] }; + expect(data.items.length).toBe(0); + }); + + it("requires auth for review submission", async () => { + const res = await app.request( + "/api/v1/plugins/test-plugin/reviews", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rating: 5 }), + }, + env, + ); + + expect(res.status).toBe(401); + }); + + it("validates rating range", async () => { + const res = await app.request( + "/api/v1/plugins/test-plugin/reviews", + { + method: "POST", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 6 }), + }, + env, + ); + + expect(res.status).toBe(400); + }); + + it("returns 404 for review on nonexistent plugin", async () => { + const res = await app.request( + "/api/v1/plugins/nonexistent/reviews", + { + method: "POST", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 5 }), + }, + env, + ); + + expect(res.status).toBe(404); + }); + + it("rate limits repeated submissions", async () => { + // Create a second plugin so same author can review a different one + seedPlugin(d1._db, "other-plugin", "publisher-1"); + + // First review succeeds + const res1 = await app.request( + "/api/v1/plugins/test-plugin/reviews", + { + method: "POST", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 5 }), + }, + env, + ); + expect(res1.status).toBe(201); + + // Second review within rate limit window is rejected + const res2 = await app.request( + "/api/v1/plugins/other-plugin/reviews", + { + method: "POST", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 4 }), + }, + env, + ); + expect(res2.status).toBe(429); + }); + + it("sanitizes HTML in review body", async () => { + const res = await app.request( + "/api/v1/plugins/test-plugin/reviews", + { + method: "POST", + headers: { + Authorization: `Bearer ${reviewerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating: 4, body: "Nice plugin " }), + }, + env, + ); + + expect(res.status).toBe(201); + const data = (await res.json()) as { body: string }; + expect(data.body).toBe("Nice plugin alert('xss')"); + expect(data.body).not.toContain("