X-Target-App header for the client surface
X-Target-App header for the client surface
Section titled “X-Target-App header for the client surface”1. Context
Section titled “1. Context”P1 #4 locks in a shared Supabase identity across gym_app and the
upcoming tickets_app. A single user can therefore hold bookings under
both verticals concurrently. Cross-company client-surface endpoints that
return activity-attached personal data ignore the vertical, which leaks
content across apps:
GET /api/client/me/wallet/upcoming(walletClientListUpcoming,wallet-client.controller.ts:28-33— verified) joinscompanyCustomers → bookings → sessions → activitiesfiltered only byuserId+bookings.status = 'CONFIRMED'(wallet-client.service.ts:168— verified). NotargetAppscope.- The P3 #7 arc that added
spheres.targetApponly solved it at the catalogue-browse boundary:GET /api/client/activitiesreads?targetApp=from the query DTO (activities-client.service.ts:115-123,find-activities-client.dto.ts:101-108— both verified). This per-call pattern does not scale to every cross-company endpoint.
The business surface already solved an isomorphic problem: a per-request
scope hint expressed as an HTTP header, read at the request boundary,
exposed to services via a param decorator
(tktspace-business/.../company.interceptor.ts sets X-Company-Id;
jwt-auth.guard.ts:55,73 reads it onto req.companyId;
active-company.decorator.ts exposes it — verified). This ADR mirrors
that pattern on the client surface with X-Target-App, lands the
backend infra (interceptor + decorator + helper), and proves it by
retrofitting one endpoint (wallet upcoming).
2. Decision
Section titled “2. Decision”Introduce a single client-surface invariant:
Any client-surface endpoint that returns or filters activity-attached data MUST respect a
targetAppscope. Scope is resolved at the request boundary by the priority query > header > undefined. Endpoints opt in by injecting one decorator and composing one helper into a DrizzleWHERE. “Undefined” is a valid state (web / admin) — it means “no filter”.
Implementation has three colocated pieces:
- A NestJS interceptor
TargetAppInterceptorreadsreq.headers['x-target-app'], validates it againstSphereTargetAppEnum.enumValues, and assignsreq.targetApp. Invalid values are silently dropped (the header is a hint, not an auth boundary). Mounted globally viaAPP_INTERCEPTORinauth.module.ts, gated by URL prefix/api/client/*inside the interceptor itself — business and superadmin routes pass through untouched. - A param decorator
ActiveTargetAppreturnsreq.targetApptypedstring | undefined. UnlikeActiveCompany, it does NOT throw on absence — absence is the valid web/admin path. - Two helpers, co-located in
libs/shared/data-access-db/src/lib/helpers/apply-target-app-filter.ts:resolveTargetApp(query?, header?)— implements the priority rule (query > header > undefined), validates both againstSphereTargetAppEnum.enumValues, drops invalids.applyTargetAppFilter(targetApp)— returns a DrizzleSQLpredicate matching the existing subquery shape atactivities-client.service.ts:115-123, orundefinedwhen input isundefinedso the call site composes insideand(...)without a conditional wrapper.
The retrofit pattern at a service site is therefore a one-line
composition inside the existing and(...):
and( /* existing predicates */, applyTargetAppFilter(targetApp), // undefined-arm dropped by Drizzle)URL-prefix gating (NOT JWT-gating) is intentional: the header is a
scope hint orthogonal to authentication. An anonymous public route on
/api/client/* that ships in the future would still want to honor it
(e.g. unauthenticated catalogue search with a target-app fallback).
3. Why a NestJS Interceptor (not Guard, not Middleware)
Section titled “3. Why a NestJS Interceptor (not Guard, not Middleware)”| Option | Behaviour | Why rejected / accepted |
|---|---|---|
| Middleware | Runs before the routing layer resolves; has access to req but not to context | No DI scoping into the route, can’t easily skip for non-client surfaces without string matching anyway, and order-of-registration is fragile relative to Nest’s global guards. Rejected. |
| Guard | Runs after auth; canActivate returns boolean | Guards are an authorization concept — they grant or deny. We do not want to reject on absence/invalid; we want to enrich the request. Co-opting canActivate for “always true, but side-effect” is a smell, and Nest already runs JwtAuthGuard here. Rejected. |
| Interceptor | Wraps the handler, runs after guards, has full ExecutionContext | Right semantic match: enriches req with a request-scoped value, never blocks the pipeline, sits in a slot Nest specifically reserves for cross-cutting request transforms. Accepted. |
The interceptor sets req.targetApp before the controller method is
invoked, so @ActiveTargetApp() reads the validated value exactly as
@ActiveCompany() reads req.companyId.
4. Backend file layout
Section titled “4. Backend file layout”New files
Section titled “New files”| Path | Purpose |
|---|---|
libs/features/auth/src/lib/interceptors/target-app.interceptor.ts | TargetAppInterceptor implements NestInterceptor. URL-prefix check (req.url.startsWith('/api/client/')), reads req.headers['x-target-app'], validates against SphereTargetAppEnum.enumValues, sets req.targetApp. Falls through next.handle() always. |
libs/features/auth/src/lib/interceptors/index.ts | Barrel export for the new interceptor. |
libs/shared/common/src/decorators/active-target-app.decorator.ts | Mirrors active-company.decorator.ts shape — createParamDecorator((_, ctx) => ctx.switchToHttp().getRequest().targetApp). No UnauthorizedException throw on absence. |
libs/shared/common/src/helpers/resolve-target-app.ts | Pure resolver resolveTargetApp(query?, header?). No DB-table imports. Lives in shared/common (string-in/string-out). |
libs/shared/common/src/helpers/index.ts (new, OR append) | Barrel export for the pure resolver. |
libs/features/activities/src/lib/helpers/apply-target-app-filter.ts | Drizzle SQL predicate applyTargetAppFilter(targetApp). Imports activities, spheres from data-access-db schema package. Lives in the activities feature (NOT in shared/data-access-db/) to keep the shared DB package free of feature-table imports. |
libs/features/activities/src/lib/helpers/index.ts (new, OR append) | Barrel export for the SQL helper. |
apps/api-e2e/src/client/wallet-target-app.e2e-spec.ts | AC-10 e2e. Lives under apps/api-e2e/src/client/ (matches existing surface-named dirs convention — siblings are client/, admin/, spheres/, favorites/, passes/). The wallet/ sub-dir is NOT used. |
libs/features/auth/src/lib/interceptors/target-app.interceptor.spec.ts | AC-11 interceptor unit test — invalid header value (FOO_APP) → req.targetApp === undefined. Pinned at interceptor layer because HTTP tests can’t distinguish “interceptor dropped” from “resolver ignored”. |
Edited files
Section titled “Edited files”| Path | Change |
|---|---|
libs/features/auth/src/lib/auth.module.ts | Register TargetAppInterceptor via { provide: APP_INTERCEPTOR, useClass: TargetAppInterceptor } alongside the existing APP_GUARD provider (line 26-29 today). |
libs/features/auth/src/index.ts | Re-export TargetAppInterceptor only if it is consumed outside auth/ (likely not — APP_INTERCEPTOR registration is internal). Skip if unused downstream. |
libs/shared/common/src/decorators/index.ts | Re-export ActiveTargetApp alongside ActiveCompany, ActiveUser. |
libs/features/activities/src/index.ts | Re-export applyTargetAppFilter from the activities feature barrel (wallet imports it). |
libs/features/wallet/src/lib/controllers/wallet-client.controller.ts | Inject @ActiveTargetApp() targetApp?: string on getUpcoming (line 31). Pass through to service. Add @ApiHeader({ name: 'X-Target-App', enum: SphereTargetAppEnum.enumValues, enumName: 'SphereTargetApp', required: false }) on the operation. |
libs/features/wallet/src/lib/services/wallet-client.service.ts | getUpcomingAcrossCompanies(userId, targetApp?). Import applyTargetAppFilter from activities-feature barrel; compose into the and(...) at lines 197-203. |
libs/features/activities/src/lib/controllers/activities-global-client.controller.ts | Inject @ActiveTargetApp() headerTargetApp?: string on activitiesClientList (global browse, @Public(), operationId activitiesClientList). Forward to findAll(dto, undefined, headerTargetApp). Add @ApiHeader(...). |
libs/features/activities/src/lib/controllers/activities-client.controller.ts | Inject @ActiveTargetApp() headerTargetApp?: string on activitiesClientListByCompany (operationId activitiesClientListByCompany). Forward to findAll(dto, companyId, headerTargetApp). Add @ApiHeader(...). |
libs/features/activities/src/lib/services/activities-client.service.ts | Extend findAll(dto, companyId?, headerTargetApp?) — gain a third optional arg. Inside, resolve via const effectiveTargetApp = resolveTargetApp(dto.targetApp, headerTargetApp) and use the result in the existing inArray(activities.sphereId, …) subquery at line ~115 (replacing the direct dto.targetApp read). |
libs/features/activities/src/lib/dto/find-activities-client.dto.ts | No change. The targetApp query param stays as today. |
libs/features/activities/src/lib/spec/activities-client.target-app.spec.ts | Extend with AC-11 HTTP resolution cases (4 valid-value scenarios). Invalid-header case moved to interceptor unit test. |
_workflow/contracts/client.openapi.yaml | Regenerated via pnpm run sync:contracts. |
5. Resolution rule semantics
Section titled “5. Resolution rule semantics”resolveTargetApp(query?: string, header?: string): string | undefined:
| Query input | Header input | Returns |
|---|---|---|
| valid enum value | (any) | query value |
| invalid string | valid enum value | header value (query dropped) |
| invalid string | invalid string | undefined |
| invalid string | undefined | undefined |
| undefined | valid enum value | header value |
| undefined | invalid string | undefined |
| undefined | undefined | undefined |
Validation is SphereTargetAppEnum.enumValues.includes(input). No
throws; the helper never rejects a request. Per AC-1, the interceptor
also silently drops invalid header values before they reach the
resolver — the resolver’s invalid-header arm is belt-and-braces.
applyTargetAppFilter(undefined) === undefined so Drizzle drops the
clause from and(...). applyTargetAppFilter('GYM_APP') returns the
exact subquery shape currently inline at
activities-client.service.ts:115-123.
6. API design (client surface only)
Section titled “6. API design (client surface only)”No new endpoints. No request-body, response-body, or DTO changes. Three existing operations gain header-parameter documentation. Operation identifiers verified against the live controllers:
walletClientListUpcoming(wallet-client.controller.ts:29) — wallet upcoming retrofit.activitiesClientList(activities-global-client.controller.ts:20) — global cross-company browse (@Public()).activitiesClientListByCompany(activities-client.controller.ts:65) — per-company browse.
Both activities controllers route through the same ActivitiesClientService.findAll
method; only the controller-level wiring + @ApiHeader decoration differs.
Header-resolution semantics are identical for both.
OpenAPI patches (described, not applied)
Section titled “OpenAPI patches (described, not applied)”Both operations add:
@ApiHeader({ name: 'X-Target-App', enum: SphereTargetAppEnum.enumValues, enumName: 'SphereTargetApp', required: false, description: 'Optional client-surface scope hint. ...'})Per _workflow/CLAUDE.md “OpenAPI authoring rules”:
enumNamematches the existing named enum so consumers see the sameSphereTargetAppDart/TS type already in use.required: false.- One
@ApiHeaderper opting-in operation. The interceptor readsreq.headersdirectly regardless of whether@ApiHeaderis declared — the decorator only affects the contract / Swagger UI.
7. Surface impact
Section titled “7. Surface impact”| Surface | Touched? | What changes |
|---|---|---|
client.openapi.yaml | Yes | Adds X-Target-App header parameter on two operations. No schema additions (the SphereTargetApp enum already exists). |
business.openapi.yaml | No | Interceptor URL-prefix-gated to /api/client/*. Business routes never see req.targetApp. |
super-admin.openapi.yaml | No | Same — gated out. |
No field-level surface differences. The same SphereTargetApp enum is
visible only on the client contract (where it already is today, on the
search query param).
8. Mobile file layout (gym_app only)
Section titled “8. Mobile file layout (gym_app only)”| Path | Change |
|---|---|
apps/gym_app/lib/api/target_app_interceptor.dart (new) | class TargetAppInterceptor implements Interceptor (Chopper). intercept(...) copies the inbound request, adds 'X-Target-App': 'GYM_APP' to its headers, awaits chain.proceed(modifiedRequest). GYM_APP is a const String literal in this file — matches SphereTargetAppEnum.enumValues[0] verified at activities.schema.ts:27. |
apps/gym_app/lib/main.dart (edit) | Line 58-62 today is ApiClient.instance.init(apiUrl, authUrl, interceptors: [authInterceptor]). Append const TargetAppInterceptor() so the list becomes [authInterceptor, const TargetAppInterceptor()]. |
apps/gym_app/integration_test/target_app_header_test.dart (new) | AC-12 smoke. Mirrors apps/gym_app/integration_test/sphere_filter_test.dart — installs HttpOverrides.global with a stub HttpClient that captures outgoing request headers, fires one Api.invoke(...) through ApiClient.instance.api, asserts the captured request has x-target-app: GYM_APP. No backend round-trip. |
packages/api/* is NOT touched (AC-9). The shared package’s
ApiClient.init(...) already accepts an interceptors list (verified
at packages/api/lib/src/api_client.dart:13-26). Hardcoding GYM_APP
into the shared package would leak the app’s identity into shared
infra and break tickets_app adoption. Per-app interceptor is the
right boundary.
9. No DB migration
Section titled “9. No DB migration”spheres.target_app (activities.schema.ts:83) and the
sphere_target_app enum (activities.schema.ts:26-30) already exist.
This ticket is pure-code.
10. Cross-surface coordination checklist (backend agent)
Section titled “10. Cross-surface coordination checklist (backend agent)”The dev agent MUST land all of the following in one MR. No partial landings.
- Create
target-app.interceptor.tswith URL-prefix gating. - Create
active-target-app.decorator.ts(no throw on absence). - Create
apply-target-app-filter.tswith bothresolveTargetAppandapplyTargetAppFilter. - Wire
APP_INTERCEPTORprovider inauth.module.ts. - Re-export the decorator from
libs/shared/common/src/decorators/index.ts. - Re-export
resolveTargetAppfromlibs/shared/common/src/helpers/index.tsandlibs/shared/common/src/index.ts. - Re-export
applyTargetAppFilterfromlibs/features/activities/src/index.tsso the wallet feature can import it. - Retrofit
walletClientListUpcoming:@ActiveTargetApp(),@ApiHeader(...), pass through to service, compose intoand(...)atwallet-client.service.ts:197-203. - Retrofit BOTH activities search controllers:
-
activities-global-client.controller.ts—activitiesClientList(@Public()) -activities-client.controller.ts—activitiesClientListByCompanyInject@ActiveTargetApp() headerTargetApp?: stringon each; pass tofindAll(dto, companyId | undefined, headerTargetApp). Add@ApiHeader(...). - Extend
findAll(dto, companyId?, headerTargetApp?)in the service; replace directdto.targetAppread atactivities-client.service.ts:115withresolveTargetApp(dto.targetApp, headerTargetApp). -
@ApiHeader(...)on THREE operations (wallet + 2 activities). - New unit specs:
target-app.interceptor.spec.ts(invalid header),apply-target-app-filter.spec.ts,resolve-target-app.spec.ts. - Extend
activities-client.target-app.spec.tswith AC-11 HTTP cases (header / query / parity — 4 valid-value scenarios). - Create
apps/api-e2e/src/client/wallet-target-app.e2e-spec.ts(axios pattern; lives underclient/which matches existing surface-named dirs). - Run
pnpm run sync:contractsand commit thecontracts/client.openapi.yamldiff in this repo. -
pnpm run sync:contracts:checkpasses in CI.
Mobile agent (separate MR is fine, since mobile codegen does not change):
- Create
apps/gym_app/lib/api/target_app_interceptor.dart. - Wire into
apps/gym_app/lib/main.dart:58-62. - Create
apps/gym_app/integration_test/target_app_header_test.dart.
11. Risks
Section titled “11. Risks”- Tightening behaviour on search. AC-6 changes
activities-client searchto honor the header when the query is absent. Web never sets the header → unchanged. gym_app does set the header → it now narrows the search byGYM_APPeven when the user has not selected a chip. This is the intended behaviour, but the release notes / regression sweep must confirm no unauthenticated gym_app code path expects the cross-vertical catalogue. - Older gym_app builds hitting the new backend. No header → no filter → wallet upcoming returns all bookings, same as today. Not a regression. Tickets_app, when it adopts the same shape, will retroactively scope its already-deployed users. Fine.
- Invalid-header silent drop. A misconfigured client setting
X-Target-App: foowould expect filtering but get none. This is the least surprising of the two failure modes (the other is a 400 that breaks every screen). The mobile interceptor uses a const literal matched to the schema, so this failure mode is reserved for ad-hoc curl debugging. - Performance. The added
inArray(activities.sphereId, …spheres…)subquery is identical to the one already in production for?targetApp=. Sphere count is bounded (≤ 5 today). No new index needed. - Coupling to
SphereTargetAppEnum.enumValues. Both follow-ups (869dpa3zpDINING removal;869dpxbj6CINEMA/SHOWS merge + types simplification) change the enum / sphere rows. The resolver readsenumValuesat runtime, so any enum schema change is picked up automatically once Drizzle is regenerated. No design dependency.
12. Rollout plan
Section titled “12. Rollout plan”- No feature flag. The header is additive — absence preserves pre-change behaviour.
- No backfill. Pure-code change.
- Backend MR + sync:contracts diff land together.
- Mobile MR can ship same-day or one release later. gym_app builds without the interceptor continue to work against the new backend.
- Web is unaffected; no MR needed in
tktspace-webortktspace-business.
13. Alternatives considered
Section titled “13. Alternatives considered”A. Guard instead of Interceptor (rejected)
Section titled “A. Guard instead of Interceptor (rejected)”The existing JwtAuthGuard already reads X-Company-Id in its callback
(lines 55, 73). Easiest path would be to read X-Target-App there too.
Rejected because:
- The guard’s responsibility is authn/authz. Adding a parsing
side-effect makes its semantics fuzzier — every future reader has to
re-derive that “ok, this guard also enriches
reqwith a scope hint”. - The guard runs for every surface (it routes by URL prefix to the matching passport strategy). The interceptor needs the same URL-prefix gate either way, so we gain nothing by colocating.
- Interceptors are the documented Nest slot for cross-cutting, non-blocking request enrichment.
B. JWT claim instead of header (rejected)
Section titled “B. JWT claim instead of header (rejected)”Pack targetApp into the JWT. Rejected:
- The same user with the same JWT can be on either
gym_apportickets_app(and potentiallytickets_appweb). A claim is per-token, not per-request. - Re-issuing the JWT on app switch is operationally hostile.
- The header model is already the platform’s idiom (
X-Company-Id). - Tokens minted by
CLIENT_SUPABASE_*are not under our control to enrich anyway.
C. Per-call query param only (rejected)
Section titled “C. Per-call query param only (rejected)”Keep solving each leak by adding a new query param. Rejected:
- We already shipped
?targetApp=on one endpoint. Adding it to wallet upcoming, then my-bookings, then companies my-bookings, then passes search, then favorites is N times the surface area with N opportunities to forget. - Mobile would have to plumb the value through every screen’s API call.
- The principle (mobile-app-as-scope) is per-request, not per-call.
D. Server-side derivation from User-Agent (rejected)
Section titled “D. Server-side derivation from User-Agent (rejected)”Sniff User-Agent: GymApp/x.y.z on the server. Rejected:
- Brittle. UAs are spoofable, change shape across platforms, and don’t exist on web at all.
- Couples backend infra to mobile build metadata.
- No reason to invent when an explicit header costs the same.
14. Open follow-ups (non-dependencies)
Section titled “14. Open follow-ups (non-dependencies)”869dpa3zp— dropDINING_APPenum value, drop DINING activity type and sphere; addSERVICES_APP. Independent. Our resolver readsenumValuesat runtime, so this ticket survives the enum shape change automatically.869dpxbj6— collapse activity types to[SLOT_BASED, SERVICE]with orthogonalhasSeatSelection: boolean; merge CINEMA + SHOWS sphere rows into a singleEVENTSrow. Same — orthogonal to the header mechanism. No design dependency.tickets_appChopper interceptor. Lands whentickets_appboots its native scaffold (P1 #4 T1). Copiestarget_app_interceptor.dartintoapps/tickets_app/lib/api/with valueTICKETS_APP. Explicitly out of scope here.- Remaining cross-company / activity-attached endpoints. Listed in
the spec under “Known deltas”:
/companies/:id/my-bookings,/companies/:id/activities/:id/my-bookings, future favorites/passes/saved-filters. Each is a one-line retrofit using the same helper. Explicitly NOT in this ticket.
15. Verification notes (drift between spec and code)
Section titled “15. Verification notes (drift between spec and code)”All paths and line numbers cited in the spec were verified against the working tree:
wallet-client.controller.ts:28-33matches (walletClientListUpcomingat line 29, handler at 31-33).wallet-client.service.ts:168matches (getUpcomingAcrossCompanies).- The
and(...)clause to extend is at lines 197-203 inwallet-client.service.ts(verified — the spec quotes “~197”, exact match). activities-client.service.ts:115-123matches the existingtargetAppsubquery (verified verbatim).find-activities-client.dto.ts:101-108matches the existingtargetAppquery field withenumName: 'SphereTargetApp'(verified verbatim).activities.schema.ts:26-30defines the enum[GYM_APP, TICKETS_APP, DINING_APP](verified).activities.schema.ts:83declarestargetApp: SphereTargetAppEnum('target_app').notNull()onspheres(verified).jwt-auth.guard.ts:55,73readsreq.headers['x-company-id']ontoreq.companyId(verified — both call sites identical, one in the optional-auth branch, one in the strict branch).active-company.decorator.tsthrowsUnauthorizedExceptionon absence (verified —ActiveTargetAppwill deliberately NOT throw).tktspace-business/.../company.interceptor.tssets the header fromAuthCompanyService.current()(verified at line 25).packages/api/lib/src/api_client.dart:13-26accepts aninterceptorslist and chains it afterNullParamFilterInterceptor(verified — no shared-package change needed).apps/gym_app/lib/main.dart:58-62callsApiClient.instance.init(...)with the[authInterceptor]list (verified at line 58; spec was accurate).apps/gym_app/integration_test/sphere_filter_test.dartusesHttpOverrides.globalto stub responses for the productionSearchPagewidget (verified — pattern is portable to the new smoke).libs/features/activities/src/lib/spec/activities-client.target-app.spec.tsexists and runs HTTP-against-running-server via axios (verified — extending in place is correct per AC-11).
Critic-pass reconciliation (iteration 2):
- e2e path was originally
apps/api-e2e/src/wallet/...— moved toapps/api-e2e/src/client/wallet-target-app.e2e-spec.tsper critic recommendation.client/is the existing surface-named convention (siblings:client/,admin/,spheres/,favorites/,passes/). - Activities search retrofit covers BOTH controllers
(
activities-global-client.controller.tsforactivitiesClientListactivities-client.controller.tsforactivitiesClientListByCompany), not just one. Service method gains a thirdheaderTargetApp?arg.
applyTargetAppFilterwas originally specified infeatures/activities/.../helpers/. During Phase C implementation the helper was relocated tolibs/shared/common/src/helpers/because the wallet feature importing from activities would have closed an Nx-detectedactivities ↔ walletcycle. Verified: activities already depends on wallet viaWalletModule/WalletServiceinjection at five call sites (activities.module.ts:17,activities-client.module.ts:3,bookings.service.ts:18,bookings-client.service.ts:28,activities-scheduler.service.ts:7). Relocation justified.- Trade-off:
shared/commonnow imports two domain tables (activities,spheres), which is a mild violation of the “no domain coupling” intent forshared/common. Tolerable for now; cleaner solution is to extract a dedicatedlibs/shared/target-app/library that depends only ondata-access-dband is consumed by bothfeatures/activitiesandfeatures/wallet. Tracked as a debt follow-up ticket — see Open follow-ups §14. resolveTargetApp(pure) stays inshared/common/.../helpers/.- AC-11 invalid-header case moved from HTTP spec to a dedicated interceptor unit test (HTTP layer can’t observably distinguish “interceptor dropped” from “resolver ignored”).
Code-review reconciliation (Phase D, iteration 1):
- Reviewer verdict: APPROVED with 1 MAJOR (helper relocation — documented
above), 3 MINORs (sibling
type: 'string'alongside$refin@ApiHeaderoutput — codegens treat$refas authoritative;queryChunkswalker in helper spec; mobile test docstring with obsoleteTODO(phase-c)marker). - Helper-relocation debt follow-up created in backlog.
No drift found. Ready for build.