refactor(apps/app): decouple lib/axios.ts from stores via callback seam #3

Closed
bert.hausmans wants to merge 0 commits from refactor/axios-store-coupling into main

Decouples apps/app/src/lib/axios.ts from the stores/ layer through a
typed callback seam (registerInterceptors(client, deps)), backed by a
new bindings plugin (plugins/3.axios-bindings.ts). Pays down debt
deferred during WS-3 sessie 1c.

Background

WS-3 sessie 1c introduced eslint-plugin-boundaries with a strict
layered-architecture matrix where lib → stores is disallowed. At the
time, four lib/axios.ts → stores/ imports (2 static, 2 dynamic) were
marked with per-line eslint-disable-next-line comments referencing
TECH-AXIOS-STORE-COUPLING. This PR removes those four exceptions
structurally — not by suppression.

Architecture

lib/axios.ts is now pure HTTP infrastructure:

  • Exports apiClient (axios instance, no interceptors attached by default)
  • Exports registerInterceptors(client, deps) — the seam function
  • Exports AxiosBindingsDeps interface with five typed callbacks
  • Knows nothing about Pinia, stores, routing, or persistence mechanisms

plugins/3.axios-bindings.ts provides the runtime wiring:

  • Numeric prefix 3. runs after 2.pinia.ts so Pinia is active
  • Stores resolved lazily inside each callback (Pinia-tolerant)
  • Auto-loaded by the existing @core/utils/plugins.ts glob — no main.ts change

Commits

  1. 5eac201docs(refactor): audit axios↔store coupling for decoupling work
    Phase A read-only audit → dev-docs/TECH-AXIOS-STORE-COUPLING-AUDIT.md.
  2. 53f6a7brefactor(apps/app): extract axios interceptors to registerInterceptors seam
    lib/axios.ts rewrite; the four eslint-disable markers removed structurally.
  3. 26a92b3feat(apps/app): add plugins/3.axios-bindings.ts to wire stores into axios
    New plugin; suplies the four runtime closures.
  4. 4197df2docs: close TECH-AXIOS-STORE-COUPLING and add TECH-AXIOS-INTERCEPTOR-TESTS
    BACKLOG.md + ARCH-CONSOLIDATION-2026-04.md updated; new follow-up entry
    for the four scenarios (X-Org-Id header, 401/403 logout, impersonation
    revocation, error toasts) that remain untested today.
  5. 853939erefactor(apps/app): decouple axios from impersonation sessionStorage contract
    Fifth callback getImpersonationTargetUserId added to AxiosBindingsDeps;
    lib/axios.ts no longer reads sessionStorage directly nor knows the
    'crewli_impersonation' key or { targetUserId } shape. The store is
    the canonical source (verified: useImpersonationStore.targetUserId is
    a public computed hydrated from sessionStorage at store-init).
  6. de07ccachore(apps/app): drop unnecessary async on synchronous error handlers
    Two async error => { throw error }error => { throw error }. Mechanical.

Architectural decisions

Phase B sign-off (Bert):

  • Q1 = Approach 1 (callback-injection, not event-bus) — typed, four touchpoints.
  • Q2 = AxiosBindingsDeps lives in lib/axios.ts itself, exported.
  • Q3 = Test scope deferred via new TECH-AXIOS-INTERCEPTOR-TESTS backlog entry.
  • Q4 = 3.axios-bindings.ts flat .ts file (matches 2.pinia.ts convention).

Post-Phase-C improvements (during PR review):

  • Improvement 1: sessionStorage decoupling. The original refactor preserved
    legacy direct sessionStorage reads inside the request interceptor.
    Audit showed useImpersonationStore.targetUserId is an existing public
    computed; Option A (delegate via callback) was the clean path.
  • Improvement 2: removed unused async keywords from synchronous error
    handlers. Behavior-neutral.

Acceptance

  • pnpm lint 0 problems in apps/app/
    (pre-existing boundaries-v6 deprecation warnings tracked separately
    as TECH-BOUNDARIES-V6-SELECTOR-MIGRATION)
  • pnpm typecheck clean
  • pnpm test 49/49 passed (no regression)
  • pnpm build succeeds
  • 0 Vite mixed-import warnings (was 2 — useAuthStore and
    useImpersonationStore no longer dynamically imported by lib/axios.ts)
  • All four eslint-disable-next-line boundaries/element-types markers
    in lib/axios.ts are gone (structurally, not suppressed)
  • lib/axios.ts has zero references to @/stores/* or sessionStorage
  • Conventional Commits + Co-Authored-By: Claude on all commits
  • No file in apps/portal/, api/, packages/, docs/ modified

Follow-up backlog item

TECH-AXIOS-INTERCEPTOR-TESTS (medium prio) — added to BACKLOG.md.
Covers the four acceptance scenarios that remain untested:

  • X-Organisation-Id header injection on outbound requests
  • 401/403 logout flow (auth store integration)
  • Impersonation revocation flow on 403+impersonation_ended
  • Error toast on 4xx/5xx (notification store integration)

Deferred deliberately: mixing refactor and test-creation in the same
session destroys the ability to verify whether tests spec pre- or
post-refactor behavior. Tests will be added in a focused session
against the new architecture from zero.

Merge

--no-ff (web UI: kies "Create merge commit") per CLAUDE.md commit hygiene.

Decouples `apps/app/src/lib/axios.ts` from the `stores/` layer through a typed callback seam (`registerInterceptors(client, deps)`), backed by a new bindings plugin (`plugins/3.axios-bindings.ts`). Pays down debt deferred during WS-3 sessie 1c. ## Background WS-3 sessie 1c introduced `eslint-plugin-boundaries` with a strict layered-architecture matrix where `lib → stores` is disallowed. At the time, four `lib/axios.ts → stores/` imports (2 static, 2 dynamic) were marked with per-line `eslint-disable-next-line` comments referencing `TECH-AXIOS-STORE-COUPLING`. This PR removes those four exceptions structurally — not by suppression. ## Architecture `lib/axios.ts` is now pure HTTP infrastructure: - Exports `apiClient` (axios instance, no interceptors attached by default) - Exports `registerInterceptors(client, deps)` — the seam function - Exports `AxiosBindingsDeps` interface with five typed callbacks - Knows nothing about Pinia, stores, routing, or persistence mechanisms `plugins/3.axios-bindings.ts` provides the runtime wiring: - Numeric prefix `3.` runs after `2.pinia.ts` so Pinia is active - Stores resolved lazily inside each callback (Pinia-tolerant) - Auto-loaded by the existing `@core/utils/plugins.ts` glob — no `main.ts` change ## Commits 1. **5eac201** — `docs(refactor): audit axios↔store coupling for decoupling work` Phase A read-only audit → `dev-docs/TECH-AXIOS-STORE-COUPLING-AUDIT.md`. 2. **53f6a7b** — `refactor(apps/app): extract axios interceptors to registerInterceptors seam` `lib/axios.ts` rewrite; the four `eslint-disable` markers removed structurally. 3. **26a92b3** — `feat(apps/app): add plugins/3.axios-bindings.ts to wire stores into axios` New plugin; suplies the four runtime closures. 4. **4197df2** — `docs: close TECH-AXIOS-STORE-COUPLING and add TECH-AXIOS-INTERCEPTOR-TESTS` BACKLOG.md + ARCH-CONSOLIDATION-2026-04.md updated; new follow-up entry for the four scenarios (X-Org-Id header, 401/403 logout, impersonation revocation, error toasts) that remain untested today. 5. **853939e** — `refactor(apps/app): decouple axios from impersonation sessionStorage contract` Fifth callback `getImpersonationTargetUserId` added to `AxiosBindingsDeps`; `lib/axios.ts` no longer reads `sessionStorage` directly nor knows the `'crewli_impersonation'` key or `{ targetUserId }` shape. The store is the canonical source (verified: `useImpersonationStore.targetUserId` is a public computed hydrated from sessionStorage at store-init). 6. **de07cca** — `chore(apps/app): drop unnecessary async on synchronous error handlers` Two `async error => { throw error }` → `error => { throw error }`. Mechanical. ## Architectural decisions **Phase B sign-off (Bert):** - Q1 = Approach 1 (callback-injection, not event-bus) — typed, four touchpoints. - Q2 = `AxiosBindingsDeps` lives in `lib/axios.ts` itself, exported. - Q3 = Test scope deferred via new `TECH-AXIOS-INTERCEPTOR-TESTS` backlog entry. - Q4 = `3.axios-bindings.ts` flat .ts file (matches `2.pinia.ts` convention). **Post-Phase-C improvements (during PR review):** - Improvement 1: sessionStorage decoupling. The original refactor preserved legacy direct sessionStorage reads inside the request interceptor. Audit showed `useImpersonationStore.targetUserId` is an existing public computed; Option A (delegate via callback) was the clean path. - Improvement 2: removed unused `async` keywords from synchronous error handlers. Behavior-neutral. ## Acceptance - ✅ `pnpm lint` 0 problems in `apps/app/` (pre-existing boundaries-v6 deprecation warnings tracked separately as `TECH-BOUNDARIES-V6-SELECTOR-MIGRATION`) - ✅ `pnpm typecheck` clean - ✅ `pnpm test` 49/49 passed (no regression) - ✅ `pnpm build` succeeds - ✅ **0 Vite mixed-import warnings** (was 2 — `useAuthStore` and `useImpersonationStore` no longer dynamically imported by `lib/axios.ts`) - ✅ All four `eslint-disable-next-line boundaries/element-types` markers in `lib/axios.ts` are gone (structurally, not suppressed) - ✅ `lib/axios.ts` has zero references to `@/stores/*` or `sessionStorage` - ✅ Conventional Commits + `Co-Authored-By: Claude` on all commits - ✅ No file in `apps/portal/`, `api/`, `packages/`, `docs/` modified ## Follow-up backlog item `TECH-AXIOS-INTERCEPTOR-TESTS` (medium prio) — added to `BACKLOG.md`. Covers the four acceptance scenarios that remain untested: - X-Organisation-Id header injection on outbound requests - 401/403 logout flow (auth store integration) - Impersonation revocation flow on 403+impersonation_ended - Error toast on 4xx/5xx (notification store integration) Deferred deliberately: mixing refactor and test-creation in the same session destroys the ability to verify whether tests spec pre- or post-refactor behavior. Tests will be added in a focused session against the new architecture from zero. ## Merge `--no-ff` (web UI: kies "Create merge commit") per CLAUDE.md commit hygiene.
bert.hausmans added 6 commits 2026-05-04 22:41:52 +02:00
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>
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>
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>
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>
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>
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>
bert.hausmans closed this pull request 2026-05-04 22:44:14 +02:00
bert.hausmans deleted branch refactor/axios-store-coupling 2026-05-04 22:44:14 +02:00

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bert.hausmans/crewli#3