mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-11 16:08:09 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user