diff --git a/README.md b/README.md index 8463f92..b64d0d4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Everything is built on [Hono](https://hono.dev), making it lightweight and fast. ## What it does -The worker exposes three main services: +The worker exposes two main services: - **Versions Service** (`/versions/v1`) The source of truth for FOSSBilling updates. It fetches release data from GitHub, caches it for performance, and helps instances decide if they need to update. @@ -14,9 +14,6 @@ The worker exposes three main services: - **Central Alerts** (`/central-alerts/v1`) Allows the project to push critical notifications to all FOSSBilling installations—useful for security hotfixes or major announcements. -- **Releases Service** (`/releases/v1`) - _Legacy._ This is kept around to support older FOSSBilling versions that haven't updated to the new update system yet. It sends deprecation headers and will eventually be removed. - ## Architecture We've structured the app to separate the core logic from the specific runtime environment (Cloudflare, Node, etc.). diff --git a/src/app/index.ts b/src/app/index.ts index 11845ae..4ea5993 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -2,7 +2,6 @@ import { Hono } from "hono"; import { contextStorage } from "hono/context-storage"; import { HTTPException } from "hono/http-exception"; import centralAlertsV1 from "../services/central-alerts/v1"; -import releasesV1 from "../services/releases/v1"; import versionsV1 from "../services/versions/v1"; import { platformMiddleware } from "../lib/middleware"; import { createCloudflareBindings } from "../lib/adapters/cloudflare"; @@ -17,7 +16,6 @@ app.use("*", async (c, next) => { return middleware(c, next); }); -app.route("/releases/v1", releasesV1); app.route("/central-alerts/v1", centralAlertsV1); app.route("/versions/v1", versionsV1); diff --git a/src/services/central-alerts/v1/index.ts b/src/services/central-alerts/v1/index.ts index d461b69..421a9cc 100644 --- a/src/services/central-alerts/v1/index.ts +++ b/src/services/central-alerts/v1/index.ts @@ -1,11 +1,12 @@ import { Hono } from "hono"; +import { cors } from "hono/cors"; import { trimTrailingSlash } from "hono/trailing-slash"; import { CentralAlertsDatabase } from "./database"; import { getPlatform } from "../../../lib/middleware"; const centralAlertsV1 = new Hono<{ Bindings: CloudflareBindings }>(); -centralAlertsV1.use(trimTrailingSlash()); +centralAlertsV1.use("/*", cors({ origin: "*" }), trimTrailingSlash()); centralAlertsV1.get("/list", async (c) => { const platform = getPlatform(c); diff --git a/src/services/releases/v1/README.md b/src/services/releases/v1/README.md deleted file mode 100644 index 3f07699..0000000 --- a/src/services/releases/v1/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Releases Service (Deprecated) - -**Base Path:** `/releases/v1` - -This service is deprecated and scheduled for removal on **December 31, 2025**. New implementations should use the Versions Service instead. - -The Releases service provides FOSSBilling release information with basic support status tracking. It was originally created to help installations determine if they're running supported versions, but the Versions Service now offers more comprehensive functionality. - -## Deprecation Notice - -All responses from this service include HTTP deprecation headers: - -- `Deprecation: true` -- `Sunset: Wed, 31 Dec 2025 23:59:59 UTC` -- `Link: ; rel="successor-version"` - -These headers allow automated tools and clients to detect the deprecation and plan for migration. - -## Endpoints - -### GET `/` - -Returns all FOSSBilling releases with a simple support status classification. - -This endpoint fetches release data from the Versions Service and adds a basic `support` field to each version. The support status is determined by comparing each version to the latest release: - -- Latest version: `latest` -- Versions with only patch-level differences: `outdated` -- All other versions: `insecure` - -**Request:** - -```http -GET /releases/v1 -``` - -**Response:** - -```json -{ - "result": { - "versions": [ - { - "version": "0.5.0", - "support": "insecure" - }, - { - "version": "0.7.2", - "support": "latest" - } - ] - }, - "error": null -} -``` - -## Migration Guide - -To migrate to the Versions Service, replace calls to `/releases/v1` with `/versions/v1`. The Versions Service provides the same information plus additional details like download URLs, PHP requirements, changelogs, and file sizes. See the [Versions Service documentation](../versions/v1/README.md) for details. diff --git a/src/services/releases/v1/index.ts b/src/services/releases/v1/index.ts deleted file mode 100644 index b2e8e07..0000000 --- a/src/services/releases/v1/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Hono } from "hono"; -import { cache } from "hono/cache"; -import { cors } from "hono/cors"; -import { etag } from "hono/etag"; -import { prettyJSON } from "hono/pretty-json"; -import { trimTrailingSlash } from "hono/trailing-slash"; -import { diff as semverDiff, compare as semverCompare } from "semver"; -import { FOSSBillingVersion } from "./interfaces"; -import { getReleases } from "../../versions/v1"; -import { getPlatform } from "../../../lib/middleware"; - -const releasesV1 = new Hono<{ Bindings: CloudflareBindings; strict: true }>(); - -releasesV1.use( - "/*", - cors({ - origin: "*" - }), - trimTrailingSlash() -); - -releasesV1.get( - "/", - cache({ cacheName: "releases-api-v1", cacheControl: "max-age: 86400" }), - etag(), - prettyJSON(), - async (c) => { - const platform = getPlatform(c); - const releases = await getReleases( - platform.getCache("CACHE_KV"), - platform.getEnv("GITHUB_TOKEN") || "", - false - ); - - const rawVersions = Object.keys(releases).sort(semverCompare); - const latestVersion = rawVersions[rawVersions.length - 1]; - const versions: FOSSBillingVersion[] = rawVersions.map((version) => { - const versionStr = String(version); - - if (versionStr === latestVersion) { - return { version: versionStr, support: "latest" }; - } - - const support = - semverDiff(versionStr, latestVersion) === "patch" - ? "outdated" - : "insecure"; - - return { version: versionStr, support }; - }); - - c.header("Deprecation", "true"); - c.header("Sunset", "Wed, 31 Dec 2025 23:59:59 UTC"); - c.header("Link", '; rel="successor-version"'); - - return c.json({ result: { versions }, error: null }); - } -); - -export default releasesV1; diff --git a/src/services/releases/v1/interfaces.ts b/src/services/releases/v1/interfaces.ts deleted file mode 100644 index 4b1913a..0000000 --- a/src/services/releases/v1/interfaces.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type FOSSBillingVersion = { - version: string; - support: "latest" | "outdated" | "insecure"; -}; diff --git a/test/app/index.test.ts b/test/app/index.test.ts index 5d704dc..86a04e8 100644 --- a/test/app/index.test.ts +++ b/test/app/index.test.ts @@ -16,9 +16,7 @@ import { ApiResponse, CentralAlertsResponse, GitHubContentResponse, - MockFetchResponse, MockGitHubRequest, - ReleasesResponse, VersionInfo, VersionsResponse } from "../utils/test-types"; @@ -104,36 +102,6 @@ describe("FOSSBilling API Worker - Main App", () => { expect(data).toHaveProperty("message"); }); - it("should route /releases/v1 to releases service", async () => { - // Mock internal fetch call that releases service makes to versions service - const originalFetch = global.fetch; - global.fetch = vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ - result: { - "0.5.0": { version: "0.5.0" }, - "0.6.0": { version: "0.6.0" } - } - }) - } as MockFetchResponse); - - const ctx = createExecutionContext(); - const response = await app.request("/releases/v1", {}, env, ctx); - await waitOnExecutionContext(ctx); - - expect(response.status).toBe(200); - const data: ReleasesResponse = await response.json(); - - // Should return releases API response format with deprecation headers - expect(data).toHaveProperty("result"); - expect(data).toHaveProperty("error"); - expect(response.headers.get("Deprecation")).toBeTruthy(); - expect(response.headers.get("Sunset")).toBeTruthy(); - - // Restore fetch - global.fetch = originalFetch; - }); - it("should route /central-alerts/v1/list to central alerts service", async () => { const ctx = createExecutionContext(); const response = await app.request( @@ -233,34 +201,6 @@ describe("FOSSBilling API Worker - Main App", () => { expect(data.result.version).toBe("0.6.0"); }); - it("should allow releases service to fetch from versions service", async () => { - // Mock internal fetch call that releases service makes - const originalFetch = global.fetch; - global.fetch = vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ - result: { - "0.5.0": { version: "0.5.0" }, - "0.6.0": { version: "0.6.0" } - } - }) - } as MockFetchResponse); - - const ctx = createExecutionContext(); - const response = await app.request("/releases/v1", {}, env, ctx); - await waitOnExecutionContext(ctx); - - expect(response.status).toBe(200); - const data: ReleasesResponse = await response.json(); - - // Releases service internally calls versions service - expect(data.result).toBeTruthy(); - expect(data.result.versions.length).toBeGreaterThan(0); - - // Restore fetch - global.fetch = originalFetch; - }); - it("should allow central alerts service to return static data", async () => { const ctx = createExecutionContext(); const response = await app.request( diff --git a/test/integration/app.test.ts b/test/integration/app.test.ts index cb69be6..56c7169 100644 --- a/test/integration/app.test.ts +++ b/test/integration/app.test.ts @@ -12,7 +12,6 @@ import { ApiResponse, CentralAlertsResponse, MockGitHubRequest, - ReleasesResponse, VersionsResponse } from "../utils/test-types"; @@ -38,35 +37,28 @@ describe("FOSSBilling API Worker - Full App Integration", () => { }); describe("Service Discovery and Routing", () => { - it("should route to all three services correctly", async () => { + it("should route to all services correctly", async () => { const ctx1 = createExecutionContext(); const versionsResponse = await app.request("/versions/v1", {}, env, ctx1); await waitOnExecutionContext(ctx1); const ctx2 = createExecutionContext(); - const releasesResponse = await app.request("/releases/v1", {}, env, ctx2); - await waitOnExecutionContext(ctx2); - - const ctx3 = createExecutionContext(); const alertsResponse = await app.request( "/central-alerts/v1/list", {}, env, - ctx3 + ctx2 ); - await waitOnExecutionContext(ctx3); + await waitOnExecutionContext(ctx2); expect(versionsResponse.status).toBe(200); - expect(releasesResponse.status).toBe(200); + expect(alertsResponse.status).toBe(200); const versionsData = (await versionsResponse.json()) as VersionsResponse; - const releasesData = (await releasesResponse.json()) as ReleasesResponse; const alertsData = (await alertsResponse.json()) as CentralAlertsResponse; expect(versionsData).toHaveProperty("result"); expect(versionsData).toHaveProperty("error_code", 0); - expect(releasesData).toHaveProperty("result"); - expect(releasesData.result.versions).toBeInstanceOf(Array); expect(alertsData).toHaveProperty("result"); }); @@ -92,27 +84,13 @@ describe("FOSSBilling API Worker - Full App Integration", () => { }); describe("Cross-Service Communication", () => { - it("should allow releases service to fetch from versions service", async () => { - const originalFetch = global.fetch; - global.fetch = vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ - result: { - "0.5.0": { version: "0.5.0" }, - "0.6.0": { version: "0.6.0" } - } - }) - } as Response); - + it("should allow services to share cached data", async () => { const ctx = createExecutionContext(); - const response = await app.request("/releases/v1", {}, env, ctx); + await app.request("/versions/v1", {}, env, ctx); await waitOnExecutionContext(ctx); - expect(response.status).toBe(200); - const data = (await response.json()) as ReleasesResponse; - expect(data.result.versions.length).toBeGreaterThan(0); - - global.fetch = originalFetch; + const cached = await env.CACHE_KV.get("gh-fossbilling-releases"); + expect(cached).toBeTruthy(); }); }); @@ -165,7 +143,6 @@ describe("FOSSBilling API Worker - Full App Integration", () => { it("should handle 404 for invalid service routes", async () => { const endpoints = [ "/versions/v1/invalid-endpoint", - "/releases/v1/invalid", "/central-alerts/v1/invalid" ]; @@ -191,8 +168,7 @@ describe("FOSSBilling API Worker - Full App Integration", () => { it("should maintain consistent response format across all services", async () => { const endpoints = [ { path: "/versions/v1", fields: ["result", "error_code", "message"] }, - { path: "/central-alerts/v1/list", fields: ["result"] }, - { path: "/releases/v1", fields: ["result", "error"] } + { path: "/central-alerts/v1/list", fields: ["result"] } ]; for (const { path, fields } of endpoints) { @@ -213,8 +189,7 @@ describe("FOSSBilling API Worker - Full App Integration", () => { const endpoints = [ "/versions/v1", "/versions/v1/latest", - "/central-alerts/v1/list", - "/releases/v1" + "/central-alerts/v1/list" ]; for (const endpoint of endpoints) { @@ -245,7 +220,7 @@ describe("FOSSBilling API Worker - Full App Integration", () => { }); it("should handle OPTIONS preflight requests", async () => { - const endpoints = ["/versions/v1", "/releases/v1"]; + const endpoints = ["/versions/v1"]; for (const endpoint of endpoints) { const ctx = createExecutionContext(); @@ -267,7 +242,7 @@ describe("FOSSBilling API Worker - Full App Integration", () => { describe("Headers and Middleware", () => { it("should include CORS headers on all responses", async () => { - const endpoints = ["/versions/v1", "/releases/v1"]; + const endpoints = ["/versions/v1", "/central-alerts/v1/list"]; for (const endpoint of endpoints) { const ctx = createExecutionContext(); @@ -278,15 +253,6 @@ describe("FOSSBilling API Worker - Full App Integration", () => { } }); - it("should include deprecation headers on releases service", async () => { - const ctx = createExecutionContext(); - const response = await app.request("/releases/v1", {}, env, ctx); - await waitOnExecutionContext(ctx); - - expect(response.headers.get("Deprecation")).toBe("true"); - expect(response.headers.get("Sunset")).toBeTruthy(); - }); - it("should include ETag headers on cacheable responses", async () => { const ctx = createExecutionContext(); const response = await app.request("/versions/v1", {}, env, ctx); diff --git a/test/mocks/README.md b/test/mocks/README.md index f9e2cf7..cd8e3c5 100644 --- a/test/mocks/README.md +++ b/test/mocks/README.md @@ -6,7 +6,6 @@ This directory contains mock implementations and mock data used across the test - `mock-adapters.ts` - Mock implementations for database, cache, and environment adapters - `github-releases.ts` - Mock GitHub API release data -- `releases.ts` - Processed release mock data for testing releases API ## Usage diff --git a/test/mocks/releases.ts b/test/mocks/releases.ts deleted file mode 100644 index 03955c0..0000000 --- a/test/mocks/releases.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Processed Releases Mock Data - * - * Fixtures for testing releases processing and API responses - * - * @license AGPL-3.0 - */ - -import { Releases } from "../../src/services/versions/v1/interfaces"; - -export const mockReleases: Releases = { - "0.5.0": { - version: "0.5.0", - released_on: "2023-01-01T00:00:00Z", - minimum_php_version: "8.1", - download_url: - "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.5.0/FOSSBilling.zip", - size_bytes: 1024000, - is_prerelease: false, - github_release_id: 1001, - changelog: "## 0.5.0\n- Initial release" - }, - "0.5.1": { - version: "0.5.1", - released_on: "2023-02-01T00:00:00Z", - minimum_php_version: "8.1", - download_url: - "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.5.1/FOSSBilling.zip", - size_bytes: 1025000, - is_prerelease: false, - github_release_id: 1002, - changelog: "## 0.5.1\n- Bug fixes" - }, - "0.5.2": { - version: "0.5.2", - released_on: "2023-03-01T00:00:00Z", - minimum_php_version: "8.1", - download_url: - "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.5.2/FOSSBilling.zip", - size_bytes: 1026000, - is_prerelease: false, - github_release_id: 1003, - changelog: "## 0.5.2\n- More fixes" - }, - "0.6.0": { - version: "0.6.0", - released_on: "2023-04-01T00:00:00Z", - minimum_php_version: "8.2", - download_url: - "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip", - size_bytes: 1030000, - is_prerelease: false, - github_release_id: 1004, - changelog: "## 0.6.0\n- New features" - } -}; - -export const mockVersionsApiResponse = { - result: { - "0.5.0": { version: "0.5.0" }, - "0.5.1": { version: "0.5.1" }, - "0.5.2": { version: "0.5.2" }, - "0.6.0": { version: "0.6.0" } - } -}; diff --git a/test/services/releases/v1/index.test.ts b/test/services/releases/v1/index.test.ts deleted file mode 100644 index 076986b..0000000 --- a/test/services/releases/v1/index.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { - env, - createExecutionContext, - waitOnExecutionContext -} from "cloudflare:test"; -import app from "../../../../src/app"; -import { mockReleases } from "../../../mocks/releases"; -import { suppressConsole } from "../../../utils/mock-helpers"; -import type { ReleasesResponse } from "../../../utils/test-types"; -import { getReleases } from "../../../../src/services/versions/v1"; -import { Releases } from "../../../../src/services/versions/v1/interfaces"; - -vi.mock("../../../../src/services/versions/v1", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("../../../../src/services/versions/v1") - >(); - return { - ...actual, - getReleases: vi.fn() - }; -}); - -let restoreConsole: (() => void) | null = null; - -describe("Releases API v1 (Deprecated)", () => { - beforeEach(() => { - restoreConsole = suppressConsole(); - vi.clearAllMocks(); - }); - - afterEach(() => { - if (restoreConsole) { - restoreConsole(); - restoreConsole = null; - } - }); - - describe("GET /", () => { - it("should return releases with support status", async () => { - vi.mocked(getReleases).mockResolvedValue(mockReleases); - - const ctx = createExecutionContext(); - const response = await app.request("/releases/v1", {}, env, ctx); - await waitOnExecutionContext(ctx); - - expect(response.status).toBe(200); - const data: ReleasesResponse = await response.json(); - - expect(data).toHaveProperty("result"); - expect(data).toHaveProperty("error"); - expect(data.result).toHaveProperty("versions"); - expect(Array.isArray(data.result.versions)).toBe(true); - - // Check deprecation headers - expect(response.headers.get("Deprecation")).toBeTruthy(); - expect(response.headers.get("Sunset")).toBeTruthy(); - expect(response.headers.get("Link")).toBeTruthy(); - }); - - it("should handle errors gracefully", async () => { - vi.mocked(getReleases).mockRejectedValue(new Error("Database error")); - - const ctx = createExecutionContext(); - const response = await app.request("/releases/v1", {}, env, ctx); - await waitOnExecutionContext(ctx); - - expect(response.status).toBe(500); - const data: ReleasesResponse = await response.json(); - - expect(data).toHaveProperty("result", null); - expect(data).toHaveProperty("error"); - expect(data.error).toBeTruthy(); - }); - }); - - describe("Support Status Calculation", () => { - it("should mark old versions as unsupported", async () => { - const mockData = { - "0.1.0": { version: "0.1.0" }, - "0.2.0": { version: "0.2.0" } - }; - - vi.mocked(getReleases).mockResolvedValue(mockData as unknown as Releases); - - const ctx = createExecutionContext(); - const response = await app.request("/releases/v1", {}, env, ctx); - await waitOnExecutionContext(ctx); - - expect(response.status).toBe(200); - const data: ReleasesResponse = await response.json(); - const versions = data.result.versions; - expect(versions[0].support).toBe("insecure"); - expect(versions[1].support).toBe("latest"); - }); - - it("should mark recent versions as supported", async () => { - const mockData = { - "0.5.0": { version: "0.5.0" }, - "0.6.0": { version: "0.6.0" } - }; - - vi.mocked(getReleases).mockResolvedValue(mockData as unknown as Releases); - - const ctx = createExecutionContext(); - const response = await app.request("/releases/v1", {}, env, ctx); - await waitOnExecutionContext(ctx); - - expect(response.status).toBe(200); - const data: ReleasesResponse = await response.json(); - const versions = data.result.versions; - expect(versions[0].support).toBe("insecure"); - expect(versions[1].support).toBe("latest"); - }); - }); - - describe("Deprecation Headers", () => { - it("should include all required deprecation headers", async () => { - vi.mocked(getReleases).mockResolvedValue(mockReleases); - - const ctx = createExecutionContext(); - const response = await app.request("/releases/v1", {}, env, ctx); - await waitOnExecutionContext(ctx); - - expect(response.headers.get("Deprecation")).toBe("true"); - expect(response.headers.get("Sunset")).toBeTruthy(); - expect(response.headers.get("Link")).toBeTruthy(); - - const linkHeader = response.headers.get("Link"); - expect(linkHeader).toContain('rel="successor-version"'); - expect(linkHeader).toContain("/versions/v1"); - }); - }); - - describe("Error Handling", () => { - it("should return 404 for unknown routes", async () => { - const ctx = createExecutionContext(); - const response = await app.request("/releases/v1/unknown", {}, env, ctx); - await waitOnExecutionContext(ctx); - - expect(response.status).toBe(404); - }); - - it("should handle missing versions data", async () => { - vi.mocked(getReleases).mockResolvedValue({}); - - const ctx = createExecutionContext(); - const response = await app.request("/releases/v1", {}, env, ctx); - await waitOnExecutionContext(ctx); - - expect(response.status).toBe(200); - const data: ReleasesResponse = await response.json(); - - expect(data.result.versions).toHaveLength(0); - }); - }); -}); diff --git a/test/utils/test-types.ts b/test/utils/test-types.ts index 6f6285d..3fe7647 100644 --- a/test/utils/test-types.ts +++ b/test/utils/test-types.ts @@ -43,16 +43,6 @@ export interface UpdateResponse { message: string | null; } -export interface ReleasesResponse { - result: { - versions: ReleaseVersion[]; - }; - error: { - code: number; - message: string; - } | null; -} - // Version and Release Types export interface VersionInfo { version: string; @@ -65,11 +55,6 @@ export interface VersionInfo { changelog: string; } -export interface ReleaseVersion { - version: string; - support: "supported" | "unsupported" | "latest" | "outdated" | "insecure"; -} - // GitHub Types export interface GitHubRelease { id: number;