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>
18 KiB
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 1–2:
axiosimport + named-type imports. - Lines 8–16:
apiClientfactory —axios.create({ baseURL, withCredentials, headers, timeout: 30000 }). Pure config, no store deps. - Lines 27–37 (request interceptor): reads
sessionStorage.getItem('crewli_impersonation'), parses JSON, setsX-Impersonate-Userheader. This is not a boundary violation — it touchessessionStoragedirectly, 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/errorunderimport.meta.env.DEV— pure logging. - Lines 73, 75–80, 82, 85, 88–95, 97, 99–101: the rest of the response error mapping — same status-code branching, all toast-via-
notifyonce touchpoint 2 is callback-ified. - Line 108:
export { apiClient }— single named export. Critical: 30 importers acrossapps/app/src/consumeimport { apiClient } from '@/lib/axios'. The named export must keep working post-refactor. Phase C only adds a newregisterInterceptorsexport; 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 existing1.router/2.pinianumeric prefixes. Future readers reading top-to-bottom in a directory listing see the load sequence at a glance.- A single
.tsfile (not a directory +index.ts) — the wiring is ~30 lines of closure plumbing, no internal modules to split. - Name
axios-bindingsmatches 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.tssrc/components/form-failures/__tests__/FormFailuresTable.spec.tssrc/components/form-failures/__tests__/DismissFailureDialog.spec.tssrc/components/form-failures/__tests__/FormFailureDetail.spec.tssrc/components/form-failures/__tests__/RetryFailureDialog.spec.tssrc/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, registersregisterInterceptorswith mocked deps, and asserts onaxios-mock-adapter(ornock-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
apiClientnamed export still works": if any.get/.postchain 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 2–4
involve window.location.href mutations that are awkward in
Vitest+happy-dom.
A.4 — Consumer audit for @/lib/axios
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.vuecomponents/account-settings/AccountTab.vue(dynamic import)components/common/ImageUploadField.vuepages/login.vuepages/forgot-password.vuepages/reset-password.vuepages/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
Depstype (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
Depsshape is the contract oflib/axios.ts. It belongs to the module that consumes it. The bindings plugin imports it to know what to provide; that's a normalimport typeflow and doesn't break the boundaries matrix (plugins → libis 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, andtypes/is for shared domain/value-object types, not for module-internal interfaces. - The architectural test that matters is "does
lib/axios.tsimport 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 Q1–Q4.
Branch: refactor/axios-store-coupling.
Phase A commit message:
docs(refactor): audit axios↔store coupling for decoupling work.