7 Commits

Author SHA1 Message Date
550d864252 Merge pull request #3: refactor(apps/app): decouple lib/axios.ts from stores via callback seam 2026-05-04 22:44:03 +02:00
de07ccac8e chore(apps/app): drop unnecessary async on synchronous error handlers
Both interceptor error handlers in lib/axios.ts were declared
`async` but contain zero `await` calls — the request handler
just rethrows, and the response handler walks a synchronous
status-code branching tree before rethrowing. axios accepts both
sync and async handler signatures, so dropping the keyword is
mechanical and behavior-neutral.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 22:40:12 +02:00
853939e8b8 refactor(apps/app): decouple axios from impersonation sessionStorage contract
Chose Option A from the follow-up brief: useImpersonationStore
already holds an `ImpersonationState` ref hydrated from
sessionStorage at store-init and exposes the active impersonation
target user as a public `targetUserId` computed. The store is the
canonical source; sessionStorage is just its persistence sidecar.

Adds a fifth callback `getImpersonationTargetUserId: () => string
| null` to AxiosBindingsDeps and replaces the
sessionStorage.getItem('crewli_impersonation') + JSON.parse block
in the request interceptor with a single `deps.getImpersonationTargetUserId()`
call. The bindings plugin wires it to
`useImpersonationStore().targetUserId`.

After this commit lib/axios.ts has zero references to
sessionStorage and zero magic strings about impersonation
persistence — the only persistence-mechanism knowledge left is in
useImpersonationStore (where it belongs) and in
plugins/3.axios-bindings.ts (allowed to know about stores). The
HTTP module is now unambiguously pure infrastructure.

Behavior preserved 1:1: the store hydrates from sessionStorage
synchronously inside the defineStore factory, so the very first
HTTP request after page load sees the same target user id as
before.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 22:39:04 +02:00
4197df2b2f docs: close TECH-AXIOS-STORE-COUPLING and add TECH-AXIOS-INTERCEPTOR-TESTS
Removes the closed TECH-AXIOS-STORE-COUPLING entry from BACKLOG.md
(the structural decoupling landed in 53f6a7b + 26a92b3). The
git-history search `git log --grep=TECH-AXIOS-STORE-COUPLING`
remains the durable closure record, per the backlog hygiene
convention.

Adds a follow-up entry TECH-AXIOS-INTERCEPTOR-TESTS that captures
all four acceptance scenarios (X-Organisation-Id header
injection, 401 auth-fail flow, 403+impersonation_ended revocation
flow, 4xx/5xx error toast). Phase A audit found that none of
these is tested today; the refactor is gedragsneutraal so no
regression was introduced, but the gap is real and should not
silently outlive the refactor that made it visible. Priority
medium per Bert's Phase B sign-off.

Appends the debt-closed sentence to the Sessie 1c entry in
ARCH-CONSOLIDATION-2026-04.md, citing commit 53f6a7b.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 22:24:05 +02:00
26a92b3078 feat(apps/app): add plugins/3.axios-bindings.ts to wire stores into axios
Supplies the runtime closures that the registerInterceptors seam
needs. The plugin imports the four stores
(`useOrganisationStore`, `useNotificationStore`, `useAuthStore`,
`useImpersonationStore`) — allowed by the boundaries matrix
(`plugins → stores`) — and passes them as lazy callbacks so the
store factories only resolve when an HTTP call actually fires.

Numeric prefix `3.` runs after `2.pinia.ts` (auto-loaded by
`@core/utils/plugins.ts` in alphabetical-path order), so Pinia is
guaranteed active before the bindings register. No change to
`main.ts` is required — the file is picked up by the existing
`import.meta.glob('./plugins/*.{ts,js}')` glob.

Two redirects previously inside axios.ts now live where they
belong:
  - `window.location.href = '/platform'` on impersonation
    revocation, in the `onImpersonationRevoked` closure.
  - `handleUnauthorized()` (which itself redirects to `/login`)
    on 401, gated by `isInitialized` inside the `onAuthFail`
    closure — preserves the race-condition fix from sessie 1b-iii.

With this commit the two Vite mixed-import warnings
(useAuthStore + useImpersonationStore being both statically and
dynamically imported) disappear from `pnpm build`. Lint stays at
0 problems, typecheck clean, 49/49 tests pass.

Refs TECH-AXIOS-STORE-COUPLING.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 22:22:49 +02:00
53f6a7be73 refactor(apps/app): extract axios interceptors to registerInterceptors seam
Closes the lib → stores boundary violations that WS-3 sessie 1c
flagged. lib/axios.ts is now pure HTTP infrastructure: it exports
the configured `apiClient` plus a `registerInterceptors(client,
deps)` function that takes a typed `AxiosBindingsDeps` callback
bag (`getActiveOrgId`, `notify`, `onAuthFail`,
`onImpersonationRevoked`). All four `eslint-disable-next-line
boundaries/element-types` comments referencing
TECH-AXIOS-STORE-COUPLING are removed in the same change because
the imports they suppressed are gone — they would otherwise be
orphan disables.

Behavior is preserved 1:1: same status-code branching, same toast
messages, same DEV-only console logs, same sessionStorage-driven
X-Impersonate-User header (which never depended on a store and
stays in lib/axios.ts as before). The two redirects that used to
live in axios.ts (`/platform` on impersonation revocation,
`/login` on auth fail) move into the bindings-plugin closures so
the HTTP module stops knowing about routing.

The `apiClient` singleton is now exported without interceptors
attached — the bindings plugin
(`plugins/3.axios-bindings.ts`, follow-up commit) wires them up
during plugin-init, before `app.mount`.

Refs TECH-AXIOS-STORE-COUPLING.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 22:22:33 +02:00
5eac201d88 docs(refactor): audit axios↔store coupling for decoupling work
Phase A of TECH-AXIOS-STORE-COUPLING. Read-only inventory of the
four lib/axios.ts → stores/ touchpoints (lines 3, 5, 63, 75 carry
per-line boundary disables), plugin load-ordering analysis, test
coverage matrix, consumer audit (30 importers, all using the
`apiClient` named export), and Vite mixed-import warning
confirmation.

Surfaces four open questions for Phase B sign-off:
  Q1 callback-injection vs event-bus  → recommends callback-injection
  Q2 location of `Deps` type          → recommends inside lib/axios.ts
  Q3 test scope for this session      → recommends defer to backlog
  Q4 plugin filename                  → recommends 3.axios-bindings.ts

No code changed. No BACKLOG.md edit. Awaiting Bert's Phase B
sign-off before implementing Phase C.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-04 22:06:20 +02:00
5 changed files with 531 additions and 122 deletions

View File

@@ -1,9 +1,19 @@
import axios from 'axios'
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
// eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog.
import { useNotificationStore } from '@/stores/useNotificationStore'
// eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog.
import { useOrganisationStore } from '@/stores/useOrganisationStore'
/**
* 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
* (`plugins/3.axios-bindings.ts`) supplies the runtime closures.
*/
export interface AxiosBindingsDeps {
getActiveOrgId: () => string | null
getImpersonationTargetUserId: () => 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,
@@ -15,94 +25,73 @@ const apiClient: AxiosInstance = axios.create({
timeout: 30000,
})
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const orgStore = useOrganisationStore()
export function registerInterceptors(client: AxiosInstance, deps: AxiosBindingsDeps): void {
client.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const orgId = deps.getActiveOrgId()
if (orgId)
config.headers['X-Organisation-Id'] = orgId
if (orgStore.activeOrganisationId)
config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId
const impersonationTargetUserId = deps.getImpersonationTargetUserId()
if (impersonationTargetUserId)
config.headers['X-Impersonate-User'] = impersonationTargetUserId
// Add impersonation header when active
// Lazy import to avoid circular dependency with store
const impersonationData = sessionStorage.getItem('crewli_impersonation')
if (impersonationData) {
try {
const parsed = JSON.parse(impersonationData) as { targetUserId?: string }
if (parsed.targetUserId)
config.headers['X-Impersonate-User'] = parsed.targetUserId
if (import.meta.env.DEV)
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
return config
},
error => { throw error },
)
client.interceptors.response.use(
response => {
if (import.meta.env.DEV)
console.log(`${response.status} ${response.config.url}`, response.data)
return response
},
error => {
if (import.meta.env.DEV)
console.error(`${error.response?.status} ${error.config?.url}`, error.response?.data)
const status = error.response?.status
// Backend revoked the impersonation session mid-flight; the callback
// owns the local cleanup and the post-revoke redirect.
if (status === 403 && error.response?.data?.impersonation_ended) {
deps.onImpersonationRevoked()
throw error
}
catch {
// Invalid data — ignore
if (status === 401) {
deps.onAuthFail()
}
else if (status === 403) {
deps.notify('You don\'t have permission for this action.', 'error')
}
else if (status === 404) {
deps.notify('The requested item was not found.', 'warning')
}
else if (status === 422) {
const message = error.response?.data?.message
if (message && typeof message === 'string')
deps.notify(message, 'error')
}
else if (status === 503) {
deps.notify('Service temporarily unavailable. Please try again later.', 'error')
}
else if (status && status >= 500) {
deps.notify('An unexpected error occurred. Please try again later.', 'error')
}
else if (!error.response) {
deps.notify('Unable to connect to the server. Check your internet connection.', 'error')
}
}
if (import.meta.env.DEV)
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
return config
},
async error => { throw error },
)
apiClient.interceptors.response.use(
response => {
if (import.meta.env.DEV)
console.log(`${response.status} ${response.config.url}`, response.data)
return response
},
async error => {
if (import.meta.env.DEV)
console.error(`${error.response?.status} ${error.config?.url}`, error.response?.data)
const status = error.response?.status
const notificationStore = useNotificationStore()
// Handle impersonation session expiry
if (status === 403 && error.response?.data?.impersonation_ended) {
// eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog.
const { useImpersonationStore } = await import('@/stores/useImpersonationStore')
const impersonationStore = useImpersonationStore()
impersonationStore.clearState()
window.location.href = '/platform'
throw error
}
if (status === 401) {
// Lazy import to avoid circular dependency
// eslint-disable-next-line boundaries/element-types -- TECH-AXIOS-STORE-COUPLING: deliberate HTTP↔state seam, refactor scheduled per backlog.
const { useAuthStore } = await import('@/stores/useAuthStore')
const authStore = useAuthStore()
if (authStore.isInitialized)
authStore.handleUnauthorized()
}
else if (status === 403) {
notificationStore.show('You don\'t have permission for this action.', 'error')
}
else if (status === 404) {
notificationStore.show('The requested item was not found.', 'warning')
}
else if (status === 422) {
// Show validation message to user; still reject so component onError handlers can react
const message = error.response?.data?.message
if (message && typeof message === 'string')
notificationStore.show(message, 'error')
}
else if (status === 503) {
notificationStore.show('Service temporarily unavailable. Please try again later.', 'error')
}
else if (status && status >= 500) {
notificationStore.show('An unexpected error occurred. Please try again later.', 'error')
}
else if (!error.response) {
// Network error — no response received
notificationStore.show('Unable to connect to the server. Check your internet connection.', 'error')
}
throw error
},
)
},
)
}
export { apiClient }

View File

@@ -0,0 +1,27 @@
import type { App } from 'vue'
import { apiClient, registerInterceptors } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import { useNotificationStore } from '@/stores/useNotificationStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
// Numeric prefix `3.` runs after `2.pinia.ts`, so Pinia is active by
// the time these store factories resolve. Stores are looked up lazily
// 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),
onAuthFail: () => {
const authStore = useAuthStore()
if (authStore.isInitialized)
authStore.handleUnauthorized()
},
onImpersonationRevoked: () => {
useImpersonationStore().clearState()
window.location.href = '/platform'
},
})
}

View File

@@ -574,7 +574,11 @@ Zie §4 voor scope en stappen.
enforcement na PR-B), `TECH-WS3-BOUNDARIES-ROUTER-ZONE` (matrix-update
wanneer `plugins/1.router/` naar `router/` verhuist). WS-3 lint cleanup
+ boundaries enforcement effectief afgerond; volgende WS-3 stap is PR-B
(portal merge) zodra WS-6 sessie 2 in main is geland.
(portal merge) zodra WS-6 sessie 2 in main is geland. Debt closed in
commit `53f6a7b`: `lib/axios.ts` decoupled from stores via
`registerInterceptors(client, deps)` callback seam,
`plugins/3.axios-bindings.ts` provides the runtime wiring; the four
per-line disables are removed.
**Klaar-criteria:**
- `apps/portal/` is verwijderd

View File

@@ -602,43 +602,65 @@ sprint waard, geen meelift-pad.
---
### TECH-AXIOS-STORE-COUPLING — Decouple lib/axios.ts from stores layer
### TECH-AXIOS-INTERCEPTOR-TESTS — Coverage voor de vier axios-interceptor scenarios
**Aanleiding:** WS-3 sessie 1c (eslint-plugin-boundaries enforcement)
constateerde dat `apps/app/src/lib/axios.ts` 4 imports heeft uit `stores/`
(2 statisch op regel 3-4 voor `useNotificationStore` /
`useOrganisationStore`, 2 dynamisch op regel 61, 72 voor
`useImpersonationStore` / `useAuthStore` uit 1b-iii). De `lib → stores`
edge schendt de layered-architecture matrix. Om sessie 1c on-time te
landen zijn de 4 sites gemarkeerd met `eslint-disable-next-line` +
verwijzing naar dit backlog-item; de structurele fix is bewust uitgesteld
naar een dedicated sessie omdat het architectuurwerk is, geen tooling-
cleanup.
**Aanleiding:** TECH-AXIOS-STORE-COUPLING (gesloten 2026-05-04, zie
`git log --grep=TECH-AXIOS-STORE-COUPLING`) heeft `lib/axios.ts`
ontkoppeld van de stores via een `registerInterceptors(client, deps)`
seam plus `plugins/3.axios-bindings.ts`. Tijdens de Phase A audit van
die sessie bleek dat de vier acceptatie-scenarios geen van alle een
test hebben — niet vóór en niet ná de refactor. De refactor is
gedragsneutraal (1:1 behoud), dus er is geen regressie geïntroduceerd,
maar het blijft een echte coverage-gap die we niet wilden meeniggen
in de refactor-sessie zelf: refactor-en-test-toevoeging in dezelfde
commit-set vernietigt het vermogen om vast te stellen of de tests pre-
of post-refactor gedrag specificeren.
**Wat:**
- Decouple `lib/axios.ts` van stores zodat het puur HTTP-infrastructuur
wordt. Twee paden, kies bij refactor:
- **Approach 1 (preferred starting point):** `lib/axios.ts` exporteert
de axios-instance plus een `registerInterceptors(client, deps)`
functie die callbacks accepteert (`onAuthFail`,
`onImpersonationDrop`, `getActiveOrgId`, `notify(message, level)`).
Een nieuwe `plugins/axios-bindings.ts` (mag `stores` importeren per
matrix) roept `registerInterceptors` aan bij app-init met closures
over de stores.
- **Approach 2 (fallback):** event-bus / callback registry; axios.ts
emit-eert semantische events (`auth-failed`, `needs-org-header`,
`notify-error`) en `plugins/axios-bindings.ts` subscribet.
- Verwijder alle 4 `eslint-disable-next-line` comments uit
`lib/axios.ts`.
- Tests moeten dekken: X-Organisation-Id header injection, 401/403
logout flow, impersonation revocation flow, error toast op 4xx/5xx.
- Optioneel: meteen ook de static/dynamic import-split in axios.ts
uniformeren (nu inconsistent om legacy 1b-iii-redenen).
**Wat:** Vitest-tests die de interceptors echt laden (niet via
`vi.mock('@/lib/axios')`) en assertions doen tegen een gemockte
HTTP-laag (bv. `axios-mock-adapter`). De vier scenarios:
**Prioriteit:** Middel — geen blokker voor andere workstreams, maar elke
maand dat dit blijft staan is een vlek op de boundaries-enforcement
geloofwaardigheid. Aanbevolen: meelift met de eerste WS-3 PR die `lib/`
of `plugins/` raakt, of een dedicated 2-3 uur sessie na WS-6 sluiting.
1. **`X-Organisation-Id` header-injection.** Set een actieve
organisatie via `useOrganisationStore`, registreer interceptors
met de bindings-deps, fire een outbound request, assert dat de
header de actieve ULID bevat. Test ook het null-pad: geen
actieve organisatie → header niet gezet.
2. **401 → auth-fail flow.** Mock de response op 401, registreer
interceptors waarbij `onAuthFail` een spy is, assert dat de
spy wordt aangeroepen exact wanneer `useAuthStore.isInitialized`
true is en niet gedurende de eerste `/auth/me`-probe (de
race-conditie die sessie 1b-iii repareerde — die guard moet
blijven werken).
3. **403 + `impersonation_ended` → revocation flow.** Mock de
response op `403` met body `{ impersonation_ended: true }`,
registreer interceptors waarbij `onImpersonationRevoked` een
spy is, assert dat de spy precies één keer wordt aangeroepen
en dat de generieke 403-toast NIET wordt geactiveerd (dat was
een early-return in `axios.ts`, makkelijk per ongeluk te
breken bij een toekomstige refactor).
4. **4xx/5xx error toast.** Mock 403/404/422/503/5xx/network-error
responses, assert dat `notify` de juiste boodschap + level
krijgt voor elk geval. 422 met body-`message` moet het
server-bericht doorgeven; 422 zonder `message` mag geen toast
triggeren (huidige gedrag).
**Hoe niet:** geen unit-tests die het hele `lib/axios.ts`-module
mocken — die testen de seam niet, alleen het mock-framework. De
toegevoegde waarde zit in een test-fixture die de echte
`registerInterceptors` aanroept met een echte axios-instance die
tegen `axios-mock-adapter` praat.
**Niet in scope:** integratietests die echt door Pinia heen lopen.
De seam is bewust callback-injectie zodat tests met spy-callbacks
volstaan. Wie de full-stack flow wil dekken (zie `App.vue`'s
session-init dance) doet dat met E2E in een latere sprint.
**Prioriteit:** Middel — de gap is reëel maar niet blokkerend. De
6 bestaande `vi.mock('@/lib/axios')`-tests vangen "named export
werkt nog" af, en de vier flows zijn manueel via de browser
verifieerbaar. Aanbevolen moment: eerste WS-3 PR die `axios.ts`
of de bindings-plugin opnieuw raakt, of opvolger van TECH-APP-VITEST
als bredere harness-uitbreiding.
---

View File

@@ -0,0 +1,367 @@
# TECH-AXIOS-STORE-COUPLING — Phase A audit
**Date:** 2026-05-04
**Branch:** `refactor/axios-store-coupling`
**Scope:** read-only audit. No code changed in this phase.
**Goal:** make `apps/app/src/lib/axios.ts` a pure HTTP module by routing
its four `stores/` references through a callback seam wired up from a
new `plugins/` plugin. Phase A inventories what exists today; Phase B
asks Bert to resolve four open questions; Phase C implements.
---
## A.1 — Touchpoint inventory in `apps/app/src/lib/axios.ts`
The file is 109 lines on `main` at `831f36e`. The four boundary-rule
disables are at lines **3, 5, 63, 75** (single-line drift versus the
BACKLOG note of "regels 3-4, 61, 72" — caused by minor formatting
changes since 1c, semantically identical).
### Touchpoint 1 — `useOrganisationStore` (static)
| Field | Value |
| --- | --- |
| Disable line | 3 |
| Import line | 4 (`import { useOrganisationStore } from '@/stores/useOrganisationStore'`) |
| Invocation line | 20 (`const orgStore = useOrganisationStore()`) |
| Interceptor | request |
| Access | read state — `orgStore.activeOrganisationId` (string \| null) |
| Semantic purpose | inject the active organisation ULID into the `X-Organisation-Id` request header so the backend can scope the query to the active tenant |
| Shape needed in `Deps` | `getActiveOrgId: () => string \| null` |
### Touchpoint 2 — `useNotificationStore` (static)
| Field | Value |
| --- | --- |
| Disable line | 5 |
| Import line | 6 (`import { useNotificationStore } from '@/stores/useNotificationStore'`) |
| Invocation line | 59 (`const notificationStore = useNotificationStore()`) |
| Interceptor | response (error path) |
| Access | calls `.show(message, level)` action |
| Semantic purpose | toast a message via `v-snackbar` for 403 / 404 / 422 / 503 / 5xx / network errors |
| Shape needed in `Deps` | `notify: (message: string, level: 'error' \| 'warning') => void` (the only two levels used in the current code) |
### Touchpoint 3 — `useImpersonationStore` (dynamic)
| Field | Value |
| --- | --- |
| Disable line | 63 |
| Import line | 64 (`const { useImpersonationStore } = await import('@/stores/useImpersonationStore')`) |
| Invocation line | 65 (`const impersonationStore = useImpersonationStore()`) |
| Interceptor | response (error path), under `status === 403 && error.response?.data?.impersonation_ended` |
| Access | calls `.clearState()` then `window.location.href = '/platform'` |
| Semantic purpose | when the backend revokes an impersonation session mid-flight (403 with `impersonation_ended`), drop the local impersonation state and full-page-redirect back to `/platform` so the original admin session restores |
| Shape needed in `Deps` | `onImpersonationRevoked: () => void` (the redirect is the side-effect of clearing state; whether the callback owns the redirect or just the clear is a Phase B Q2-adjacent question — see notes) |
### Touchpoint 4 — `useAuthStore` (dynamic)
| Field | Value |
| --- | --- |
| Disable line | 75 |
| Import line | 76 (`const { useAuthStore } = await import('@/stores/useAuthStore')`) |
| Invocation line | 77 (`const authStore = useAuthStore()`) |
| Interceptor | response (error path), under `status === 401` |
| Access | reads `.isInitialized`; if true, calls `.handleUnauthorized()` |
| Semantic purpose | on 401, if the auth store has finished its initial `/auth/me` probe, clear local auth state and redirect to `/login`. The `isInitialized` guard exists because the initial `/auth/me` call itself returns 401 for unauthenticated visitors and that is *not* a logout-trigger |
| Shape needed in `Deps` | `onAuthFail: () => void` — the callback closure should encapsulate both the `isInitialized` check and the `handleUnauthorized()` call (see Phase B Q2). Alternatively, two callbacks (`isAuthInitialized: () => boolean` + `onAuthFail: () => void`) but that's a leakier seam. |
### Non-store behavior in `axios.ts` (preserve, do not refactor)
- **Lines 12:** `axios` import + named-type imports.
- **Lines 816:** `apiClient` factory — `axios.create({ baseURL, withCredentials, headers, timeout: 30000 })`. Pure config, no store deps.
- **Lines 2737 (request interceptor):** reads `sessionStorage.getItem('crewli_impersonation')`, parses JSON, sets `X-Impersonate-User` header. **This is *not* a boundary violation** — it touches `sessionStorage` directly, not a store. It is conceptually parallel to touchpoint 1 but already store-free, so it stays as-is in Phase C unless Bert prefers a parallel callback (`getImpersonationTargetId: () => string | null`) for symmetry. Recommendation: leave alone in Phase C — the seam should only encapsulate the store-coupled paths. Refactoring this is scope creep.
- **Line 40, 50, 56:** `console.log/error` under `import.meta.env.DEV` — pure logging.
- **Lines 73, 7580, 82, 85, 8895, 97, 99101:** the rest of the response error mapping — same status-code branching, all toast-via-`notify` once touchpoint 2 is callback-ified.
- **Line 108:** `export { apiClient }` — single named export. **Critical:** 30 importers across `apps/app/src/` consume `import { apiClient } from '@/lib/axios'`. The named export must keep working post-refactor. Phase C only adds a *new* `registerInterceptors` export; nothing is renamed or removed.
### Singleton vs. factory
`apiClient` is a top-level singleton (created at module-eval time). The
two interceptor registrations execute as a side-effect of the first
import. Phase C must split this: the singleton stays, but interceptor
registration moves to an explicit `registerInterceptors(client, deps)`
call invoked from the new bindings plugin. Until that call runs, the
client is interceptor-less — fine, because the bindings plugin runs in
the plugin-init phase before any composable or component issues an
HTTP call (Pinia + the bindings plugin both initialize during
`registerPlugins(app)` in `main.ts`, before `app.mount`).
---
## A.2 — Plugins ordering and proposed filename
### How plugins load
`main.ts` calls `registerPlugins(app)` from
`@core/utils/plugins.ts`. The helper is a Vuexy convention that uses
`import.meta.glob(['../../plugins/*.{ts,js}', '../../plugins/*/index.{ts,js}'], { eager: true })`,
collects the keys, **alphabetically sorts the path strings**, and calls
each module's `default(app)` in that order. There is no manual
registration in `main.ts` — drop a file in `src/plugins/` and it gets
picked up.
### Current alphabetical load order
| Sort key (path string) | File | Role | Pinia ready after this? |
| --- | --- | --- | --- |
| `../../plugins/1.router/index.ts` | `1.router/index.ts` | `app.use(router)` + guards | no |
| `../../plugins/2.pinia.ts` | `2.pinia.ts` | `createPinia()` + `app.use(store)` | **yes (this is where it happens)** |
| `../../plugins/iconify/index.ts` | `iconify/index.ts` | iconify registration | yes |
| `../../plugins/layouts.ts` | `layouts.ts` | `createLayouts()` | yes |
| `../../plugins/vuetify/index.ts` | `vuetify/index.ts` | Vuetify registration | yes |
| `../../plugins/webfontloader.ts` | `webfontloader.ts` | webfont preload | yes |
Ordering invariant: anything that needs Pinia stores must sort
**after** `2.pinia.ts`. The numeric prefix is the established convention
for explicit ordering (router=1, pinia=2). Anything without a numeric
prefix sorts after the numeric ones because digits sort before letters
in standard lexicographic comparison.
### Proposed filename
**`apps/app/src/plugins/3.axios-bindings.ts`**
Rationale:
- `3.` makes the post-Pinia ordering explicit and human-readable
alongside the existing `1.router` / `2.pinia` numeric prefixes.
Future readers reading top-to-bottom in a directory listing see
the load sequence at a glance.
- A single `.ts` file (not a directory + `index.ts`) — the wiring is
~30 lines of closure plumbing, no internal modules to split.
- Name `axios-bindings` matches the BACKLOG entry's wording. "Bindings"
reads as "bind axios interceptors to store callbacks" — the file's
job is exactly that wiring.
If Bert prefers a non-numeric name (e.g. just `axios-bindings.ts`,
sorting after the numeric files), that works too — Pinia is still
already up. Numeric-prefix is recommended for parity with the existing
router/pinia ordering convention. **Open as Phase B Q4.**
---
## A.3 — Test coverage matrix for the four acceptance scenarios
`apps/app` runs Vitest against 49 tests across 10 files (sanity smoke
in `tests/`, plus `__tests__/` colocated under `layouts/`,
`composables/api/`, and `components/form-failures/`).
| Acceptance scenario | Existing test? | Notes |
| --- | --- | --- |
| 1. `X-Organisation-Id` header injection | **No** | No test exercises the request interceptor. |
| 2. 401 → `handleUnauthorized()` flow | **No** | `useAuthStore` has no colocated tests; no interceptor test. |
| 3. 403 + `impersonation_ended` revocation | **No** | `useImpersonationStore` has no colocated tests; no interceptor test. |
| 4. 4xx/5xx error toast via `useNotificationStore` | **No** | `useNotificationStore` has no colocated tests; no interceptor test. |
### How `@/lib/axios` is used in existing tests
All 6 existing axios-touching test files use `vi.mock('@/lib/axios', () => ({ apiClient: { ... } }))` to swap the entire module out. None of them load real interceptors. List:
- `src/composables/api/__tests__/useFormFailures.spec.ts`
- `src/components/form-failures/__tests__/FormFailuresTable.spec.ts`
- `src/components/form-failures/__tests__/DismissFailureDialog.spec.ts`
- `src/components/form-failures/__tests__/FormFailureDetail.spec.ts`
- `src/components/form-failures/__tests__/RetryFailureDialog.spec.ts`
- `src/components/form-failures/__tests__/ResolveFailureDialog.spec.ts`
These tests will keep passing post-refactor regardless of how
`registerInterceptors` looks — they replace the module wholesale.
### Stores tested directly?
No — `apps/app/src/stores/__tests__/` does not exist. None of the four
stores referenced from `axios.ts` has a unit test. The Vitest harness
(`tests/sanity.spec.ts`) was just landed in WS-6 sessie 3b
(TECH-APP-VITEST closure); the colocated `__tests__/` folders only
cover layouts, form-failures components, and `useFormFailures`.
### Recommendation for Phase B Q3
Default to **option C from the brief: add as follow-up backlog entry
`TECH-AXIOS-INTERCEPTOR-TESTS`**, do not add tests in this session.
Reasoning:
- All 4 acceptance scenarios are untested today, so the refactor is
not regressing existing coverage.
- Building an interceptor-test fixture that loads the real
`apiClient`, registers `registerInterceptors` with mocked deps,
and asserts on `axios-mock-adapter` (or `nock`-style) intercepted
requests is genuinely >2h of infrastructure work in a project
that has only had Vitest for a few sessies. The brief's default
scope rule applies.
- The 6 module-mocked tests give us a regression net for "the
`apiClient` named export still works": if any `.get`/`.post` chain
breaks, those component tests fall over. Combined with manual
smoke-testing the four flows in dev, that's adequate for this
session.
If Bert overrules this and wants tests in-session, the smallest
viable path is option B targeted on **just touchpoint 1** (`X-Organisation-Id`
header injection) — it's the cheapest to test (request-time, no
mocked window navigation) and the most boring to verify by hand,
making it the highest-value test-per-effort. Touchpoints 24
involve `window.location.href` mutations that are awkward in
Vitest+happy-dom.
---
## A.4 — Consumer audit for `@/lib/axios`
```bash
grep -rln "from '@/lib/axios'" apps/app/src/ | wc -l # 30
grep -rln "vi.mock('@/lib/axios'" apps/app/src/ | wc -l # 6
grep -rln "import('@/lib/axios')" apps/app/src/ | wc -l # 1 (AccountTab.vue, dynamic)
```
### Group 1 — composables/api (22 files)
All 22 files in `src/composables/api/use*.ts` import `{ apiClient }` and
use `.get` / `.post` / `.patch` / `.put` / `.delete`. Vanilla axios usage.
### Group 2 — stores (2 files)
`src/stores/useAuthStore.ts` and `src/stores/useImpersonationStore.ts`
both import `{ apiClient }`. (Other stores don't talk to axios.)
**Important:** these are the two stores that are ALSO referenced from
`axios.ts` via dynamic import — that's the cycle the Vite warnings
flag. Removing those dynamic imports from `axios.ts` resolves the
cycle automatically.
### Group 3 — components / pages (6 files)
- `components/platform/ImpersonateDialog.vue`
- `components/account-settings/AccountTab.vue` (dynamic import)
- `components/common/ImageUploadField.vue`
- `pages/login.vue`
- `pages/forgot-password.vue`
- `pages/reset-password.vue`
- `pages/verify-email-change.vue`
These violate the project rule "never import axios directly in a component (use a composable)" — that's a separate cleanup
not in this session's scope. Listing for completeness.
### Group 4 — tests that mock the module (6 files)
Listed in A.3. They use `vi.mock` and replace `apiClient` with a stub;
they don't care about the interceptor wiring.
### Breaking-change surface
**Zero.** Every consumer reads `{ apiClient }` from
`@/lib/axios`. Phase C only:
- Adds a new export `registerInterceptors`.
- Adds a new export `Deps` type (or hosts it in `@/types/` per Phase B Q2).
Existing imports keep working unchanged. No fanout.
---
## A.5 — Vite mixed-import warning confirmation
Ran `pnpm build` from `apps/app/` on `main`@`831f36e`. Both warnings
reproduce as the BACKLOG describes. The relevant excerpt:
```
[plugin vite:reporter]
(!) /…/apps/app/src/stores/useAuthStore.ts is dynamically imported by
/…/apps/app/src/lib/axios.ts but also statically imported by
/…/apps/app/src/App.vue, /…/AccountTab.vue, /…/SecurityTab.vue,
/…/EventMetricCards.vue, /…/EventTabsNav.vue,
/…/OrganisationSwitcher.vue, … (≈30 callers total),
dynamic import will not move module into another chunk.
[plugin vite:reporter]
(!) /…/apps/app/src/stores/useImpersonationStore.ts is dynamically
imported by /…/apps/app/src/lib/axios.ts but also statically
imported by /…/apps/app/src/App.vue, /…/ImpersonateDialog.vue,
/…/ImpersonationBanner.vue, /…/DefaultLayoutWithVerticalNav.vue,
dynamic import will not move module into another chunk.
```
Build still succeeds (10.96s, 49 tests pass). The warnings are
diagnostic only, but they are an explicit acceptance criterion for
Phase C: removing the two `await import('@/stores/...')` calls in
`axios.ts` should drop both warnings to zero. If either warning
persists post-refactor, it means another file in the codebase is
also doing `await import('@/stores/useAuthStore')` or
`'.../useImpersonationStore'`. Quick `grep -rn "await import.*useAuthStore\|await import.*useImpersonationStore" apps/app/src/`
during Phase C will confirm; expectation is that `axios.ts` is the
only offender.
---
## A.6 — Open questions for Bert (Phase B sign-off)
### Q1. Approach 1 (callback-injection) or Approach 2 (event-bus)?
**Recommendation: Approach 1 (callback-injection).**
The four touchpoints map cleanly to four callbacks (or three +
one read), all single-subscriber, all synchronous in their
domain (the heavy work is the callee's, not the seam's). No
case where multiple subscribers would want to react to the same
event. No case where the subscriber needs to register/unregister
dynamically at runtime. An event bus would add a second
indirection, a stringly-typed event name surface, and break
TypeScript's ability to enforce that every event has exactly the
deps it needs.
Approach 2 would only beat Approach 1 if we wanted the bindings
plugin to subscribe to events emitted from places other than
`axios.ts`. We don't.
### Q2. Where should the typed `Deps` interface live?
**Recommendation: inside `lib/axios.ts`, exported.**
Rationale:
- The `Deps` shape is the *contract of `lib/axios.ts`*. It belongs
to the module that consumes it. The bindings plugin imports it
to know what to provide; that's a normal `import type` flow and
doesn't break the boundaries matrix (`plugins → lib` is allowed).
- Hoisting it to `src/types/` would be more "architecturally pure"
but adds a hop with no payoff: nothing else in the codebase
consumes this contract, and `types/` is for shared
domain/value-object types, not for module-internal interfaces.
- The architectural test that matters is "does `lib/axios.ts`
import from `@/stores/...`?" — that's what the boundaries rule
enforces. Where the type lives doesn't affect that.
If Bert prefers the `types/`-hoist for consistency, no objection;
the diff cost is one extra file. State preference in Phase B.
### Q3. Test scope for this session.
**Recommendation: option C — add follow-up backlog entry
`TECH-AXIOS-INTERCEPTOR-TESTS`, no new tests in this session.**
Reasoning in A.3. All four scenarios are untested today; the refactor
preserves behavior 1:1; the 6 existing module-mock tests are an
adequate "named-export still works" net; building real
interceptor-fixture infrastructure is >2h.
If Bert prefers option B (add tests for touchpoint 1 only — the
`X-Organisation-Id` header injection — as the cheapest meaningful
test), that's a reasonable middle ground; estimate ~1h.
### Q4. Confirm plugin filename and ordering.
**Recommendation: `apps/app/src/plugins/3.axios-bindings.ts`.**
Numeric prefix `3.` for parity with `1.router/` and `2.pinia.ts`,
making the post-Pinia load order explicit. Alternative: drop the
prefix (`axios-bindings.ts`) — it would still sort after the
numeric files because digits precede letters lexicographically.
Numeric is preferred for readability.
---
## A.7 — Deliverable
This file is `dev-docs/TECH-AXIOS-STORE-COUPLING-AUDIT.md`. Phase A
ends here. **No code edits, no `BACKLOG.md` change, no plugin file,
no `axios.ts` change yet.** Awaiting Phase B sign-off on Q1Q4.
Branch: `refactor/axios-store-coupling`.
Phase A commit message:
`docs(refactor): audit axios↔store coupling for decoupling work`.