Skip to content

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”

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) joins companyCustomers → bookings → sessions → activities filtered only by userId + bookings.status = 'CONFIRMED' (wallet-client.service.ts:168 — verified). No targetApp scope.
  • The P3 #7 arc that added spheres.targetApp only solved it at the catalogue-browse boundary: GET /api/client/activities reads ?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).

Introduce a single client-surface invariant:

Any client-surface endpoint that returns or filters activity-attached data MUST respect a targetApp scope. 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 Drizzle WHERE. “Undefined” is a valid state (web / admin) — it means “no filter”.

Implementation has three colocated pieces:

  1. A NestJS interceptor TargetAppInterceptor reads req.headers['x-target-app'], validates it against SphereTargetAppEnum.enumValues, and assigns req.targetApp. Invalid values are silently dropped (the header is a hint, not an auth boundary). Mounted globally via APP_INTERCEPTOR in auth.module.ts, gated by URL prefix /api/client/* inside the interceptor itself — business and superadmin routes pass through untouched.
  2. A param decorator ActiveTargetApp returns req.targetApp typed string | undefined. Unlike ActiveCompany, it does NOT throw on absence — absence is the valid web/admin path.
  3. 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 against SphereTargetAppEnum.enumValues, drops invalids.
    • applyTargetAppFilter(targetApp) — returns a Drizzle SQL predicate matching the existing subquery shape at activities-client.service.ts:115-123, or undefined when input is undefined so the call site composes inside and(...) 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)”
OptionBehaviourWhy rejected / accepted
MiddlewareRuns before the routing layer resolves; has access to req but not to contextNo 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.
GuardRuns after auth; canActivate returns booleanGuards 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.
InterceptorWraps the handler, runs after guards, has full ExecutionContextRight 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.

PathPurpose
libs/features/auth/src/lib/interceptors/target-app.interceptor.tsTargetAppInterceptor 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.tsBarrel export for the new interceptor.
libs/shared/common/src/decorators/active-target-app.decorator.tsMirrors active-company.decorator.ts shape — createParamDecorator((_, ctx) => ctx.switchToHttp().getRequest().targetApp). No UnauthorizedException throw on absence.
libs/shared/common/src/helpers/resolve-target-app.tsPure 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.tsDrizzle 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.tsAC-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.tsAC-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”.
PathChange
libs/features/auth/src/lib/auth.module.tsRegister TargetAppInterceptor via { provide: APP_INTERCEPTOR, useClass: TargetAppInterceptor } alongside the existing APP_GUARD provider (line 26-29 today).
libs/features/auth/src/index.tsRe-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.tsRe-export ActiveTargetApp alongside ActiveCompany, ActiveUser.
libs/features/activities/src/index.tsRe-export applyTargetAppFilter from the activities feature barrel (wallet imports it).
libs/features/wallet/src/lib/controllers/wallet-client.controller.tsInject @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.tsgetUpcomingAcrossCompanies(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.tsInject @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.tsInject @ActiveTargetApp() headerTargetApp?: string on activitiesClientListByCompany (operationId activitiesClientListByCompany). Forward to findAll(dto, companyId, headerTargetApp). Add @ApiHeader(...).
libs/features/activities/src/lib/services/activities-client.service.tsExtend 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.tsNo change. The targetApp query param stays as today.
libs/features/activities/src/lib/spec/activities-client.target-app.spec.tsExtend with AC-11 HTTP resolution cases (4 valid-value scenarios). Invalid-header case moved to interceptor unit test.
_workflow/contracts/client.openapi.yamlRegenerated via pnpm run sync:contracts.

resolveTargetApp(query?: string, header?: string): string | undefined:

Query inputHeader inputReturns
valid enum value(any)query value
invalid stringvalid enum valueheader value (query dropped)
invalid stringinvalid stringundefined
invalid stringundefinedundefined
undefinedvalid enum valueheader value
undefinedinvalid stringundefined
undefinedundefinedundefined

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.

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.

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”:

  • enumName matches the existing named enum so consumers see the same SphereTargetApp Dart/TS type already in use.
  • required: false.
  • One @ApiHeader per opting-in operation. The interceptor reads req.headers directly regardless of whether @ApiHeader is declared — the decorator only affects the contract / Swagger UI.
SurfaceTouched?What changes
client.openapi.yamlYesAdds X-Target-App header parameter on two operations. No schema additions (the SphereTargetApp enum already exists).
business.openapi.yamlNoInterceptor URL-prefix-gated to /api/client/*. Business routes never see req.targetApp.
super-admin.openapi.yamlNoSame — 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).

PathChange
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.

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.ts with URL-prefix gating.
  • Create active-target-app.decorator.ts (no throw on absence).
  • Create apply-target-app-filter.ts with both resolveTargetApp and applyTargetAppFilter.
  • Wire APP_INTERCEPTOR provider in auth.module.ts.
  • Re-export the decorator from libs/shared/common/src/decorators/index.ts.
  • Re-export resolveTargetApp from libs/shared/common/src/helpers/index.ts and libs/shared/common/src/index.ts.
  • Re-export applyTargetAppFilter from libs/features/activities/src/index.ts so the wallet feature can import it.
  • Retrofit walletClientListUpcoming: @ActiveTargetApp(), @ApiHeader(...), pass through to service, compose into and(...) at wallet-client.service.ts:197-203.
  • Retrofit BOTH activities search controllers: - activities-global-client.controller.tsactivitiesClientList (@Public()) - activities-client.controller.tsactivitiesClientListByCompany Inject @ActiveTargetApp() headerTargetApp?: string on each; pass to findAll(dto, companyId | undefined, headerTargetApp). Add @ApiHeader(...).
  • Extend findAll(dto, companyId?, headerTargetApp?) in the service; replace direct dto.targetApp read at activities-client.service.ts:115 with resolveTargetApp(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.ts with 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 under client/ which matches existing surface-named dirs).
  • Run pnpm run sync:contracts and commit the contracts/client.openapi.yaml diff in this repo.
  • pnpm run sync:contracts:check passes 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.
  • Tightening behaviour on search. AC-6 changes activities-client search to 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 by GYM_APP even 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: foo would 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 (869dpa3zp DINING removal; 869dpxbj6 CINEMA/SHOWS merge + types simplification) change the enum / sphere rows. The resolver reads enumValues at runtime, so any enum schema change is picked up automatically once Drizzle is regenerated. No design dependency.
  • 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-web or tktspace-business.

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 req with 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.

Pack targetApp into the JWT. Rejected:

  • The same user with the same JWT can be on either gym_app or tickets_app (and potentially tickets_app web). 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.

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.
  • 869dpa3zp — drop DINING_APP enum value, drop DINING activity type and sphere; add SERVICES_APP. Independent. Our resolver reads enumValues at runtime, so this ticket survives the enum shape change automatically.
  • 869dpxbj6 — collapse activity types to [SLOT_BASED, SERVICE] with orthogonal hasSeatSelection: boolean; merge CINEMA + SHOWS sphere rows into a single EVENTS row. Same — orthogonal to the header mechanism. No design dependency.
  • tickets_app Chopper interceptor. Lands when tickets_app boots its native scaffold (P1 #4 T1). Copies target_app_interceptor.dart into apps/tickets_app/lib/api/ with value TICKETS_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-33 matches (walletClientListUpcoming at line 29, handler at 31-33).
  • wallet-client.service.ts:168 matches (getUpcomingAcrossCompanies).
  • The and(...) clause to extend is at lines 197-203 in wallet-client.service.ts (verified — the spec quotes “~197”, exact match).
  • activities-client.service.ts:115-123 matches the existing targetApp subquery (verified verbatim).
  • find-activities-client.dto.ts:101-108 matches the existing targetApp query field with enumName: 'SphereTargetApp' (verified verbatim).
  • activities.schema.ts:26-30 defines the enum [GYM_APP, TICKETS_APP, DINING_APP] (verified).
  • activities.schema.ts:83 declares targetApp: SphereTargetAppEnum('target_app').notNull() on spheres (verified).
  • jwt-auth.guard.ts:55,73 reads req.headers['x-company-id'] onto req.companyId (verified — both call sites identical, one in the optional-auth branch, one in the strict branch).
  • active-company.decorator.ts throws UnauthorizedException on absence (verified — ActiveTargetApp will deliberately NOT throw).
  • tktspace-business/.../company.interceptor.ts sets the header from AuthCompanyService.current() (verified at line 25).
  • packages/api/lib/src/api_client.dart:13-26 accepts an interceptors list and chains it after NullParamFilterInterceptor (verified — no shared-package change needed).
  • apps/gym_app/lib/main.dart:58-62 calls ApiClient.instance.init(...) with the [authInterceptor] list (verified at line 58; spec was accurate).
  • apps/gym_app/integration_test/sphere_filter_test.dart uses HttpOverrides.global to stub responses for the production SearchPage widget (verified — pattern is portable to the new smoke).
  • libs/features/activities/src/lib/spec/activities-client.target-app.spec.ts exists 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 to apps/api-e2e/src/client/wallet-target-app.e2e-spec.ts per 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.ts for activitiesClientList
    • activities-client.controller.ts for activitiesClientListByCompany), not just one. Service method gains a third headerTargetApp? arg.
  • applyTargetAppFilter was originally specified in features/activities/.../helpers/. During Phase C implementation the helper was relocated to libs/shared/common/src/helpers/ because the wallet feature importing from activities would have closed an Nx-detected activities ↔ wallet cycle. Verified: activities already depends on wallet via WalletModule/WalletService injection 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/common now imports two domain tables (activities, spheres), which is a mild violation of the “no domain coupling” intent for shared/common. Tolerable for now; cleaner solution is to extract a dedicated libs/shared/target-app/ library that depends only on data-access-db and is consumed by both features/activities and features/wallet. Tracked as a debt follow-up ticket — see Open follow-ups §14.
  • resolveTargetApp (pure) stays in shared/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 $ref in @ApiHeader output — codegens treat $ref as authoritative; queryChunks walker in helper spec; mobile test docstring with obsolete TODO(phase-c) marker).
  • Helper-relocation debt follow-up created in backlog.

No drift found. Ready for build.