ADR: web-mobile-logic-port
ADR — web-mobile-logic-port
Section titled “ADR — web-mobile-logic-port”Context
Section titled “Context”tktspace-web currently exposes five shallow pages, no auth, no profile, no
favorites, no notifications. The full product surface lives in
tktspace-mobile-app (gym_app, tickets_app, and eight shared Dart
packages). The spec web-mobile-logic-port ports the mobile-app product
logic into tktspace-web behind a single sphere-selector UX. Two existing
backend endpoints become public (GET /activities, GET /activities/{id}),
and roughly 25 additional client-surface paths must be declared in the
local OpenAPI contract before web coding can start.
This ADR resolves the architectural questions called out by the spec: the public-access policy on the client surface (Decision 1), the Supabase
- Angular SSR strategy (Decision 2, resolves OQ-10), the contract gap reconciliation against the dev OpenAPI (Decision 3), and the build phase sequencing (Decision 4).
Decision 1 — Public-access policy on the client surface
Section titled “Decision 1 — Public-access policy on the client surface”Endpoints on /api/client/* are partitioned into two buckets:
- Public (no
Authorizationheader required). Endpoints that return catalogue / discovery / platform metadata identical for every caller and carry no user-specific information beyond fields that already degrade gracefully for anonymous callers (e.g.isFavorited: false). - Bearer-only (Supabase JWT required). Every endpoint that reads or mutates user-scoped state: bookings, wallet, profile, notifications, favorites, passes, payments, and customer profile inside a company.
Concretely, the public set on the client surface after this ticket is:
| Path | Reason it is public |
|---|---|
GET /spheres | Platform discovery metadata (already public). |
GET /categories | Platform catalogue metadata (already public). |
GET /categories/{id} | Same. |
GET /activities | NEW — catalogue browse, identical for every caller. |
GET /activities/{id} | NEW — catalogue detail, identical for every caller. |
GET /companies/{id} | Company profile (PII-stripped projection, see below). |
GET /whitelabel-apps/{id}/config | Supabase URL + anon key — public bootstrap data. |
All other paths require Bearer auth.
Field-visibility rule for GET /companies/{id} (BLOCKER 1 — resolved)
Section titled “Field-visibility rule for GET /companies/{id} (BLOCKER 1 — resolved)”The /api/client/companies/{id} response MUST NOT carry the company
owner’s email address or owner’s user id. Both are operator-identity
fields that have no place on an anonymous-reachable surface.
- The contract’s
CompanyResponseDto(client surface) dropsemailandownerIdfrom bothpropertiesandrequired. The business surface keeps them for operator workflows. - The backend
CompaniesService.findOnecurrently returns the full Drizzle row. The client controller MUST project the result to the narrowed shape before returning. If a single shared projection function is introduced, it must be forked per surface — do NOT share a response shape between the client and business controllers. - AC-level prescription (since spec AC-43 does not cover this):
“Backend
CompaniesClientController.findOnemust use the@Public()decorator (the class has no class-level guard — only the globalJwtAuthGuardwhich honours@Public()). The client controller MUST project thecompanies.*row to dropemailandownerIdbefore serialising to the wire. If a sharedCompaniesServicemethod is reused, fork the projection or filter by surface — do NOT return the full row to the client surface.”
AC-33 / AC-34 / AC-43 specifically (BLOCKER 2 — resolved)
Section titled “AC-33 / AC-34 / AC-43 specifically (BLOCKER 2 — resolved)”GET /activities and GET /activities/{id} move from Bearer-only to
public in this ticket. The contract sets security: [] on both paths and
omits the 401 response.
Backend implementation prescription. Use the existing @Public()
decorator (defined at
tktspace-backend/libs/features/auth/src/lib/decorators/public.decorator.ts,
already used by categories-client.controller, spheres-client.controller,
whitelabel-apps-client.controller, etc.). DO NOT remove the
class-level @UseGuards(ClientJwtGuard) from
ActivitiesGlobalClientController. That class also hosts
GET /activities/:activityId/sessions which must remain Bearer-only;
removing the class-level guard would silently make sessions public too.
Apply @Public() on the two specific method handlers only:
ActivitiesGlobalClientController.findAll(the@Get()mapped toGET /api/client/activities).ActivitiesGlobalClientController.findOne(the@Get(':id')mapped toGET /api/client/activities/:id).
The same pattern applies to CompaniesClientController.findOne
(per BLOCKER 1). CompaniesClientController has no class-level
@UseGuards — only the global JwtAuthGuard — so a single @Public()
on the findOne handler is sufficient.
Interaction with ClientJwtGuard. The global JwtAuthGuard
(tktspace-backend/libs/features/auth/src/lib/guards/jwt-auth.guard.ts)
honours @Public() and short-circuits before invoking passport.
ClientJwtGuard (the class-level guard) does NOT currently inspect
the IS_PUBLIC_KEY metadata — it only checks req.user, which will be
unset on anonymous calls and will throw 401. The backend ticket
implementing AC-43 MUST either:
- (preferred) extend
ClientJwtGuard.canActivateto readIS_PUBLIC_KEYviaReflectorand short-circuit totruewhen the metadata is set, mirroring the global guard’s pattern; OR - pull the two public method handlers out of
ActivitiesGlobalClientControllerinto a sibling controller that has no class-level guard.
Option (preferred) is the lower-risk change and is the architect’s
recommendation. It does not affect any existing Bearer-protected
endpoint because none of them carries @Public() metadata.
Rule recap, in one line. Class-level @UseGuards stays. Specific
handlers get @Public(). The class-level guard must be made
@Public()-aware (one-line edit) so the metadata actually short-circuits.
Anonymous isFavorited contract. ActivityDetailClientResponseDto.isFavorited
is required: true, type: boolean. The contract documents the runtime rule:
For anonymous callers
isFavoritedis alwaysfalse. For authenticated callers it reflects whether the activity is in the calling user’s favorites list.
OQ-4 sanity check — row-level visibility flags
Section titled “OQ-4 sanity check — row-level visibility flags”Read the activities schema at
tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts
(lines 187-239) and the query layer at
tktspace-backend/libs/features/activities/src/lib/services/activities-client.service.ts
(findAll lines 46-88, findById lines 90-199).
Result: no BLOCKER. The activities table has no companyPrivate,
internalOnly, visibility, isPublic, or equivalent row-level
visibility column beyond status (enum: DRAFT, PUBLISHED, etc.).
Both findAll and findById already enforce eq(activities.status, 'PUBLISHED') in the WHERE clause. Removing the JWT guard exposes
exactly the same row set the authenticated client was already seeing.
OQ-5 sanity check — service-layer null-guard for isFavorited
Section titled “OQ-5 sanity check — service-layer null-guard for isFavorited”Read ActivitiesClientService.findById (lines 90-199). The implementation
already handles userId = undefined safely:
const isFavoritedExpr = userId ? sql<boolean>`EXISTS (... f.user_id = ${userId}::uuid AND f.activity_id = ${activities}.id)` : sql<boolean>`FALSE`;The inline comment on lines 91-102 explicitly anticipates the anonymous
path. No service-layer change is required as part of AC-43. The
backend ticket only needs to remove @UseGuards(ClientJwtGuard) from
ActivitiesGlobalClientController. The list endpoint (findAll) does
not consult userId at all today, so it is also safe.
If a code refactor between this ADR and the backend implementation
removes the userId ? ... : FALSE ternary, that is a regression and the
backend ticket must restore it as part of AC-43.
Rate-limiting (out of scope)
Section titled “Rate-limiting (out of scope)”Per spec Risks and OQ-4, IP-level rate-limiting on the newly-public paths is NOT in scope of this ticket. The architect confirms this is tracked as a separate follow-up infra ticket. Operationally the dev backend does not appear to enforce IP rate-limiting today, so the deployment of the public guard removal must coincide with the infra ticket landing — or production rollout must wait. Flagged for the build pipeline to surface.
Decision 2 — Supabase + Angular SSR strategy (resolves OQ-10)
Section titled “Decision 2 — Supabase + Angular SSR strategy (resolves OQ-10)”Choice: hybrid — client-only Supabase session with cookie-backed
Section titled “Choice: hybrid — client-only Supabase session with cookie-backed”access-token hint for SSR-rendered authenticated pages.
In practice this means:
- Server render is always anonymous by default. SSR HTML is
generated as if the user is logged out. This is correct for
tktspace-web’s public-by-design surface (landing, explore, sphere filter, activity detail) — these pages must be SEO-indexable and render full content for crawlers. - Client hydration resolves the Supabase session from
localStorage(Supabase JS SDK default). After hydration, an HTTP interceptor attachesAuthorization: Bearer <jwt>to outgoing API calls and the authenticated UI surfaces (profile button, heart, etc.) appear. - For protected routes (
/profile,/favorites,/checkout/*, etc.) the server returns a minimal “Loading…” shell — no user-specific data is rendered server-side. After client hydration the route either resolves to the real page (session present) or redirects to sign-in (no session). This avoids both the protected-page flicker and the security risk of leaking user data into the SSR payload.
The “hybrid” hint is a non-sensitive tktspace-auth-hint cookie that
SSR reads to decide whether to render the navigation bar in its
authenticated or anonymous variant. The cookie carries only a
boolean (“a session exists”) and never the JWT itself.
Auth-hint cookie specification (BLOCKER M1 — resolved)
Section titled “Auth-hint cookie specification (BLOCKER M1 — resolved)”This subsection pins the cookie’s exact shape so the Phase B implementer has no choice to make.
- Name.
tktspace-auth-hint. - Value. Literal string
"1"when a Supabase session exists; the cookie is deleted (not set to"0") on sign-out. No user id, no JWT, no expiry timestamp — a pure boolean encoded as cookie presence + the literal value. This is the minimal-surface option: nothing on the cookie can be used to identify, fingerprint, or target a user. If a future feature needs an SSR-readable display name placeholder, amend this ADR — do NOT widen the cookie ad-hoc. - Attributes (set on every issue).
Path=/SameSite=LaxSecure(HTTPS-only; the production and dev frontends are HTTPS)HttpOnlyabsent (the cookie MUST be readable from client JS so sign-in / sign-out / token-refresh handlers can rotate it in lockstep with the Supabase session inlocalStorage)Max-Age=604800(7 days — matches Supabase’s default refresh-token lifetime; rotated on everyTOKEN_REFRESHEDevent so it never expires while the user is active)Domainleft unset. The browser uses the host of the issuing page, which is correct for the single-host deployment oftktspace-web. If a future spec adds a cross-subdomain product, that spec must amend the Domain policy.
- Rotation rules.
- On Supabase
onAuthStateChangeeventSIGNED_IN: set the cookie. - On
TOKEN_REFRESHED: re-issue the cookie with a freshMax-Age. - On
SIGNED_OUT(and onUSER_DELETED, if it ever fires): delete the cookie by settingMax-Age=0with the same name + Path so the deletion is unambiguous.
- On Supabase
- Read paths.
- SSR (Angular Universal Express middleware): parse
req.headers.cookieviacookie.parse(thecookienpm package, already used by Express). Render the nav variant based on the parsed value. - Client TS: read via
document.cookie(parsed with the samecookielibrary to keep parser semantics aligned with the server). - Recommend introducing a single shared utility, e.g.
client/src/app/core/auth/auth-hint-cookie.util.ts, that exportsparseAuthHint(rawCookieHeaderOrDocumentCookie: string): booleanandserializeAuthHint(present: boolean, maxAgeSeconds: number): string. Both server and client import this util — no duplicate parsing logic.
- SSR (Angular Universal Express middleware): parse
- Out-of-scope by design. The cookie is never sent to
api.dev.tktspace.co(different origin) and the backend MUST NOT read it. If a future feature wants per-cookie behavior, route it via the JWT or a separate Bearer-protected endpoint.
Considered alternatives
Section titled “Considered alternatives”- (a) Cookie-based Supabase session via
@supabase/ssrhelpers. Stores the session in an HttpOnly cookie readable on the server. Enables fully-authenticated SSR including protected-page rendering.- Pros: SEO for authenticated pages, no protected-page flicker.
- Cons: Significant complexity (cookie-domain rules, SSR refresh
flow, two-place session state), risk of leaking session data into
HTML cache, and an entirely new Angular SSR auth integration that
has no analogue in
gym_app. Protected pages ontktspace-webare largely behind-the-fold (profile/favorites) and have no SEO need. The complexity cost is not justified by the user benefit.
- (c) Pure client-only auth. Server always renders anonymous. Client
hydrates and resolves session.
- Pros: Simplest. Closest to what
gym_appdoes today (Supabase JS SDK +localStorage). - Cons: Logged-in users see a “Sign in” CTA in the header for the duration of hydration on every navigation (a few hundred ms), which is a visible regression vs. the mobile app’s experience.
- Pros: Simplest. Closest to what
The chosen hybrid captures (c)‘s simplicity while patching (c)‘s single visible UX wart.
Reality check — what mobile does today
Section titled “Reality check — what mobile does today”The gym_app Supabase auth lives in
tktspace-mobile-app/packages/auth/lib/src/auth_service.dart. It calls
Supabase.initialize(url, anonKey) once, then uses
_supabase.auth.signInWithPassword,
_supabase.auth.onAuthStateChange.listen(...), and
_supabase.auth.currentSession?.accessToken to attach the bearer.
This is the closest analogue available — there is no SSR concern
on the mobile side. The chosen web strategy mirrors mobile for the
auth-state semantics and adds only the SSR-anonymous default + cookie
hint on top.
SSR-time HTTP and session safety (MAJOR M2 — pinned)
Section titled “SSR-time HTTP and session safety (MAJOR M2 — pinned)”The following are hard requirements, not suggestions:
- SSR makes only public-endpoint HTTP calls. Any
HttpClientrequest issued during server rendering must hit a path withsecurity: []incontracts/client.openapi.yaml. No protected endpoint may be called from a server-sideHttpClient. - The
AuthInterceptorMUST short-circuit on the server. Concretely, the interceptor injectsPLATFORM_IDand checksisPlatformServer(platformId); when true, it skips theAuthorizationheader attachment unconditionally and forwards the request as-is. This guarantees that even an accidental call to a protected endpoint from SSR will (a) not carry a bearer token and (b) deterministically return 401 rather than silently leaking user-scoped data into the SSR HTML cache. - All SSR-time access to session state must go through
AuthService. No injected service may readlocalStorage,document.cookie, orwindowdirectly during SSR.AuthServiceexposes an SSR-safe token getter that returnsnullon the server. ThePlatformStoragetoken (see below) is the only abstraction allowed to wrap browser storage primitives. - No request-scoped session injection. The Express middleware does
NOT decode the auth-hint cookie into anything more than the
boolean it carries. The cookie value is consumed exclusively by the
nav-bar rendering layer; it never feeds an
Authorizationheader, a server-sideHttpClientcall, or any user-scoped service.
If any of these invariants is violated, the protected-page flicker ADR-1 was trying to avoid would re-emerge as an actual data leak.
Implementation shape (for Phase B/C reference, NOT prescriptive)
Section titled “Implementation shape (for Phase B/C reference, NOT prescriptive)”AuthService(Angular injectable,providedIn: 'root'). Wraps@supabase/supabase-js. Exposes signals:currentUser,isLoading,accessToken. On the server (isPlatformServertrue)init()is a no-op — no Supabase SDK call, nolocalStorageaccess. On the client,init()callscreateClient(SUPABASE_URL, SUPABASE_ANON_KEY)and subscribes toonAuthStateChange.AuthInterceptor(HTTP interceptor). ReadsAuthService.accessToken(). If present, setsAuthorization: Bearer <token>. If absent, sends the request unmodified. Bearer attachment is conditional, not unconditional — anonymous public requests ontktspace-webmust NOT carry the anon Supabase key onAuthorization, otherwise the backend treats them as authenticated.PlatformStorage(token Injectable). AbstractslocalStorageandcookieaccess behind an interface, with a no-op server implementation. Supabase SDK’sauth.storageoption points to this service. On the server it returnsnull/undefinedfor every lookup; on the client it delegates to reallocalStorage.AuthHintCookieService. Sets/clears thetktspace-auth-hintcookie on sign-in/sign-out events. The SSR layer reads it via Angular Universal’s request context to render the correct nav bar variant.AuthGuard(CanActivate). On the server: redirects unconditionally to/sign-in?returnUrl=...if the route is protected — server has no real session knowledge. On the client: checksAuthService.currentUser(). After hydration completes, the client guard re-evaluates and either keeps the page or redirects.
These shapes are described here so Phase B / C engineering tickets have an anchor; the precise injection tokens and file paths are an implementation detail to be resolved during build.
Decision 3 — Contract gap reconciliation
Section titled “Decision 3 — Contract gap reconciliation”The local contracts/client.openapi.yaml is the source of truth for the
client surface. Dev backend currently exposes 45 paths; local contract
exposes ~12. This ticket brings the local contract up to cover every
path the web build needs (AC-33 through AC-42) and intentionally OMITS
paths the spec lists as out of scope.
Rule — security: blocks come from runtime smoke, not from dev OpenAPI
Section titled “Rule — security: blocks come from runtime smoke, not from dev OpenAPI”The dev OpenAPI export’s security annotations are misaligned with
runtime (spec Risks). Concretely the dev export reports security: null
for nearly every path — and runtime smoke shows 401 on most of them.
The local contract’s security: block is set from the runtime smoke
table below, NOT from the dev export.
Runtime-smoke table (anonymous against https://api.dev.tktspace.co/api/client)
Section titled “Runtime-smoke table (anonymous against https://api.dev.tktspace.co/api/client)”| Path | Method | Anon HTTP | Final contract security | Source DTO |
|---|---|---|---|---|
/activities | GET | 401* | [] (public, target) | ActivityPreviewClientResponseDto (array) |
/activities/{id} | GET | 401* | [] (public, target) | ActivityDetailClientResponseDto |
/activities/{activityId}/sessions | GET | 401 | Bearer | SessionAvailableClientResponseDto (array) |
/companies/{companyId}/activities | GET | 401 | Bearer | ActivityPreviewClientResponseDto (array) |
/companies/{companyId}/activities/{id} | GET | 401 | Bearer | ActivityDetailClientResponseDto |
/companies/{companyId}/activities/{activityId}/sessions | GET | 401 | Bearer | SessionAvailableClientResponseDto (array) |
/companies/{companyId}/my-bookings | GET | 401 | Bearer | CustomerBookingClientResponseDto (array) |
/companies/{id} | GET | 401** | [] (public, target) | CompanyResponseDto |
/companies/{companyId}/me | GET | 401 | Bearer | CustomerProfileDto |
/companies/{companyId}/payment-settings | GET | 401 | Bearer | PaymentSettingsResponseDto (array) |
/companies/{companyId}/sessions/{sessionId}/bookings | POST | 401 | Bearer | req CreateClientBookingDto / res CreateBookingResponseDto |
/companies/{companyId}/bookings | POST | 401 | Bearer | req CreateClientBookingDto / res CreateBookingResponseDto |
/companies/{companyId}/bookings/{bookingId}/pay | POST | 401 | Bearer | BookingRecordDto |
/companies/{companyId}/bookings/{bookingId}/cancel | POST | 401 | Bearer | CancelBookingResponseDto |
/companies/{companyId}/bookings/{bookingId} | DELETE | 401 | Bearer | CancelBookingResponseDto |
/me | GET | 401 | Bearer | UserMeDto |
/me | PATCH | 401 | Bearer | req UpdateMeDto / res UserMeDto |
/me/avatar | POST | 401 | Bearer | multipart file: binary / res UserMeDto |
/me/notification-preferences | PATCH | 401 | Bearer | req UpdateNotificationPrefsDto / res UserMeDto |
/me/wallet | GET | 401 | Bearer | WalletSummaryItemDto (array) |
/me/wallet/upcoming | GET | 401 | Bearer | UpcomingActivityGroupDto (array) |
/me/wallet/{companyId} | GET | 401 | Bearer | CompanyWalletDetailDto |
/me/wallet/{companyId}/transactions | GET | 401 | Bearer | WalletTransactionsResponseDto |
/me/wallet/{companyId}/top-up | POST | 401 | Bearer | req WalletTopUpDto / res TopUpResponseDto |
/me/notifications | GET | 401 | Bearer | paginated NotificationDto |
/me/notifications/unread-count | GET | 401 | Bearer | UnreadCountDto |
/me/notifications/{id}/read | PATCH | 401 | Bearer | (no body) |
/me/notifications/read-all | PATCH | 401 | Bearer | (no body) |
/payments/{id} | GET | 401 | Bearer | PaymentResponseDto |
/whitelabel-apps/{id}/config | GET | 404*** | [] (public) | WhitelabelAppConfigDto |
Footnotes:
- * AC-43 dependency.
/activitiesand/activities/{id}smoke as 401 on dev today because the backend guard removal (AC-43) has not shipped. The contract still declares them as public — the contract is the target state. The contract merge and the backend guard removal must both land before web build starts (see Decision 4). - **
/companies/{id}runtime smoke. Anonymous request returns 401 today. The spec (AC-36) declares this endpoint public. The contract declaressecurity: []; the backend dev MUST add@Public()toCompaniesClientController.findOneAND project the response to the narrowedCompanyResponseDto(noemail, noownerId). See Decision 1 “Field-visibility rule” above. This is no longer an open question — the prescription is fixed. - ***
/whitelabel-apps/{id}/configsmoke. Anonymous request returns 404 (not 401), which is the standard signal that auth is NOT required (resource not found). Declared as public. Corroborated by source reading: the controller carries@Public()at the class level (seetktspace-backend/libs/features/companies/src/lib/controllers/whitelabel-apps-client.controller.ts), so the public access is intentional and stable — not an artefact of the missing-resource path.
DTO drift policy
Section titled “DTO drift policy”When the dev OpenAPI export and the backend main-branch source
disagree, resolve in favor of the dev shape, because the web client
will be hitting the dev backend at integration time (spec Risks). The
drift items found during this ADR:
-
CreateClientBookingDto.sessionIdfield. Dev shape has an optionalsessionId: stringon the body. The local contract addssessionIdas an optional property. See “M4 —sessionIdduplication semantics” below for the full prescription. -
PaymentSettingsResponseDtofield set (BLOCKER 3 — resolved). Re-read oftktspace-backend/libs/features/payments/src/lib/services/payment-settings.service.tsshows the client-callablefindAllWithoutPagination(companyId?)method (lines 124+) does NOT join the per-platform settings tables (liqpay,mono) at all. The earlier draft framed missingliqpayPublicKeyas a “secret strip” P0; in reality the secret columns never reach the wire because the service does not query them. The real issue is the opposite: the public key the web checkout needs to render the LiqPay widget is also absent.Prescription for the backend ticket implementing AC-36:
- In
PaymentSettingsService.findAllWithoutPagination(or whatever the exact client-controller-called list method is named when this ADR lands — backend dev MUST verify the exact method name inpayment-settings.service.ts), LEFT JOIN theliqpaytable onpayment_settings.idand projectliqpay.publicKeyonto each row’sliqpayPublicKeyfield whenplatform = 'liqpay'ANDpayment_settings.active = true. Forplatform = 'mono'rows (orliqpayrows where noliqpaysettings record exists yet),liqpayPublicKeyisnull. - The
monosettings table is NOT joined on the client surface. The contract carries no Mono public-key field; Mono client-side onboarding does not need one. - Do not add
liqpayPrivateKeyormonoTokento the client DTO. The contract does not name them; they live exclusively on the business surface.
This downgrades the earlier “P0 if not stripped” framing — there is no live leak today, only a missing JOIN. The narrower contract projection is documented inline in
contracts/client.openapi.yamlPaymentSettingsResponseDto schema. - In
M4 — CreateClientBookingDto.sessionId duplication semantics
Section titled “M4 — CreateClientBookingDto.sessionId duplication semantics”The body schema declares sessionId as an OPTIONAL property because
the body-shape booking endpoint
POST /companies/{companyId}/bookings requires it on the body. On the
path-shape variant
POST /companies/{companyId}/sessions/{sessionId}/bookings the URL
is authoritative and the body value is ignored.
Backend rule (authoritative — backend dev MUST follow this):
- When both the URL path parameter
sessionIdand the bodysessionIdare present, the URL value WINS. The body value is ignored without error. - When neither is present (body-shape endpoint called with no
body
sessionId), the backend returns400witherrors.bookings.session_required.
Web build implication:
- The web booking-create call SHOULD use the path-shape endpoint
(
POST /companies/{companyId}/sessions/{sessionId}/bookings) and MAY omitsessionIdfrom the JSON body to avoid the duplication. - If at codegen time the generated body type for the path-shape
endpoint is materially cleaner without
sessionId, the web dev agent MAY introduce a separate request DTO (e.g.CreateClientBookingPathBodyDto = Omit<CreateClientBookingDto, 'sessionId'>) and switch the path-shaperequestBodyschema to that. This is a small follow-up choice the dev agent owns — but document it in the same PR.
M5 — Cross-field validation of CreateClientBookingDto is backend-only
Section titled “M5 — Cross-field validation of CreateClientBookingDto is backend-only”The contract carries NO schema-level enforcement of the cross-field
rules between paymentMethod, customerEntitlementId, extras,
extrasPaymentMethod, and resultUrl (the “PASS with extras not
covering all extras requires extrasPaymentMethod” rule and similar).
Those rules are enforced by the backend and return 422 with the
specific errors.* codes listed in the spec / response schemas.
Client responsibilities:
- The web feature dev (Phase B/C) owns client-side validation that mirrors the backend rules (so the user is not bounced by a 422 after submit). This is form-level UX work, not contract work.
- Client validation MUST be defensive: even when the form passes, the backend may still return 422 (e.g. an entitlement was used elsewhere). The web layer renders the i18n key carried in the error response.
MINOR — CreateBookingResponseDto.required rename
Section titled “MINOR — CreateBookingResponseDto.required rename”The dev backend currently returns the wallet “required amount” on a
property named required. That literal name collides with the JSON
Schema required keyword and trips at least one of our codegen
tools (and trips human reviewers). The contract renames the field to
requiredAmount; the backend serialiser MUST emit it under the new
name on the client surface. The business surface (if it ever exposes
this response shape) may diverge.
Out-of-scope paths intentionally NOT mirrored
Section titled “Out-of-scope paths intentionally NOT mirrored”Per spec “Out of scope” section:
POST /payments,GET /payments(admin-style list / create),POST /payments/webhook,POST /payments/mono-webhook— webhooks are server-to-server, the list/create endpoints are not used by the web flows in scope.POST /me/device-tokens,DELETE /me/device-tokens/{token}— mobile-only (web has no push).GET /companies/{companyId}/activities/{activityId}/my-bookings— per-activity booking history not needed by the web profile/orders page.
These paths exist on the dev backend but are intentionally omitted from the local contract. If a follow-up ticket needs them, it must amend the contract first.
Decision 4 — Phase sequencing for /build
Section titled “Decision 4 — Phase sequencing for /build”The spec mandates that Phase A (contract amendment AC-33–AC-44) merges
to main before web implementation begins. This ADR codifies the gate:
- Phase A (this ADR + the contract patch on this branch
adr/web-mobile-logic-port) must land onmainfirst. - Backend AC-43 (the
@Public()decorators on the two activity handlers andCompaniesClientController.findOne, plus the one-lineClientJwtGuardchange to honourIS_PUBLIC_KEY— see Decision 1) can land in parallel with the contract MR. Backend Phase B/C (controller patch + service projection / LEFT JOIN) may start in parallel with the contract MR; it does not need the contract merged first because backend reads the spec / ADR directly. - Web Phase B/C must NOT start until ALL of the following are
true. The parent
/builddriver MUST verify each one explicitly (do not start any web work on an assumption):- (a) Contract MR is merged into
mainof this repo. - (b) Backend AC-43 MR is merged into
mainoftktspace-backendAND deployed toapi.dev.tktspace.coby the CD pipeline. - (c) Anonymous runtime smoke against the dev backend returns:
curl -s -o /dev/null -w "%{http_code}" https://api.dev.tktspace.co/api/client/activities# expected: 200curl -s -o /dev/null -w "%{http_code}" https://api.dev.tktspace.co/api/client/activities/00000000-0000-0000-0000-000000000000# expected: 200 or 404 — but NOT 401curl -s -o /dev/null -w "%{http_code}" https://api.dev.tktspace.co/api/client/companies/00000000-0000-0000-0000-000000000000# expected: 200 or 404 — but NOT 401
/builddriver MUST HALT the web build and surface the deployment gap (which gate failed + what HTTP code was returned). DO NOT proceed with placeholder workarounds. - (a) Contract MR is merged into
- Mobile client regen is a follow-up ticket (spec Follow-ups). It
is mechanical (
melos run sync:spec && melos run generate:api) but it does require this contract to be merged tomainfirst. It does not block web build start.
Out-of-scope confirmations
Section titled “Out-of-scope confirmations”This ADR does NOT decide:
- Any UI / page layout / Taiga component selection for web (Phase B / C concern).
- Specific Angular module boundary, file naming, or DI tokens (Phase B / C concern).
- i18n key extraction process or tooling (Phase B / C concern).
- Mobile app changes beyond the mechanical client regen (out of scope per spec).
- Backend changes other than (i) applying
@Public()to the twoActivitiesGlobalClientControlleractivity handlers andCompaniesClientController.findOne, (ii) the one-lineClientJwtGuardchange to honourIS_PUBLIC_KEY, and (iii) the/companies/{id}PII projection (Decision 1) and/payment-settingsliqpayLEFT JOIN (Decision 3). No new business logic, no new endpoints. - Database schema changes (none required).
- Rate-limiting implementation (out of scope per spec; tracked as separate infra ticket).
- Payment gateway changes (existing LiqPay / wallet flows unchanged).
- Cookie domain /
SameSite/ CSP policy details for the auth hint cookie (Phase B / C concern; defaults proposed above are starting points only).
Open questions left to dev agents
Section titled “Open questions left to dev agents”@supabase/ssrvs.@supabase/supabase-js. Decision 2 assumes we use@supabase/supabase-js(the JS SDK that matches what gym_app does in Dart). If during Phase B the team discovers that@supabase/ssrgives a materially better DX even under the “anonymous SSR default” strategy, that is a freedom for the web-dev agent to take — but stay within Decision 2’s spirit (server-side rendering is anonymous; no SSR-cached HTML carries per-user data).
End state expected after this ADR + contract patch
Section titled “End state expected after this ADR + contract patch”adrs/web-mobile-logic-port.mdexists onmain.contracts/client.openapi.yamldeclares every in-scope path with the runtime-verifiedsecurity:block.- A backend ticket tracks the AC-43 work described in Decision 1:
@Public()on the two activity handlers and onCompaniesClientController.findOne; one-lineClientJwtGuardchange to honourIS_PUBLIC_KEY; PII projection on the client-surface company response (dropemail/ownerId); LEFT JOIN against theliqpaysettings table on the client-surface payment-settings list (Decision 3). - The web build pipeline (Phase B and onwards) can regenerate the Angular API client from the merged contract and consume every path the spec calls out.
STATUS: READY_FOR_REVIEW