From a1e11a4214038fefa74aebf84fee66e343dc3256 Mon Sep 17 00:00:00 2001 From: Codex Operator <284916543+op-simoneromeo@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:38:24 +0800 Subject: [PATCH] feat(api): add module versions endpoint --- apps/web/public/openapi.yaml | 80 ++++++- .../[slug]/versions/__tests__/route.test.ts | 220 ++++++++++++++++++ .../app/api/modules/[slug]/versions/route.ts | 176 ++++++++++++++ apps/web/src/app/docs/modules/page.tsx | 2 + docs/MODULE_STORE_API.md | 69 +++++- 5 files changed, 539 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/app/api/modules/[slug]/versions/__tests__/route.test.ts create mode 100644 apps/web/src/app/api/modules/[slug]/versions/route.ts diff --git a/apps/web/public/openapi.yaml b/apps/web/public/openapi.yaml index baae802..b835344 100644 --- a/apps/web/public/openapi.yaml +++ b/apps/web/public/openapi.yaml @@ -4,8 +4,8 @@ info: version: 0.2.0 description: | Public, read-only endpoints for ThreatCrush — module marketplace, - blog feed, and health. Authenticated endpoints are not documented - here and require a signed-in session via the web app. + blog feed, and health. Authenticated module-author endpoints require + a signed-in session via the web app or a Supabase Bearer token. contact: name: ThreatCrush email: hello@threatcrush.com @@ -79,6 +79,70 @@ paths: '404': description: Not found + /api/modules/{slug}/versions: + get: + summary: List module releases + operationId: listModuleVersions + parameters: + - in: path + name: slug + required: true + schema: { type: string } + responses: + '200': + description: Published releases newest first + content: + application/json: + schema: + type: object + properties: + versions: + type: array + items: { $ref: '#/components/schemas/ModuleVersion' } + '404': + description: Not found + post: + summary: Publish a module release + operationId: publishModuleVersion + parameters: + - in: path + name: slug + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [version] + properties: + version: { type: string, example: "1.2.3" } + changelog: { type: string, nullable: true } + package_url: { type: string, nullable: true } + git_tag: { type: string, nullable: true, example: "v1.2.3" } + min_threatcrush_version: { type: string, nullable: true, example: ">=0.2.0" } + responses: + '201': + description: Release published and module metadata synchronized + content: + application/json: + schema: + type: object + properties: + version: { $ref: '#/components/schemas/ModuleVersion' } + module: { $ref: '#/components/schemas/Module' } + '400': + description: Invalid JSON or version + '401': + description: Login required + '403': + description: Verified module author required + '404': + description: Not found + '409': + description: Duplicate version + /blog/rss.xml: get: summary: Blog RSS feed @@ -126,3 +190,15 @@ components: featured: { type: boolean } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } + + ModuleVersion: + type: object + properties: + id: { type: string, format: uuid } + module_id: { type: string, format: uuid } + version: { type: string } + changelog: { type: string, nullable: true } + package_url: { type: string, nullable: true } + git_tag: { type: string, nullable: true } + min_threatcrush_version: { type: string, nullable: true } + created_at: { type: string, format: date-time } 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..c24c159 --- /dev/null +++ b/apps/web/src/app/api/modules/[slug]/versions/__tests__/route.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TEST_MODULE } from "@/__tests__/helpers/supabase-mock"; + +type QueryResult = { data: unknown; error: { message: string; code?: string } | null }; + +const mockAuthGetUser = vi.fn(); +const mockInsertVersion = vi.fn(); +const mockUpdateModule = vi.fn(); + +let tableResults: Record = {}; + +function createChainable(table: string) { + const handler: ProxyHandler = { + get(_target, prop: string) { + if (prop === "then") { + return (resolve: (value: QueryResult) => void) => { + const result = tableResults[table]?.awaited?.shift() || { data: null, error: null }; + resolve(result); + }; + } + if (prop === "single") { + return vi.fn().mockImplementation(() => { + const result = tableResults[table]?.single?.shift() || { data: null, error: null }; + return Promise.resolve(result); + }); + } + if (prop === "maybeSingle") { + return vi.fn().mockImplementation(() => { + const result = tableResults[table]?.maybeSingle?.shift() || { data: null, error: null }; + return Promise.resolve(result); + }); + } + if (prop === "insert") { + return mockInsertVersion.mockReturnValue(new Proxy({}, handler)); + } + if (prop === "update") { + return mockUpdateModule.mockReturnValue(new Proxy({}, handler)); + } + return vi.fn().mockReturnValue(new Proxy({}, handler)); + }, + }; + + return new Proxy({}, handler); +} + +vi.mock("@/lib/supabase", () => ({ + getSupabaseAdmin: () => ({ + auth: { + getUser: mockAuthGetUser, + }, + from: (table: string) => createChainable(table), + }), +})); + +import { GET, POST } from "@/app/api/modules/[slug]/versions/route"; + +function makeRequest(url: string, init?: RequestInit) { + return new Request(url, init) as unknown as import("next/server").NextRequest; +} + +function makeContext(slug: string) { + return { params: Promise.resolve({ slug }) }; +} + +const TEST_VERSION = { + id: "v-001", + module_id: "mod-001", + version: "1.0.0", + changelog: "Initial", + created_at: "2025-01-01T00:00:00Z", +}; + +describe("GET /api/modules/:slug/versions", () => { + beforeEach(() => { + vi.clearAllMocks(); + tableResults = { + modules: { single: [{ data: { id: "mod-001" }, error: null }] }, + module_versions: { awaited: [{ data: [TEST_VERSION], error: null }] }, + }; + }); + + it("returns versions newest first", async () => { + const req = makeRequest("http://localhost/api/modules/test-scanner/versions"); + const res = await GET(req, makeContext("test-scanner")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.versions).toEqual([TEST_VERSION]); + }); + + it("returns 404 for unknown modules", async () => { + tableResults.modules = { single: [{ data: null, error: null }] }; + + const req = makeRequest("http://localhost/api/modules/nope/versions"); + const res = await GET(req, makeContext("nope")); + const body = await res.json(); + + expect(res.status).toBe(404); + expect(body.error).toBe("Module not found"); + }); +}); + +describe("POST /api/modules/:slug/versions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAuthGetUser.mockResolvedValue({ data: { user: { id: "user-1", email: "test@example.com" } } }); + tableResults = { + user_profiles: { + single: [{ data: { id: "user-1", email: "test@example.com", email_verified: true }, error: null }], + }, + modules: { + single: [ + { data: { id: "mod-001", author_email: "test@example.com" }, error: null }, + { data: { ...TEST_MODULE, version: "1.1.0" }, error: null }, + ], + }, + module_versions: { + maybeSingle: [{ data: null, error: null }], + single: [{ data: { ...TEST_VERSION, version: "1.1.0" }, error: null }], + }, + }; + }); + + it("rejects unauthenticated release publishing", async () => { + mockAuthGetUser.mockResolvedValue({ data: { user: null } }); + + const req = makeRequest("http://localhost/api/modules/test-scanner/versions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ version: "1.1.0" }), + }); + const res = await POST(req, makeContext("test-scanner")); + const body = await res.json(); + + expect(res.status).toBe(401); + expect(body.error).toContain("logged in"); + }); + + it("rejects invalid semver", async () => { + const req = makeRequest("http://localhost/api/modules/test-scanner/versions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer token-123" }, + body: JSON.stringify({ version: "latest" }), + }); + const res = await POST(req, makeContext("test-scanner")); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toContain("semantic version"); + }); + + it("rejects duplicate versions", async () => { + tableResults.module_versions = { + maybeSingle: [{ data: { id: "existing-version" }, error: null }], + }; + + const req = makeRequest("http://localhost/api/modules/test-scanner/versions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer token-123" }, + body: JSON.stringify({ version: "1.1.0" }), + }); + const res = await POST(req, makeContext("test-scanner")); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.error).toContain("already exists"); + }); + + it("returns conflict when the database rejects a duplicate release", async () => { + tableResults.module_versions = { + maybeSingle: [{ data: null, error: null }], + single: [{ data: null, error: { message: "duplicate key value violates unique constraint", code: "23505" } }], + }; + + const req = makeRequest("http://localhost/api/modules/test-scanner/versions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer token-123" }, + body: JSON.stringify({ version: "1.1.0" }), + }); + const res = await POST(req, makeContext("test-scanner")); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.error).toContain("already exists"); + }); + + it("creates a release and synchronizes module metadata", async () => { + const req = makeRequest("http://localhost/api/modules/test-scanner/versions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer token-123" }, + body: JSON.stringify({ + version: "1.1.0", + changelog: "Adds indicator modules", + git_tag: "v1.1.0", + package_url: "https://example.com/module.tgz", + min_threatcrush_version: ">=0.2.0", + }), + }); + const res = await POST(req, makeContext("test-scanner")); + const body = await res.json(); + + expect(res.status).toBe(201); + expect(body.version.version).toBe("1.1.0"); + expect(body.module.version).toBe("1.1.0"); + expect(mockInsertVersion).toHaveBeenCalledWith(expect.objectContaining({ + version: "1.1.0", + changelog: "Adds indicator modules", + git_tag: "v1.1.0", + package_url: "https://example.com/module.tgz", + })); + expect(mockUpdateModule).toHaveBeenCalledWith(expect.objectContaining({ + version: "1.1.0", + min_threatcrush_version: ">=0.2.0", + })); + }); +}); 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..0d41318 --- /dev/null +++ b/apps/web/src/app/api/modules/[slug]/versions/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSupabaseAdmin } from "@/lib/supabase"; + +type RouteContext = { params: Promise<{ slug: string }> }; + +const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; + +async function getAuthenticatedUser(request: NextRequest) { + const sb = getSupabaseAdmin(); + const authHeader = request.headers.get("authorization"); + const tokenFromHeader = authHeader?.replace("Bearer ", ""); + const tokenFromCookie = request.cookies?.get?.("sb-access-token")?.value + || request.cookies?.get?.("supabase-auth-token")?.value; + + for (const token of [tokenFromHeader, tokenFromCookie]) { + if (!token) continue; + const { data } = await sb.auth.getUser(token); + if (data?.user) return data.user; + } + + return null; +} + +/** + * GET /api/modules/[slug]/versions + * List module releases newest first. + */ +export async function GET( + _request: NextRequest, + context: RouteContext +) { + const { slug } = await context.params; + const sb = getSupabaseAdmin(); + + const { data: mod } = await sb + .from("modules") + .select("id") + .eq("slug", slug) + .eq("published", true) + .single(); + + if (!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 first-class module release. Requires the verified module author. + */ +export async function POST( + request: NextRequest, + context: RouteContext +) { + const user = await getAuthenticatedUser(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 = body.version as string | undefined; + if (!version) { + return NextResponse.json({ error: "version is required" }, { status: 400 }); + } + if (!SEMVER_RE.test(version)) { + return NextResponse.json({ error: "version must be semantic version format, for example 1.2.3" }, { status: 400 }); + } + + const sb = getSupabaseAdmin(); + + const { data: profile } = await sb + .from("user_profiles") + .select("id, email, email_verified") + .eq("id", user.id) + .single(); + + if (!profile) { + return NextResponse.json( + { error: "You must create an account before publishing module versions." }, + { status: 401 } + ); + } + + if (!profile.email_verified) { + return NextResponse.json( + { error: "Please verify your email before publishing module versions." }, + { status: 403 } + ); + } + + const { slug } = await context.params; + const { data: mod } = await sb + .from("modules") + .select("id, author_email") + .eq("slug", slug) + .single(); + + if (!mod) { + return NextResponse.json({ error: "Module not found" }, { status: 404 }); + } + if (mod.author_email !== profile.email) { + return NextResponse.json({ error: "Only the module author can publish versions." }, { status: 403 }); + } + + const { data: existingVersion } = await sb + .from("module_versions") + .select("id") + .eq("module_id", mod.id) + .eq("version", version) + .maybeSingle(); + + if (existingVersion) { + return NextResponse.json({ error: `Version ${version} already exists for this module.` }, { status: 409 }); + } + + const releaseData = { + module_id: mod.id, + version, + changelog: (body.changelog as string) || null, + package_url: (body.package_url as string) || null, + git_tag: (body.git_tag as string) || null, + min_threatcrush_version: (body.min_threatcrush_version as string) || null, + }; + + const { data: release, error: releaseError } = await sb + .from("module_versions") + .insert(releaseData) + .select() + .single(); + + if (releaseError) { + if ("code" in releaseError && releaseError.code === "23505") { + return NextResponse.json({ error: `Version ${version} already exists for this module.` }, { status: 409 }); + } + return NextResponse.json({ error: releaseError.message }, { status: 500 }); + } + + const moduleUpdates: Record = { + version, + updated_at: new Date().toISOString(), + }; + if (body.min_threatcrush_version) { + moduleUpdates.min_threatcrush_version = body.min_threatcrush_version; + } + + const { data: updatedModule, error: updateError } = await sb + .from("modules") + .update(moduleUpdates) + .eq("id", mod.id) + .select() + .single(); + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }); + } + + return NextResponse.json({ version: release, module: updatedModule }, { status: 201 }); +} diff --git a/apps/web/src/app/docs/modules/page.tsx b/apps/web/src/app/docs/modules/page.tsx index 47bd87f..f7d89ad 100644 --- a/apps/web/src/app/docs/modules/page.tsx +++ b/apps/web/src/app/docs/modules/page.tsx @@ -52,6 +52,8 @@ const apiEndpoints: Array<{ { method: "GET", path: "/api/modules/{slug}", auth: "none", purpose: "Module detail + versions + recent reviews" }, { method: "PATCH", path: "/api/modules/{slug}", auth: "email", purpose: "Edit your module" }, { method: "DELETE", path: "/api/modules/{slug}", auth: "email", purpose: "Remove your module" }, + { method: "GET", path: "/api/modules/{slug}/versions", auth: "none", purpose: "List published releases newest first" }, + { method: "POST", path: "/api/modules/{slug}/versions", auth: "bearer", purpose: "Publish a new module release" }, { method: "GET", path: "/api/modules/{slug}/install", auth: "none", purpose: "Read-only install info (does not count)" }, { method: "POST", path: "/api/modules/{slug}/install", auth: "none", purpose: "Install info + increment download count" }, { method: "GET", path: "/api/modules/{slug}/review", auth: "none", purpose: "List reviews (paginated)" }, diff --git a/docs/MODULE_STORE_API.md b/docs/MODULE_STORE_API.md index 7de8134..c88d098 100644 --- a/docs/MODULE_STORE_API.md +++ b/docs/MODULE_STORE_API.md @@ -26,6 +26,8 @@ an issue. | `GET` | `/api/modules/{slug}` | none | Module detail + versions + recent reviews | | `PATCH` | `/api/modules/{slug}` | email | Edit your module | | `DELETE` | `/api/modules/{slug}?author_email=…` | email | Remove your module | +| `GET` | `/api/modules/{slug}/versions` | none | List published releases newest first | +| `POST` | `/api/modules/{slug}/versions` | required | Publish a new module release | | `GET` | `/api/modules/{slug}/install` | none | Read-only install info (does **not** count) | | `POST` | `/api/modules/{slug}/install` | none | Install info **and** increment download count | | `GET` | `/api/modules/{slug}/review` | none | List reviews (paginated, 20 / page) | @@ -274,6 +276,63 @@ and reviews. --- +### `GET /api/modules/{slug}/versions` — list releases + +Public. Fails with `404` if the slug doesn't exist or `published` is `false`. + +**Response 200** + +```json +{ "versions": [ /* ModuleVersion[], newest first */ ] } +``` + +--- + +### `POST /api/modules/{slug}/versions` — publish release + +Publishes a new semantic-versioned release for a module. Requires a valid +Supabase Bearer token whose `user_profiles.email` is verified and matches the +module row's `author_email`. + +**Body** + +```json +{ + "version": "1.2.3", + "changelog": "Optional release notes", + "package_url": "https://example.com/module-1.2.3.tgz", + "git_tag": "v1.2.3", + "min_threatcrush_version": ">=0.2.0" +} +``` + +`version` is required and must use semantic version format such as `1.2.3`. +Duplicate versions return `409`. Successful publishes also synchronize the +module row's top-level `version`, `min_threatcrush_version` when provided, and +`updated_at`, so install clients see the current release metadata. + +**Response 201** + +```json +{ + "version": { /* ModuleVersion */ }, + "module": { /* updated Module */ } +} +``` + +**Errors** + +- `400 Invalid JSON` +- `400 version is required` +- `400 version must be semantic version format, for example 1.2.3` +- `401 You must be logged in to publish module versions.` +- `403 Please verify your email before publishing module versions.` +- `403 Only the module author can publish versions.` +- `404 Module not found` +- `409 Version {version} already exists for this module.` + +--- + ### `GET /api/modules/{slug}/install` — read-only install info Use this when a client wants to check what to install without affecting @@ -562,9 +621,8 @@ 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. +Use `GET /api/modules/{slug}/versions` to list release rows and +`POST /api/modules/{slug}/versions` to publish a new author-owned release. ### Review *(row in `module_reviews`)* @@ -723,9 +781,8 @@ 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}`. +2. **Signed release artifacts.** First-class version metadata is available now, + but uploaded tarballs are still URL-based and unsigned. 3. **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.