diff --git a/src/lib/reviews.ts b/src/lib/reviews.ts new file mode 100644 index 0000000..3f90b61 --- /dev/null +++ b/src/lib/reviews.ts @@ -0,0 +1,124 @@ +/** + * Reviews fetching utilities. + * + * In development: fetches from the local Signet daemon at localhost:3850. + * In production: set PUBLIC_REVIEWS_ENDPOINT to the Cloudflare Worker URL. + * + * All DOM rendering uses textContent — no innerHTML. + */ + +// Inlined at build time via Astro's PUBLIC_ env convention. +// Fallback to local daemon for dev. +const REVIEWS_ENDPOINT = + (typeof import.meta !== "undefined" && (import.meta as { env?: { PUBLIC_REVIEWS_ENDPOINT?: string } }).env?.PUBLIC_REVIEWS_ENDPOINT) || + "http://localhost:3850/api/marketplace/reviews"; + +export interface MarketplaceReview { + readonly id: string; + readonly targetType: "skill" | "mcp"; + readonly targetId: string; + readonly displayName: string; + readonly rating: number; + readonly title: string; + readonly body: string; + readonly source: "local" | "synced"; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface ReviewsSummary { + readonly count: number; + readonly avgRating: number; +} + +export interface ReviewsResult { + readonly reviews: MarketplaceReview[]; + readonly total: number; + readonly summary: ReviewsSummary; +} + +export async function fetchReviews(opts: { + type?: "skill" | "mcp"; + id?: string; + limit?: number; + offset?: number; +} = {}): Promise { + const url = new URL(REVIEWS_ENDPOINT); + if (opts.type) url.searchParams.set("type", opts.type); + if (opts.id) url.searchParams.set("id", opts.id); + if (opts.limit !== undefined) url.searchParams.set("limit", String(opts.limit)); + if (opts.offset !== undefined) url.searchParams.set("offset", String(opts.offset)); + + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`Reviews fetch failed: ${res.status}`); + return res.json() as Promise; +} + +/** Renders a star rating as unicode — safe for textContent. */ +export function renderStars(rating: number): string { + const filled = Math.max(0, Math.min(5, Math.round(rating))); + return "★".repeat(filled) + "☆".repeat(5 - filled); +} + +/** Format relative time, e.g. "2 days ago". */ +export function formatRelativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 2) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); +} + +/** Build a review card element using safe DOM APIs only. */ +export function buildReviewCard(review: MarketplaceReview): HTMLElement { + const article = document.createElement("article"); + article.className = "review-card"; + + // Header: stars + display name + time + const header = document.createElement("div"); + header.className = "review-header"; + + const stars = document.createElement("span"); + stars.className = "review-stars"; + stars.textContent = renderStars(review.rating); + + const meta = document.createElement("span"); + meta.className = "review-meta"; + const nameSpan = document.createElement("span"); + nameSpan.className = "review-name"; + nameSpan.textContent = review.displayName; + const timeSpan = document.createElement("span"); + timeSpan.className = "review-time"; + timeSpan.textContent = formatRelativeTime(review.updatedAt); + meta.appendChild(nameSpan); + meta.appendChild(timeSpan); + + header.appendChild(stars); + header.appendChild(meta); + + // Target badge + const badge = document.createElement("span"); + badge.className = `review-target-badge review-target-${review.targetType}`; + badge.textContent = `${review.targetType}: ${review.targetId}`; + + // Title + const title = document.createElement("p"); + title.className = "review-title"; + title.textContent = review.title; + + // Body + const body = document.createElement("p"); + body.className = "review-body"; + body.textContent = review.body; + + article.appendChild(header); + article.appendChild(badge); + article.appendChild(title); + article.appendChild(body); + + return article; +} diff --git a/src/pages/index.astro b/src/pages/index.astro index e74f28d..be52d8e 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -102,10 +102,22 @@ import "../styles/global.css";
Loading servers...
+ + +
+
+

Recent Reviews

+ +
+
+
Loading reviews...
+
+
diff --git a/worker/DEPLOY.md b/worker/DEPLOY.md new file mode 100644 index 0000000..6858e23 --- /dev/null +++ b/worker/DEPLOY.md @@ -0,0 +1,200 @@ +# Signet Reviews Worker — Deploy Guide + +This Worker aggregates marketplace reviews synced from user Signet daemons +and serves them publicly for the marketplace frontend. + +**Prerequisites:** Cloudflare account with Workers + D1 enabled. `wrangler` CLI +installed (`bun install` in this directory will install it locally). + +--- + +## Step 1 — Create the D1 database + +```bash +cd marketplace/worker +npx wrangler d1 create signet-reviews +``` + +You'll get output like: + +``` +✅ Successfully created DB 'signet-reviews' +{ + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "signet-reviews" +} +``` + +Copy the `uuid` value and paste it into `wrangler.toml`: + +```toml +[[d1_databases]] +binding = "DB" +database_name = "signet-reviews" +database_id = "PASTE_UUID_HERE" # ← replace this +``` + +--- + +## Step 2 — Run migrations + +Apply the schema to the remote D1 database: + +```bash +npx wrangler d1 migrations apply signet-reviews --remote +``` + +You should see: + +``` +✅ Applied 1 migration(s) +``` + +To verify the table exists: + +```bash +npx wrangler d1 execute signet-reviews --remote \ + --command "SELECT name FROM sqlite_master WHERE type='table'" +``` + +--- + +## Step 3 — Set the CORS origin + +In `wrangler.toml`, update the production CORS origin to match the exact +marketplace domain (no trailing slash): + +```toml +[env.production.vars] +CORS_ORIGIN = "https://marketplace.signetai.sh" +``` + +> If the marketplace is still on the default Pages subdomain, use that: +> `https://signet-marketplace.pages.dev` + +--- + +## Step 4 — Deploy the Worker + +```bash +npx wrangler deploy --env production +``` + +Note the deployed URL — it will be something like: + +``` +https://signet-reviews..workers.dev +``` + +Or add a custom route in the Cloudflare dashboard (Workers → Routes): + +``` +reviews.signetai.sh/* → signet-reviews (production) +``` + +--- + +## Step 5 — Set PUBLIC_REVIEWS_ENDPOINT in Cloudflare Pages + +In the Cloudflare dashboard: + +1. Go to **Pages** → `signet-marketplace` → **Settings** → **Environment variables** +2. Add a **Production** variable: + - Name: `PUBLIC_REVIEWS_ENDPOINT` + - Value: `https://reviews.signetai.sh/api/reviews` (your Worker URL) +3. Redeploy the Pages project (trigger a new build or push a commit) + +--- + +## Step 6 — Set the sync endpoint in signetai + +The signetai daemon needs to know where to send reviews. Set the sync URL +via the daemon API (users run this once, or it becomes the default): + +```bash +curl -X PATCH http://localhost:3850/api/marketplace/reviews/config \ + -H "Content-Type: application/json" \ + -d '{ + "enabled": true, + "endpointUrl": "https://reviews.signetai.sh/api/reviews/sync" + }' +``` + +> **Note for the signetai PR:** update `DEFAULT_CONFIG.endpointUrl` in +> `packages/daemon/src/routes/marketplace-reviews.ts` to this URL once deployed, +> and flip `enabled: true` as the default so sync happens automatically. + +--- + +## Step 7 — Verify end-to-end + +**Check the Worker is up:** +```bash +curl https://reviews.signetai.sh/ +# → {"ok":true,"service":"signet-reviews"} +``` + +**Check the reviews endpoint:** +```bash +curl "https://reviews.signetai.sh/api/reviews?limit=5" +``` + +**Trigger a manual sync from a local daemon:** +```bash +curl -X POST http://localhost:3850/api/marketplace/reviews/sync +``` + +Then re-query the Worker to confirm the review landed. + +--- + +## Local dev + +To test the Worker locally against a local D1 instance: + +```bash +# Run migrations on local D1 +npx wrangler d1 migrations apply signet-reviews --local + +# Start the Worker dev server (binds to http://localhost:8787) +npx wrangler dev +``` + +The marketplace dev server (port 4321) will pick up reviews from `localhost:3850` +(the Signet daemon) by default. Point it at the Worker instead by setting: + +```bash +PUBLIC_REVIEWS_ENDPOINT=http://localhost:8787/api/reviews bun run dev +``` + +--- + +## Security notes + +- **Rate limiting:** 5 sync requests per IP per 60 seconds. Adjust `namespace_id` + `simple.limit` / `simple.period` in `wrangler.toml` if needed. +- **Origin gate:** All non-GET requests from non-allowed origins get a 403. + The `CORS_ORIGIN` var must be your exact marketplace domain in production. +- **Sync gate:** POST `/api/reviews/sync` requires the `X-Signet-Sync: 1` header. + The signetai daemon sets this automatically. +- **Input validation:** UUIDs, timestamps, field lengths, and rating range are + all validated. Invalid reviews are skipped (not rejected wholesale). +- **Idempotent:** Re-syncing the same review UUID is safe — it upserts. +- **No PII stored:** Only `displayName` (user-chosen) is persisted. No IPs, + emails, or account identifiers are stored. + +--- + +## Files + +``` +worker/ +├── migrations/ +│ └── 001_initial.sql D1 schema +├── src/ +│ └── index.ts Worker — all routes, validation, DB logic +├── package.json +├── tsconfig.json +├── wrangler.toml ← fill in database_id and CORS_ORIGIN +└── DEPLOY.md ← this file +``` diff --git a/worker/migrations/001_initial.sql b/worker/migrations/001_initial.sql new file mode 100644 index 0000000..35d1b59 --- /dev/null +++ b/worker/migrations/001_initial.sql @@ -0,0 +1,30 @@ +-- Signet Reviews — D1 Schema +-- Run via: wrangler d1 migrations apply signet-reviews --remote + +CREATE TABLE IF NOT EXISTS reviews ( + -- Identity + id TEXT PRIMARY KEY, -- UUID from signetai daemon (idempotent key) + + -- Target + target_type TEXT NOT NULL CHECK (target_type IN ('skill', 'mcp')), + target_id TEXT NOT NULL, -- skill name or MCP server id + + -- Content + display_name TEXT NOT NULL, + rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5), + title TEXT NOT NULL, + body TEXT NOT NULL, + + -- Timestamps (ISO 8601, set by the originating daemon) + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + received_at TEXT NOT NULL -- when the Worker received this review +); + +-- Fast lookups by target (the primary read pattern) +CREATE INDEX IF NOT EXISTS idx_reviews_target + ON reviews (target_type, target_id); + +-- Ordered listing for the "recent reviews" feed +CREATE INDEX IF NOT EXISTS idx_reviews_updated + ON reviews (updated_at DESC); diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 0000000..9a8d3d1 --- /dev/null +++ b/worker/package.json @@ -0,0 +1,15 @@ +{ + "name": "signet-reviews-worker", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy --env production", + "migrate:local": "wrangler d1 migrations apply signet-reviews --local", + "migrate:remote": "wrangler d1 migrations apply signet-reviews --remote" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240614.0", + "wrangler": "^3.0.0" + } +} diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 0000000..1ecf998 --- /dev/null +++ b/worker/src/index.ts @@ -0,0 +1,455 @@ +/** + * Signet Reviews Worker + * + * Central aggregation endpoint for Signet marketplace reviews. + * Receives synced reviews from user daemons and serves them publicly. + * + * Routes: + * GET / — health check + * GET /api/reviews — list/query reviews (public) + * POST /api/reviews/sync — receive batch sync from signetai daemon + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface Env { + DB: D1Database; + RATE_LIMITER: { limit(opts: { key: string }): Promise<{ success: boolean }> }; + CORS_ORIGIN: string; +} + +interface ReviewRow { + id: string; + target_type: "skill" | "mcp"; + target_id: string; + display_name: string; + rating: number; + title: string; + body: string; + created_at: string; + updated_at: string; + received_at: string; +} + +interface IncomingReview { + id: string; + targetType: "skill" | "mcp"; + targetId: string; + displayName: string; + rating: number; + title: string; + body: string; + createdAt: string; + updatedAt: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Validation limits — enforced on every inbound review +// ───────────────────────────────────────────────────────────────────────────── + +const LIMITS = { + BATCH_SIZE: 100, // max reviews per sync call + TARGET_ID: 200, // max chars for targetId + DISPLAY_NAME: 50, // max chars for displayName + TITLE: 100, // max chars for title + BODY: 2_000, // max chars for body + BODY_MIN: 10, // min chars for body + TITLE_MIN: 3, // min chars for title + REQUEST_BODY_BYTES: 512_000, // 512 KB max request body +} as const; + +// ───────────────────────────────────────────────────────────────────────────── +// Validation helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Returns a safe trimmed string or null if invalid/too long/too short. */ +function parseStr( + value: unknown, + minLen: number, + maxLen: number, +): string | null { + if (typeof value !== "string") return null; + const t = value.trim(); + if (t.length < minLen || t.length > maxLen) return null; + return t; +} + +function parseTargetType(v: unknown): "skill" | "mcp" | null { + if (v === "skill" || v === "mcp") return v; + return null; +} + +function parseRating(v: unknown): number | null { + if (typeof v !== "number" || !Number.isFinite(v)) return null; + const r = Math.round(v); + if (r < 1 || r > 5) return null; + return r; +} + +/** UUID v4 format check. Prevents arbitrary strings as primary keys. */ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +function parseUUID(v: unknown): string | null { + if (typeof v !== "string" || !UUID_RE.test(v)) return null; + return v.toLowerCase(); +} + +/** ISO 8601 timestamp check — accept only reasonable recent dates. */ +const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; +function parseTimestamp(v: unknown): string | null { + if (typeof v !== "string" || !ISO_RE.test(v)) return null; + const ts = new Date(v).getTime(); + if (isNaN(ts)) return null; + // Reject timestamps more than 30 days in the future + if (ts > Date.now() + 30 * 24 * 60 * 60 * 1_000) return null; + return v; +} + +/** Validates and normalizes one incoming review. Returns null if invalid. */ +function validateReview(raw: unknown): IncomingReview | null { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const r = raw as Record; + + const id = parseUUID(r["id"]); + const targetType = parseTargetType(r["targetType"]); + const targetId = parseStr(r["targetId"], 1, LIMITS.TARGET_ID); + const displayName = parseStr(r["displayName"], 1, LIMITS.DISPLAY_NAME); + const rating = parseRating(r["rating"]); + const title = parseStr(r["title"], LIMITS.TITLE_MIN, LIMITS.TITLE); + const body = parseStr(r["body"], LIMITS.BODY_MIN, LIMITS.BODY); + const createdAt = parseTimestamp(r["createdAt"]); + const updatedAt = parseTimestamp(r["updatedAt"]); + + if ( + !id || !targetType || !targetId || !displayName || + rating === null || !title || !body || !createdAt || !updatedAt + ) { + return null; + } + + return { id, targetType, targetId, displayName, rating, title, body, createdAt, updatedAt }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// CORS +// ───────────────────────────────────────────────────────────────────────────── + +function corsHeaders(origin: string): Record { + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, X-Signet-Sync", + "Access-Control-Max-Age": "86400", + }; +} + +function isOriginAllowed(requestOrigin: string | null, allowed: string): boolean { + if (allowed === "*") return true; + if (!requestOrigin) return false; + // Allow exact match and localhost for dev + if (requestOrigin === allowed) return true; + try { + const u = new URL(requestOrigin); + if (u.hostname === "localhost" || u.hostname === "127.0.0.1") return true; + } catch { + // ignore + } + return false; +} + +function makeResponse( + body: unknown, + status: number, + extra: Record = {}, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json;charset=UTF-8", + ...extra, + }, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// DB helpers +// ───────────────────────────────────────────────────────────────────────────── + +function rowToPublic(row: ReviewRow) { + return { + id: row.id, + targetType: row.target_type, + targetId: row.target_id, + displayName: row.display_name, + rating: row.rating, + title: row.title, + body: row.body, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +async function upsertReviews( + db: D1Database, + reviews: IncomingReview[], + receivedAt: string, +): Promise<{ accepted: number; rejected: number }> { + let accepted = 0; + let rejected = 0; + + // D1 batch — one statement per review, all in one round-trip + const stmts = reviews.map((r) => + db + .prepare( + `INSERT INTO reviews + (id, target_type, target_id, display_name, rating, title, body, + created_at, updated_at, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + display_name = excluded.display_name, + rating = excluded.rating, + title = excluded.title, + body = excluded.body, + updated_at = excluded.updated_at, + received_at = excluded.received_at`, + ) + .bind( + r.id, r.targetType, r.targetId, r.displayName, + r.rating, r.title, r.body, + r.createdAt, r.updatedAt, receivedAt, + ), + ); + + const results = await db.batch(stmts); + for (const res of results) { + if (res.success) accepted++; + else rejected++; + } + return { accepted, rejected }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Request parsing helpers +// ───────────────────────────────────────────────────────────────────────────── + +function parseIntParam(v: string | null, min: number, max: number, def: number): number { + if (!v) return def; + const n = parseInt(v, 10); + if (!Number.isFinite(n)) return def; + return Math.max(min, Math.min(max, n)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Route handlers +// ───────────────────────────────────────────────────────────────────────────── + +async function handleGetReviews( + request: Request, + env: Env, + cors: Record, +): Promise { + const url = new URL(request.url); + const type = url.searchParams.get("type"); + const id = url.searchParams.get("id"); + const limit = parseIntParam(url.searchParams.get("limit"), 1, 50, 20); + const offset = parseIntParam(url.searchParams.get("offset"), 0, 100_000, 0); + + // Build WHERE clause safely — no string interpolation of user values + const conditions: string[] = []; + const bindings: (string | number)[] = []; + + if (type === "skill" || type === "mcp") { + conditions.push("target_type = ?"); + bindings.push(type); + } + if (id) { + const cleanId = id.slice(0, LIMITS.TARGET_ID); + conditions.push("target_id = ?"); + bindings.push(cleanId); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const [rowsResult, countResult] = await env.DB.batch([ + env.DB.prepare( + `SELECT id, target_type, target_id, display_name, rating, title, body, + created_at, updated_at + FROM reviews ${where} + ORDER BY updated_at DESC + LIMIT ? OFFSET ?`, + ).bind(...bindings, limit, offset), + env.DB.prepare( + `SELECT COUNT(*) as total, + AVG(rating) as avg_rating + FROM reviews ${where}`, + ).bind(...bindings), + ]); + + const rows = (rowsResult.results ?? []) as ReviewRow[]; + const summary = (countResult.results?.[0] ?? { total: 0, avg_rating: 0 }) as { + total: number; + avg_rating: number | null; + }; + + return makeResponse( + { + reviews: rows.map(rowToPublic), + total: summary.total, + limit, + offset, + summary: { + count: summary.total, + avgRating: summary.avg_rating != null + ? Math.round(summary.avg_rating * 10) / 10 + : 0, + }, + }, + 200, + { ...cors, "Cache-Control": "public, max-age=30, stale-while-revalidate=60" }, + ); +} + +async function handleSync( + request: Request, + env: Env, + cors: Record, + clientIp: string, +): Promise { + // ── Rate limit ────────────────────────────────────────────────────────────── + const { success: allowed } = await env.RATE_LIMITER.limit({ key: clientIp }); + if (!allowed) { + return makeResponse( + { error: "rate limit exceeded — try again in a minute" }, + 429, + { ...cors, "Retry-After": "60" }, + ); + } + + // ── Require the X-Signet-Sync header (lightweight origin gate) ───────────── + if (request.headers.get("X-Signet-Sync") !== "1") { + return makeResponse({ error: "missing required header" }, 400, cors); + } + + // ── Content-Type check ───────────────────────────────────────────────────── + const ct = request.headers.get("Content-Type") ?? ""; + if (!ct.includes("application/json")) { + return makeResponse({ error: "Content-Type must be application/json" }, 415, cors); + } + + // ── Body size guard (CF Workers stream; clone + arrayBuffer to check size) ── + const cloned = request.clone(); + const bodyBuffer = await cloned.arrayBuffer(); + if (bodyBuffer.byteLength > LIMITS.REQUEST_BODY_BYTES) { + return makeResponse({ error: "request body too large" }, 413, cors); + } + + // ── Parse JSON ────────────────────────────────────────────────────────────── + let payload: unknown; + try { + payload = JSON.parse(new TextDecoder().decode(bodyBuffer)); + } catch { + return makeResponse({ error: "invalid JSON" }, 400, cors); + } + + // ── Validate envelope ─────────────────────────────────────────────────────── + if ( + typeof payload !== "object" || payload === null || + (payload as Record)["source"] !== "signet-marketplace" || + (payload as Record)["type"] !== "reviews-sync" + ) { + return makeResponse({ error: "invalid sync payload" }, 400, cors); + } + + const rawReviews = (payload as Record)["reviews"]; + if (!Array.isArray(rawReviews)) { + return makeResponse({ error: "'reviews' must be an array" }, 400, cors); + } + if (rawReviews.length > LIMITS.BATCH_SIZE) { + return makeResponse( + { error: `batch too large — max ${LIMITS.BATCH_SIZE} reviews per sync` }, + 400, + cors, + ); + } + + // ── Validate each review ──────────────────────────────────────────────────── + const valid: IncomingReview[] = []; + let skipped = 0; + for (const raw of rawReviews) { + const r = validateReview(raw); + if (r) valid.push(r); + else skipped++; + } + + if (valid.length === 0) { + return makeResponse({ error: "no valid reviews in batch", skipped }, 400, cors); + } + + // ── Upsert ────────────────────────────────────────────────────────────────── + const receivedAt = new Date().toISOString(); + const { accepted, rejected } = await upsertReviews(env.DB, valid, receivedAt); + + return makeResponse( + { success: true, accepted, rejected, skipped, receivedAt }, + 200, + cors, + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main fetch handler +// ───────────────────────────────────────────────────────────────────────────── + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const method = request.method.toUpperCase(); + + // ── CORS origin check ───────────────────────────────────────────────────── + const requestOrigin = request.headers.get("Origin"); + const allowed = isOriginAllowed(requestOrigin, env.CORS_ORIGIN); + const cors = allowed + ? corsHeaders(requestOrigin ?? env.CORS_ORIGIN) + : {}; + + // Preflight + if (method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: allowed + ? corsHeaders(requestOrigin ?? env.CORS_ORIGIN) + : { "Content-Length": "0" }, + }); + } + + // Block non-allowed cross-origins on state-changing requests + if (!allowed && method !== "GET") { + return makeResponse({ error: "forbidden" }, 403, {}); + } + + const path = url.pathname.replace(/\/+$/, "") || "/"; + + // ── Health check ────────────────────────────────────────────────────────── + if (path === "/" && method === "GET") { + return makeResponse({ ok: true, service: "signet-reviews" }, 200, cors); + } + + // ── GET /api/reviews ────────────────────────────────────────────────────── + if (path === "/api/reviews" && method === "GET") { + return handleGetReviews(request, env, cors); + } + + // ── POST /api/reviews/sync ──────────────────────────────────────────────── + if (path === "/api/reviews/sync" && method === "POST") { + const clientIp = + request.headers.get("CF-Connecting-IP") ?? + request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ?? + "unknown"; + return handleSync(request, env, cors, clientIp); + } + + return makeResponse({ error: "not found" }, 404, cors); + }, +} satisfies ExportedHandler; diff --git a/worker/tsconfig.json b/worker/tsconfig.json new file mode 100644 index 0000000..78ff675 --- /dev/null +++ b/worker/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..9e1cf33 --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,36 @@ +name = "signet-reviews" +main = "src/index.ts" +compatibility_date = "2025-01-01" + +# ───────────────────────────────────────────────────────────────────────────── +# D1 Database +# After running `wrangler d1 create signet-reviews`, paste the database_id here. +# ───────────────────────────────────────────────────────────────────────────── +[[d1_databases]] +binding = "DB" +database_name = "signet-reviews" +database_id = "REPLACE_WITH_D1_DATABASE_ID" + +# ───────────────────────────────────────────────────────────────────────────── +# Rate Limiting (5 sync submissions per IP per 60 seconds) +# namespace_id can be any integer — it scopes the limiter to this Worker. +# ───────────────────────────────────────────────────────────────────────────── +[[unsafe.bindings]] +name = "RATE_LIMITER" +type = "ratelimit" +namespace_id = "1001" +simple = { limit = 5, period = 60 } + +# ───────────────────────────────────────────────────────────────────────────── +# Environment variables +# CORS_ORIGIN: your marketplace domain (no trailing slash). +# Example: "https://marketplace.signetai.sh" +# For local dev leave it as "*". +# ───────────────────────────────────────────────────────────────────────────── +[vars] +CORS_ORIGIN = "*" + +[env.production.vars] +# Override in production — set to your exact marketplace domain. +# e.g. CORS_ORIGIN = "https://marketplace.signetai.sh" +CORS_ORIGIN = "REPLACE_WITH_MARKETPLACE_DOMAIN"