refactor(axios): split lib/axios.ts into factory + default + portal-token instances

The single axios.ts file becomes a directory with:
- factory.ts — createApiClient + the registerDefaultInterceptors /
  registerPortalTokenInterceptors seam (preserves the
  TECH-AXIOS-STORE-COUPLING decoupling — no store imports inside)
- default.ts — cookie-authenticated client (organizer + cookie-auth
  portal flows; existing 45 call sites resolve unchanged)
- portal-token.ts — Bearer-auth client for the artist-advance /
  supplier-intake flows (forward-compatible groundwork; no active
  consumers today)
- index.ts — re-exports apiClient + portalApiClient + the register* /
  createApiClient surface; the existing `import { apiClient } from
  '@/lib/axios'` continues to work directory-resolved.

The bindings plugin (plugins/3.axios-bindings.ts) now wires both
clients with a shared deps base + flavour-specific overrides. The
`getPortalToken` callback returns null until Phase E surfaces
`portalToken` on useAuthStore — no current consumers exercise the
Bearer path, so the null-return is intentional placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 21:18:55 +02:00
parent a2760ffd64
commit 13d7b18257
5 changed files with 123 additions and 23 deletions

View File

@@ -0,0 +1,10 @@
import { createApiClient } from './factory'
/**
* Default API client — cookie-authenticated, sends X-Organisation-Id
* and X-Impersonate-User headers via the bindings plugin. Used by
* organizer + cookie-authenticated portal flows.
*/
const apiClient = createApiClient({ withCredentials: true })
export default apiClient

View File

@@ -3,36 +3,58 @@ import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
/**
* Seam contract between the HTTP client and the rest of the app.
* `lib/axios.ts` knows nothing about Pinia, stores, or routing it
* only invokes these callbacks. The bindings plugin
* Factory + register* functions know nothing about Pinia, stores, or
* routing they only invoke these callbacks. The bindings plugin
* (`plugins/3.axios-bindings.ts`) supplies the runtime closures.
*
* Originally introduced as `TECH-AXIOS-STORE-COUPLING` (closed in
* 53f6a7b). Preserving the seam during the WS-3 PR-B2a factory split
* was a deliberate decision (Bert, 2026-05-05).
*/
export interface AxiosBindingsDeps {
getActiveOrgId: () => string | null
getImpersonationTargetUserId: () => string | null
getActiveOrgId?: () => string | null
getImpersonationTargetUserId?: () => string | null
getPortalToken?: () => string | null
notify: (message: string, level: 'error' | 'warning') => void
onAuthFail: () => void
onImpersonationRevoked: () => void
}
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
timeout: 30000,
})
export interface CreateApiClientOptions {
withCredentials?: boolean
baseURL?: string
}
export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
export function createApiClient(options: CreateApiClientOptions = {}): AxiosInstance {
const {
withCredentials = true,
baseURL = import.meta.env.VITE_API_URL,
} = options
return axios.create({
baseURL,
withCredentials,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
timeout: 30000,
})
}
/**
* Cookie-authenticated client interceptors. Attaches X-Organisation-Id
* + X-Impersonate-User on every request and routes 401/403/4xx/5xx
* through the deps callbacks.
*/
export function registerDefaultInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
client.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const orgId = deps.getActiveOrgId()
const orgId = deps.getActiveOrgId?.()
if (orgId)
config.headers['X-Organisation-Id'] = orgId
const impersonationTargetUserId = deps.getImpersonationTargetUserId()
const impersonationTargetUserId = deps.getImpersonationTargetUserId?.()
if (impersonationTargetUserId)
config.headers['X-Impersonate-User'] = impersonationTargetUserId
@@ -44,6 +66,34 @@ export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsD
error => { throw error },
)
registerSharedResponseHandler(client, deps)
}
/**
* Portal-token (Bearer) client interceptors. Attaches
* Authorization: Bearer <token> when the deps callback returns a
* non-null token. Does NOT attach org/impersonation headers the
* Bearer flow has no organisation context.
*/
export function registerPortalTokenInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
client.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = deps.getPortalToken?.()
if (token)
config.headers.Authorization = `Bearer ${token}`
if (import.meta.env.DEV)
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
return config
},
error => { throw error },
)
registerSharedResponseHandler(client, deps)
}
function registerSharedResponseHandler(client: AxiosInstance, deps: AxiosBindingsDeps): void {
client.interceptors.response.use(
response => {
if (import.meta.env.DEV)
@@ -93,5 +143,3 @@ export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsD
},
)
}
export { apiClient }

View File

@@ -0,0 +1,8 @@
export { default as apiClient } from './default'
export { default as portalApiClient } from './portal-token'
export {
createApiClient,
registerDefaultInterceptors,
registerPortalTokenInterceptors,
} from './factory'
export type { AxiosBindingsDeps, CreateApiClientOptions } from './factory'

View File

@@ -0,0 +1,14 @@
import { createApiClient } from './factory'
/**
* Portal-token API client — Bearer auth, no cookies. Used by
* unauthenticated artist/supplier flows where the token comes from a
* URL parameter (artist-advance, supplier-intake — wired post-WS-3).
*
* The Bearer token is read at request time from
* `useAuthStore.portalToken` via the bindings plugin's
* `getPortalToken` callback.
*/
const portalApiClient = createApiClient({ withCredentials: false })
export default portalApiClient

View File

@@ -1,5 +1,10 @@
import type { App } from 'vue'
import { apiClient, registerInterceptors } from '@/lib/axios'
import {
apiClient,
portalApiClient,
registerDefaultInterceptors,
registerPortalTokenInterceptors,
} from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import { useNotificationStore } from '@/stores/useNotificationStore'
@@ -10,10 +15,9 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
// inside each callback (not eagerly at plugin-init), which keeps the
// seam tolerant of any future plugin-ordering changes.
export default function (_: App): void {
registerInterceptors(apiClient, {
getActiveOrgId: () => useOrganisationStore().activeOrganisationId,
getImpersonationTargetUserId: () => useImpersonationStore().targetUserId,
notify: (message, level) => useNotificationStore().show(message, level),
const sharedDeps = {
notify: (message: string, level: 'error' | 'warning') =>
useNotificationStore().show(message, level),
onAuthFail: () => {
const authStore = useAuthStore()
if (authStore.isInitialized)
@@ -23,5 +27,21 @@ export default function (_: App): void {
useImpersonationStore().clearState()
window.location.href = '/platform'
},
}
registerDefaultInterceptors(apiClient, {
...sharedDeps,
getActiveOrgId: () => useOrganisationStore().activeOrganisationId,
getImpersonationTargetUserId: () => useImpersonationStore().targetUserId,
})
registerPortalTokenInterceptors(portalApiClient, {
...sharedDeps,
getPortalToken: () => {
const authStore = useAuthStore() as unknown as { portalToken?: string | null }
return authStore.portalToken ?? null
},
})
}