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

This commit is contained in:
Matthew Meszaros
2026-06-13 07:18:23 +02:00
parent 7901e9a92c
commit 587eae774e
11 changed files with 32 additions and 63 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ interface AuthRequestConfig extends AxiosRequestConfig {
let refreshPromise: Promise<AdminToken> | null = null;
async function refreshTokens(refreshToken: string): Promise<AdminToken> {
const res = await axios.post<AdminToken>(`${API_URL}/auth/refresh`, {
const res = await axios.post<AdminToken>(`${API_URL}/v1/auth/refresh`, {
refresh_token: refreshToken,
});
return res.data;
+4 -4
View File
@@ -15,7 +15,7 @@ import type {
export function login(input: LoginRequest): Promise<LoginStartResponse> {
return Request<LoginStartResponse>({
method: "POST",
url: "/auth/login",
url: "/v1/auth/login",
data: input,
timeout: 15_000,
});
@@ -25,7 +25,7 @@ export function login(input: LoginRequest): Promise<LoginStartResponse> {
export function loginConfirm(input: LoginConfirmRequest): Promise<LoginResponse> {
return Request<LoginResponse>({
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<LoginResponse>
export function getMe(): Promise<AdminProfile> {
return Request<AdminProfile>({
method: "GET",
url: "/auth/me",
url: "/v1/auth/me",
authorization: true,
});
}
@@ -42,7 +42,7 @@ export function getMe(): Promise<AdminProfile> {
export function logout(): Promise<void> {
return Request<void>({
method: "POST",
url: "/auth/logout",
url: "/v1/auth/logout",
authorization: true,
});
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
-28
View File
@@ -6,12 +6,6 @@ import "github.com/gin-gonic/gin"
// /<APIVersion> (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", `</v1>; rel="successor-version"`)
c.Header("Warning", `299 - "Unversioned Warmbly API paths are deprecated; migrate to /v1"`)
}
c.Next()
}
}
+13 -19
View File
@@ -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")
+2 -2
View File
@@ -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 {
+3 -3
View File
@@ -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',
+2 -2
View File
@@ -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(
@@ -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<PasskeyLoginBegin> {
// 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" },
});
+3
View File
@@ -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 couldnt verify youre 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."