diff --git a/apps/web/src/app/api/modules/[slug]/versions/__tests__/route.test.ts b/apps/web/src/app/api/modules/[slug]/versions/__tests__/route.test.ts new file mode 100644 index 0000000..738dbbf --- /dev/null +++ b/apps/web/src/app/api/modules/[slug]/versions/__tests__/route.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockAuth = vi.fn(); +const mockModuleSingle = vi.fn(); +const mockVersionsOrder = vi.fn(); +const mockVersionSingle = vi.fn(); +const mockVersionInsert = vi.fn(); +const mockModuleUpdate = vi.fn(); +const mockModuleUpdateEq = vi.fn(); + +function moduleQuery() { + const query = { + eq: vi.fn(() => query), + single: mockModuleSingle, + }; + return query; +} + +function versionsQuery() { + const query = { + eq: vi.fn(() => query), + order: mockVersionsOrder, + }; + return query; +} + +vi.mock("@/lib/api-auth", () => ({ + getAuthenticatedRequestUser: (...args: unknown[]) => mockAuth(...args), +})); + +vi.mock("@/lib/supabase", () => ({ + getSupabaseAdmin: () => ({ + from: (table: string) => { + if (table === "modules") { + return { + select: vi.fn(moduleQuery), + update: mockModuleUpdate, + }; + } + if (table === "module_versions") { + return { + select: vi.fn(versionsQuery), + insert: mockVersionInsert, + }; + } + return {}; + }, + }), +})); + +import { GET, POST } from "@/app/api/modules/[slug]/versions/route"; + +function makeRequest(body?: unknown, token = "token-123") { + return new Request("http://localhost/api/modules/test-scanner/versions", { + method: body === undefined ? "GET" : "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +function makeContext(slug = "test-scanner") { + return { params: Promise.resolve({ slug }) }; +} + +describe("GET /api/modules/:slug/versions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockModuleSingle.mockResolvedValue({ + data: { id: "mod-001" }, + error: null, + }); + mockVersionsOrder.mockResolvedValue({ + data: [{ id: "ver-1", version: "1.0.0" }], + error: null, + }); + }); + + it("lists versions newest first", async () => { + const response = await GET(makeRequest(), makeContext()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.versions).toEqual([{ id: "ver-1", version: "1.0.0" }]); + expect(mockVersionsOrder).toHaveBeenCalledWith("created_at", { + ascending: false, + }); + }); + + it("returns 404 for an unknown module", async () => { + mockModuleSingle.mockResolvedValue({ data: null, error: { message: "missing" } }); + + const response = await GET(makeRequest(), makeContext("missing")); + + expect(response.status).toBe(404); + }); +}); + +describe("POST /api/modules/:slug/versions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAuth.mockResolvedValue({ + userId: "user-1", + email: "author@example.com", + }); + mockModuleSingle.mockResolvedValue({ + data: { id: "mod-001", author_email: "author@example.com" }, + error: null, + }); + mockVersionSingle.mockResolvedValue({ + data: { id: "ver-2", module_id: "mod-001", version: "1.1.0" }, + error: null, + }); + mockVersionInsert.mockReturnValue({ + select: vi.fn(() => ({ single: mockVersionSingle })), + }); + mockModuleUpdateEq.mockResolvedValue({ error: null }); + mockModuleUpdate.mockReturnValue({ eq: mockModuleUpdateEq }); + }); + + it("publishes a version and updates the module release", async () => { + const response = await POST( + makeRequest({ + version: "1.1.0", + changelog: "Adds scheduled scans", + package_url: "https://example.com/releases/1.1.0.tgz", + git_tag: "v1.1.0", + min_threatcrush_version: ">=0.2.0", + }), + makeContext(), + ); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.version.version).toBe("1.1.0"); + expect(mockVersionInsert).toHaveBeenCalledWith( + expect.objectContaining({ + module_id: "mod-001", + version: "1.1.0", + git_tag: "v1.1.0", + }), + ); + expect(mockModuleUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + version: "1.1.0", + min_threatcrush_version: ">=0.2.0", + }), + ); + }); + + it("requires authentication", async () => { + mockAuth.mockResolvedValue(null); + + const response = await POST(makeRequest({ version: "1.1.0" }, ""), makeContext()); + + expect(response.status).toBe(401); + expect(mockVersionInsert).not.toHaveBeenCalled(); + }); + + it("rejects releases from a different author", async () => { + mockAuth.mockResolvedValue({ + userId: "user-2", + email: "other@example.com", + }); + + const response = await POST(makeRequest({ version: "1.1.0" }), makeContext()); + + expect(response.status).toBe(403); + expect(mockVersionInsert).not.toHaveBeenCalled(); + }); + + it.each(["1", "v1.2.3", "1.2", "latest"])( + "rejects invalid semantic version %s", + async (version) => { + const response = await POST(makeRequest({ version }), makeContext()); + + expect(response.status).toBe(400); + expect(mockVersionInsert).not.toHaveBeenCalled(); + }, + ); + + it("rejects a non-HTTP package URL", async () => { + const response = await POST( + makeRequest({ version: "1.1.0", package_url: "file:///tmp/module.tgz" }), + makeContext(), + ); + + expect(response.status).toBe(400); + expect(mockVersionInsert).not.toHaveBeenCalled(); + }); + + it("returns 409 for an existing version", async () => { + mockVersionSingle.mockResolvedValue({ + data: null, + error: { code: "23505", message: "duplicate key" }, + }); + + const response = await POST(makeRequest({ version: "1.1.0" }), makeContext()); + const body = await response.json(); + + expect(response.status).toBe(409); + expect(body.error).toContain("already exists"); + }); +}); diff --git a/apps/web/src/app/api/modules/[slug]/versions/route.ts b/apps/web/src/app/api/modules/[slug]/versions/route.ts new file mode 100644 index 0000000..41febcf --- /dev/null +++ b/apps/web/src/app/api/modules/[slug]/versions/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthenticatedRequestUser } from "@/lib/api-auth"; +import { getSupabaseAdmin } from "@/lib/supabase"; + +type RouteContext = { params: Promise<{ slug: string }> }; + +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +/** + * GET /api/modules/[slug]/versions + * List published versions for a marketplace module. + */ +export async function GET( + _request: NextRequest, + context: RouteContext, +) { + const { slug } = await context.params; + const sb = getSupabaseAdmin(); + + const { data: mod, error: moduleError } = await sb + .from("modules") + .select("id") + .eq("slug", slug) + .eq("published", true) + .single(); + + if (moduleError || !mod) { + return NextResponse.json({ error: "Module not found" }, { status: 404 }); + } + + const { data: versions, error } = await sb + .from("module_versions") + .select("*") + .eq("module_id", mod.id) + .order("created_at", { ascending: false }); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ versions: versions || [] }); +} + +/** + * POST /api/modules/[slug]/versions + * Publish a version for a module owned by the authenticated user. + */ +export async function POST( + request: NextRequest, + context: RouteContext, +) { + const user = await getAuthenticatedRequestUser(request); + if (!user) { + return NextResponse.json( + { error: "You must be logged in to publish module versions." }, + { status: 401 }, + ); + } + + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const version = typeof body.version === "string" ? body.version.trim() : ""; + if (!SEMVER_PATTERN.test(version)) { + return NextResponse.json( + { error: "version must be a valid semantic version" }, + { status: 400 }, + ); + } + + const packageUrl = + typeof body.package_url === "string" && body.package_url.trim() + ? body.package_url.trim() + : null; + if (packageUrl && !isHttpUrl(packageUrl)) { + return NextResponse.json( + { error: "package_url must be a valid HTTP(S) URL" }, + { status: 400 }, + ); + } + + const { slug } = await context.params; + const sb = getSupabaseAdmin(); + const { data: mod, error: moduleError } = await sb + .from("modules") + .select("id, author_email") + .eq("slug", slug) + .single(); + + if (moduleError || !mod) { + return NextResponse.json({ error: "Module not found" }, { status: 404 }); + } + if (!user.email || user.email.toLowerCase() !== mod.author_email?.toLowerCase()) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const versionData = { + module_id: mod.id, + version, + changelog: + typeof body.changelog === "string" && body.changelog.trim() + ? body.changelog.trim() + : null, + package_url: packageUrl, + git_tag: + typeof body.git_tag === "string" && body.git_tag.trim() + ? body.git_tag.trim() + : null, + min_threatcrush_version: + typeof body.min_threatcrush_version === "string" && + body.min_threatcrush_version.trim() + ? body.min_threatcrush_version.trim() + : null, + }; + + const { data: created, error } = await sb + .from("module_versions") + .insert(versionData) + .select() + .single(); + + if (error) { + const status = error.code === "23505" ? 409 : 500; + const message = + status === 409 ? `Version ${version} already exists` : error.message; + return NextResponse.json({ error: message }, { status }); + } + + const moduleUpdates: Record = { + version, + updated_at: new Date().toISOString(), + }; + if (versionData.min_threatcrush_version) { + moduleUpdates.min_threatcrush_version = versionData.min_threatcrush_version; + } + + const { error: updateError } = await sb + .from("modules") + .update(moduleUpdates) + .eq("id", mod.id); + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }); + } + + return NextResponse.json({ version: created }, { status: 201 }); +} diff --git a/docs/MODULE_STORE_API.md b/docs/MODULE_STORE_API.md index 7de8134..388203c 100644 --- a/docs/MODULE_STORE_API.md +++ b/docs/MODULE_STORE_API.md @@ -562,9 +562,15 @@ type ModuleVersion = { }; ``` -There is no public `POST /api/modules/{slug}/versions` yet — bumping the -top-level `version` via `PATCH` is how authors signal a new release today. -A dedicated versions endpoint is on the roadmap. +Published module versions have a dedicated endpoint: + +- `GET /api/modules/{slug}/versions` lists releases newest first. +- `POST /api/modules/{slug}/versions` publishes a release for the + authenticated module author. + +The publish body requires a semantic `version` and accepts `changelog`, +`package_url`, `git_tag`, and `min_threatcrush_version`. Publishing a release +also updates the module's top-level version used by install clients. ### Review *(row in `module_reviews`)* @@ -667,14 +673,17 @@ curl -sS https://threatcrush.com/api/modules \ -d "$(jq '. + {author_email:"you@example.com",pricing_type:"free"}' meta.json)" ``` -### Update the version after a release +### Publish a module version ```bash -curl -sS -X PATCH https://threatcrush.com/api/modules/urlhaus-feed \ +curl -sS -X POST https://threatcrush.com/api/modules/urlhaus-feed/versions \ + -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ - "author_email": "you@example.com", "version": "0.2.0", + "changelog": "Add incremental feed polling", + "git_tag": "v0.2.0", + "package_url": "https://github.com/you/urlhaus-feed/releases/download/v0.2.0/module.tgz", "min_threatcrush_version": ">=0.2.0" }' ``` @@ -723,13 +732,10 @@ into your client. accept `author_email` as a soft proof of ownership. Future versions will require the same `Authorization: Bearer …` header as `POST /api/modules`, and `author_email` will become advisory-only. -2. **A `POST /api/modules/{slug}/versions` endpoint** will land for - first-class version management (changelog, tarball uploads, signed - releases). Today, version bumps go through `PATCH /api/modules/{slug}`. -3. **Module signing.** Verified publishers will be able to sign release +2. **Module signing.** Verified publishers will be able to sign release tarballs; the install API will return `signature_url` + `pubkey` so the CLI can verify before running. -4. **`pricing_type = "paid"` will require Stripe / CoinPay metadata.** +3. **`pricing_type = "paid"` will require Stripe / CoinPay metadata.** Currently the price is stored but no checkout flow is wired into the install path. This will change once the paid-module experience ships.