From 587eae774ed933dc6d875fc8b4a7ea8cea846fec Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 13 Jun 2026 07:18:23 +0200 Subject: [PATCH] feat: serve the entire customer API (auth + resources) only under /v1 with no unversioned alias, and repoint the web and admin clients to the versioned base accordingly --- admin/src/lib/api/client.ts | 2 +- admin/src/lib/api/client/auth/index.ts | 8 ++--- docs/content/docs/api/endpoints.mdx | 2 +- docs/content/docs/api/index.mdx | 2 +- internal/api/middleware/version.go | 28 ---------------- internal/api/routes.go | 32 ++++++++----------- web/src/hooks/TimezoneProvider.tsx | 4 +-- web/src/lib/api.ts | 6 ++-- web/src/lib/api/client/Client.ts | 4 +-- .../lib/api/client/auth/passkey/loginBegin.ts | 4 +-- web/src/lib/information.ts | 3 ++ 11 files changed, 32 insertions(+), 63 deletions(-) diff --git a/admin/src/lib/api/client.ts b/admin/src/lib/api/client.ts index d7bcfa0b..952ad9e3 100644 --- a/admin/src/lib/api/client.ts +++ b/admin/src/lib/api/client.ts @@ -51,7 +51,7 @@ interface AuthRequestConfig extends AxiosRequestConfig { let refreshPromise: Promise | null = null; async function refreshTokens(refreshToken: string): Promise { - const res = await axios.post(`${API_URL}/auth/refresh`, { + const res = await axios.post(`${API_URL}/v1/auth/refresh`, { refresh_token: refreshToken, }); return res.data; diff --git a/admin/src/lib/api/client/auth/index.ts b/admin/src/lib/api/client/auth/index.ts index e0e80a64..e27fdfcd 100644 --- a/admin/src/lib/api/client/auth/index.ts +++ b/admin/src/lib/api/client/auth/index.ts @@ -15,7 +15,7 @@ import type { export function login(input: LoginRequest): Promise { return Request({ method: "POST", - url: "/auth/login", + url: "/v1/auth/login", data: input, timeout: 15_000, }); @@ -25,7 +25,7 @@ export function login(input: LoginRequest): Promise { export function loginConfirm(input: LoginConfirmRequest): Promise { return Request({ method: "POST", - url: "/auth/login/confirm", + url: "/v1/auth/login/confirm", data: input, timeout: 15_000, }); @@ -34,7 +34,7 @@ export function loginConfirm(input: LoginConfirmRequest): Promise export function getMe(): Promise { return Request({ method: "GET", - url: "/auth/me", + url: "/v1/auth/me", authorization: true, }); } @@ -42,7 +42,7 @@ export function getMe(): Promise { export function logout(): Promise { return Request({ method: "POST", - url: "/auth/logout", + url: "/v1/auth/logout", authorization: true, }); } diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 52526c79..59261f4d 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -12,7 +12,7 @@ This page is the source of truth for what an API key can and cannot reach. Every When an endpoint says "JWT permission: X / API permission: Y", the dual-auth middleware checks the relevant one based on which credential the caller used. -All paths below are relative to the versioned base URL `https://api.warmbly.com/v1` (for example `/campaigns` is `https://api.warmbly.com/v1/campaigns`). The same paths still resolve unversioned for backward compatibility, but those responses are marked deprecated. See [versioning](/api/) for details. +All paths below are relative to the versioned base URL `https://api.warmbly.com/v1` (for example `/campaigns` is `https://api.warmbly.com/v1/campaigns`). See [versioning](/api/) for details. ## API key accepted diff --git a/docs/content/docs/api/index.mdx b/docs/content/docs/api/index.mdx index 2f9273ee..deb84992 100644 --- a/docs/content/docs/api/index.mdx +++ b/docs/content/docs/api/index.mdx @@ -28,7 +28,7 @@ The API is versioned in the URL. The current version is `v1`, and the base URL i - **Stability**: within a version, changes are additive (new fields, new endpoints). A breaking change ships as a new version, so code written against `v1` keeps working. - **Version header**: every response carries an `API-Version` header so you can confirm which version answered. -- **Unversioned paths are deprecated**: the same routes still resolve without the `/v1` prefix for backward compatibility, but those responses carry `Deprecation` and `Sunset` headers. Always use `/v1`; the unversioned aliases will be removed after the sunset date. +- **No unversioned alias**: every endpoint lives under `/v1`; there are no bare, unversioned paths. A future breaking change will ship as `/v2` while `/v1` keeps working. ## Conventions diff --git a/internal/api/middleware/version.go b/internal/api/middleware/version.go index b9e3791c..9ef3f61b 100644 --- a/internal/api/middleware/version.go +++ b/internal/api/middleware/version.go @@ -6,12 +6,6 @@ import "github.com/gin-gonic/gin" // / (and, for now, also at the bare path as a deprecated alias). const APIVersion = "v1" -// apiVersionSunset is when the unversioned bare-path aliases are scheduled to be -// removed. Integrators should migrate to the /v1 paths before this date. It is a -// conservative far-future target, not a hard cut tomorrow; revise as policy -// firms up. -const apiVersionSunset = "Mon, 13 Dec 2027 00:00:00 GMT" - // APIVersionMiddleware stamps every response with the current API version so a // client can detect which surface it is talking to without parsing the URL. func APIVersionMiddleware(version string) gin.HandlerFunc { @@ -20,25 +14,3 @@ func APIVersionMiddleware(version string) gin.HandlerFunc { c.Next() } } - -// DeprecatedAliasMiddleware marks responses served from the unversioned bare -// paths as deprecated and points clients at the /v1 successor (RFC 8594 -// Deprecation + Sunset, plus a 299 Warning for older clients). Applied only to -// the bare-path alias mount, never to /v1. -// -// The nudge targets EXTERNAL integrators only: it emits just for API-key callers. -// The first-party dashboard authenticates with a JWT and shares one HTTP client -// with the unversioned /auth routes, so it stays on the bare paths without being -// told it is deprecated. It must run after the auth middleware (it reads the -// resolved auth type), which it does in the route group ordering. -func DeprecatedAliasMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - if GetAuthType(c) == AuthTypeAPIKey { - c.Header("Deprecation", "true") - c.Header("Sunset", apiVersionSunset) - c.Header("Link", `; rel="successor-version"`) - c.Header("Warning", `299 - "Unversioned Warmbly API paths are deprecated; migrate to /v1"`) - } - c.Next() - } -} diff --git a/internal/api/routes.go b/internal/api/routes.go index 57c54fc7..07736eff 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -157,7 +157,14 @@ func Run( c.Next() }) - auth := r.Group("/auth") + // The entire customer-facing API surface (auth + the API-key-capable and + // session-only routes) lives under a single versioned prefix, /v1. There is + // no unversioned alias: a breaking change ships as /v2. Truly public routes + // (health, signed webhooks, OAuth bouncers, worker enroll, the internal API, + // and /admin) are NOT versioned and stay at their bare paths. + v1 := r.Group("/v1") + + auth := v1.Group("/auth") { auth.POST("/login", h.LoginStart) auth.POST("/login/confirm", h.LoginConfirm) @@ -221,29 +228,20 @@ func Run( } // The full customer-facing API surface (the API-key-capable `protected` - // routes and the session-only sensitive routes) is mounted TWICE: once under - // the canonical /v1 prefix, and once at the bare path as a deprecated alias - // so existing integrators keep working. The bare alias emits Deprecation / - // Sunset headers; every response also carries API-Version. /auth, /admin, the - // internal API, and the public OAuth bouncers are intentionally NOT versioned. - mountPublicAPI := func(base *gin.RouterGroup, deprecated bool) { + // routes and the session-only sensitive routes), mounted under the versioned + // `base` (/v1). Every response also carries an API-Version header. + mountPublicAPI := func(base *gin.RouterGroup) { // JWT-only group: routes tied to a human session, never reachable via a // long-lived API key (billing, org governance, websocket bootstrap, and // the email onboarding flow that writes user-encrypted secrets). jwtOnly := base.Group("") jwtOnly.Use(m.AuthMiddleware()) - if deprecated { - jwtOnly.Use(middleware.DeprecatedAliasMiddleware()) - } // API-accessible group: routes that accept either a JWT or an API key. // CombinedAuthMiddleware sets the same context keys for both; the usage // middleware records one log row per API-key request (JWT skipped). protected := base.Group("") protected.Use(m.CombinedAuthMiddleware(), m.APIKeyUsageMiddleware(), m.IdempotencyMiddleware()) - if deprecated { - protected.Use(middleware.DeprecatedAliasMiddleware()) - } { emails := protected.Group("/emails") emails.Use(m.RateLimitMiddleware(models.RateLimitWrite)) @@ -766,12 +764,8 @@ func Run( } } - // Canonical versioned mount, plus the unversioned bare paths as a deprecated - // alias so existing integrators keep working during the migration window. - // The bare alias just carries Deprecation/Sunset headers; the routes are - // otherwise identical. - mountPublicAPI(r.Group("/v1"), false) - mountPublicAPI(r.Group(""), true) + // Single versioned mount. No unversioned alias. + mountPublicAPI(v1) // Admin routes (requires admin permissions) adminRoutes := r.Group("/admin") diff --git a/web/src/hooks/TimezoneProvider.tsx b/web/src/hooks/TimezoneProvider.tsx index 93c0ff4f..24eb39f6 100644 --- a/web/src/hooks/TimezoneProvider.tsx +++ b/web/src/hooks/TimezoneProvider.tsx @@ -1,6 +1,6 @@ "use client"; -import { API_URL } from "@/lib/information"; +import { API_BASE_URL } from "@/lib/information"; import React, { createContext, useContext } from "react"; import { useError } from "./ErrorProvider"; @@ -18,7 +18,7 @@ export default function TimezoneProvider({children}:{children: React.ReactNode}) React.useEffect(() => { const Do = async() => { try { - const resp = await fetch(`${API_URL}/timezones`) + const resp = await fetch(`${API_BASE_URL}/timezones`) if (!resp.ok){ showError(`Error ${resp.status}`, "Something went wrong when fetching the timezones.") } else { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9c27e15d..4e1e8e18 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,5 @@ import { saveTokens, TOKENS, clearTokens } from "./auth"; -import { API_URL } from "./information"; +import { API_BASE_URL } from "./information"; export const isAuthenticated = (): boolean => { for (const key of TOKENS) { @@ -44,7 +44,7 @@ export const refreshToken = async () => { } const token = localStorage.getItem('refresh_token'); - const resp = await fetch(`${API_URL}/auth/refresh`, { + const resp = await fetch(`${API_BASE_URL}/auth/refresh`, { method: "POST", headers: { 'Content-Type': 'application/json', @@ -93,7 +93,7 @@ export async function Call( const token = localStorage.getItem('access_token'); - const res = await fetch(`${API_URL}${endpoint}`, { + const res = await fetch(`${API_BASE_URL}${endpoint}`, { method, headers: { 'Content-Type': 'application/json', diff --git a/web/src/lib/api/client/Client.ts b/web/src/lib/api/client/Client.ts index aa650dd9..c1a4bcd9 100644 --- a/web/src/lib/api/client/Client.ts +++ b/web/src/lib/api/client/Client.ts @@ -1,9 +1,9 @@ import axios from "axios"; -import { API_URL } from "@/lib/information"; +import { API_BASE_URL } from "@/lib/information"; import { normalizeError } from "./normalizeError"; const Client = axios.create({ - baseURL: API_URL, + baseURL: API_BASE_URL, }) Client.interceptors.response.use( diff --git a/web/src/lib/api/client/auth/passkey/loginBegin.ts b/web/src/lib/api/client/auth/passkey/loginBegin.ts index a6168e81..a3214c0b 100644 --- a/web/src/lib/api/client/auth/passkey/loginBegin.ts +++ b/web/src/lib/api/client/auth/passkey/loginBegin.ts @@ -1,11 +1,11 @@ -import { API_URL } from "@/lib/information"; +import { API_BASE_URL } from "@/lib/information"; import type PasskeyLoginBegin from "@/lib/api/models/auth/PasskeyLoginBegin"; export default async function passkeyLoginBegin(): Promise { // Keep this as a direct fetch instead of the shared axios wrapper. Safari's // WebAuthn user-gesture detection is sensitive to async wrapper layers // before startAuthentication(). - const response = await fetch(`${API_URL}/auth/passkey/login/begin`, { + const response = await fetch(`${API_BASE_URL}/auth/passkey/login/begin`, { method: "POST", headers: { "Content-Type": "application/json" }, }); diff --git a/web/src/lib/information.ts b/web/src/lib/information.ts index ced24c29..355eaa73 100644 --- a/web/src/lib/information.ts +++ b/web/src/lib/information.ts @@ -1,6 +1,9 @@ export const WEBSITE_URL = "https://warmbly.com"; export const APP_URL = import.meta.env.VITE_APP_URL!; export const API_URL = import.meta.env.VITE_API_URL!; +// The whole dashboard talks to the versioned API. VITE_API_URL is a bare origin +// (no path), so this is the single place the /v1 prefix is applied. +export const API_BASE_URL = `${API_URL}/v1`; export const TRACKING_DOMAIN = import.meta.env.VITE_TRACKING_DOMAIN!; export const HUMAN_VERIFICATION_FAIL = "We couldn’t verify you’re human. Please try the security check again or reload the page."; export const PASSWORD_FAIL = "The password must be at least 8 characters long and contain both uppercase and lowercase letters, as well as a number."