Skip to content

ADR: web-mobile-logic-port

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 Authorization header 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:

PathReason it is public
GET /spheresPlatform discovery metadata (already public).
GET /categoriesPlatform catalogue metadata (already public).
GET /categories/{id}Same.
GET /activitiesNEW — 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}/configSupabase 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) drops email and ownerId from both properties and required. The business surface keeps them for operator workflows.
  • The backend CompaniesService.findOne currently 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.findOne must use the @Public() decorator (the class has no class-level guard — only the global JwtAuthGuard which honours @Public()). The client controller MUST project the companies.* row to drop email and ownerId before serialising to the wire. If a shared CompaniesService method 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 to GET /api/client/activities).
  • ActivitiesGlobalClientController.findOne (the @Get(':id') mapped to GET /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.canActivate to read IS_PUBLIC_KEY via Reflector and short-circuit to true when the metadata is set, mirroring the global guard’s pattern; OR
  • pull the two public method handlers out of ActivitiesGlobalClientController into 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 isFavorited is always false. 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.

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)”
Section titled “Choice: hybrid — client-only Supabase session with cookie-backed”

access-token hint for SSR-rendered authenticated pages.

In practice this means:

  1. 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.
  2. Client hydration resolves the Supabase session from localStorage (Supabase JS SDK default). After hydration, an HTTP interceptor attaches Authorization: Bearer <jwt> to outgoing API calls and the authenticated UI surfaces (profile button, heart, etc.) appear.
  3. 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.

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=Lax
    • Secure (HTTPS-only; the production and dev frontends are HTTPS)
    • HttpOnly absent (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 in localStorage)
    • Max-Age=604800 (7 days — matches Supabase’s default refresh-token lifetime; rotated on every TOKEN_REFRESHED event so it never expires while the user is active)
    • Domain left unset. The browser uses the host of the issuing page, which is correct for the single-host deployment of tktspace-web. If a future spec adds a cross-subdomain product, that spec must amend the Domain policy.
  • Rotation rules.
    • On Supabase onAuthStateChange event SIGNED_IN: set the cookie.
    • On TOKEN_REFRESHED: re-issue the cookie with a fresh Max-Age.
    • On SIGNED_OUT (and on USER_DELETED, if it ever fires): delete the cookie by setting Max-Age=0 with the same name + Path so the deletion is unambiguous.
  • Read paths.
    • SSR (Angular Universal Express middleware): parse req.headers.cookie via cookie.parse (the cookie npm package, already used by Express). Render the nav variant based on the parsed value.
    • Client TS: read via document.cookie (parsed with the same cookie library 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 exports parseAuthHint(rawCookieHeaderOrDocumentCookie: string): boolean and serializeAuthHint(present: boolean, maxAgeSeconds: number): string. Both server and client import this util — no duplicate parsing logic.
  • 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.
  • (a) Cookie-based Supabase session via @supabase/ssr helpers. 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 on tktspace-web are 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_app does 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.

The chosen hybrid captures (c)‘s simplicity while patching (c)‘s single visible UX wart.

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 HttpClient request issued during server rendering must hit a path with security: [] in contracts/client.openapi.yaml. No protected endpoint may be called from a server-side HttpClient.
  • The AuthInterceptor MUST short-circuit on the server. Concretely, the interceptor injects PLATFORM_ID and checks isPlatformServer(platformId); when true, it skips the Authorization header 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 read localStorage, document.cookie, or window directly during SSR. AuthService exposes an SSR-safe token getter that returns null on the server. The PlatformStorage token (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 Authorization header, a server-side HttpClient call, 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 (isPlatformServer true) init() is a no-op — no Supabase SDK call, no localStorage access. On the client, init() calls createClient(SUPABASE_URL, SUPABASE_ANON_KEY) and subscribes to onAuthStateChange.
  • AuthInterceptor (HTTP interceptor). Reads AuthService.accessToken(). If present, sets Authorization: Bearer <token>. If absent, sends the request unmodified. Bearer attachment is conditional, not unconditional — anonymous public requests on tktspace-web must NOT carry the anon Supabase key on Authorization, otherwise the backend treats them as authenticated.
  • PlatformStorage (token Injectable). Abstracts localStorage and cookie access behind an interface, with a no-op server implementation. Supabase SDK’s auth.storage option points to this service. On the server it returns null/undefined for every lookup; on the client it delegates to real localStorage.
  • AuthHintCookieService. Sets/clears the tktspace-auth-hint cookie 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: checks AuthService.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)”
PathMethodAnon HTTPFinal contract securitySource DTO
/activitiesGET401*[] (public, target)ActivityPreviewClientResponseDto (array)
/activities/{id}GET401*[] (public, target)ActivityDetailClientResponseDto
/activities/{activityId}/sessionsGET401BearerSessionAvailableClientResponseDto (array)
/companies/{companyId}/activitiesGET401BearerActivityPreviewClientResponseDto (array)
/companies/{companyId}/activities/{id}GET401BearerActivityDetailClientResponseDto
/companies/{companyId}/activities/{activityId}/sessionsGET401BearerSessionAvailableClientResponseDto (array)
/companies/{companyId}/my-bookingsGET401BearerCustomerBookingClientResponseDto (array)
/companies/{id}GET401**[] (public, target)CompanyResponseDto
/companies/{companyId}/meGET401BearerCustomerProfileDto
/companies/{companyId}/payment-settingsGET401BearerPaymentSettingsResponseDto (array)
/companies/{companyId}/sessions/{sessionId}/bookingsPOST401Bearerreq CreateClientBookingDto / res CreateBookingResponseDto
/companies/{companyId}/bookingsPOST401Bearerreq CreateClientBookingDto / res CreateBookingResponseDto
/companies/{companyId}/bookings/{bookingId}/payPOST401BearerBookingRecordDto
/companies/{companyId}/bookings/{bookingId}/cancelPOST401BearerCancelBookingResponseDto
/companies/{companyId}/bookings/{bookingId}DELETE401BearerCancelBookingResponseDto
/meGET401BearerUserMeDto
/mePATCH401Bearerreq UpdateMeDto / res UserMeDto
/me/avatarPOST401Bearermultipart file: binary / res UserMeDto
/me/notification-preferencesPATCH401Bearerreq UpdateNotificationPrefsDto / res UserMeDto
/me/walletGET401BearerWalletSummaryItemDto (array)
/me/wallet/upcomingGET401BearerUpcomingActivityGroupDto (array)
/me/wallet/{companyId}GET401BearerCompanyWalletDetailDto
/me/wallet/{companyId}/transactionsGET401BearerWalletTransactionsResponseDto
/me/wallet/{companyId}/top-upPOST401Bearerreq WalletTopUpDto / res TopUpResponseDto
/me/notificationsGET401Bearerpaginated NotificationDto
/me/notifications/unread-countGET401BearerUnreadCountDto
/me/notifications/{id}/readPATCH401Bearer(no body)
/me/notifications/read-allPATCH401Bearer(no body)
/payments/{id}GET401BearerPaymentResponseDto
/whitelabel-apps/{id}/configGET404***[] (public)WhitelabelAppConfigDto

Footnotes:

  • * AC-43 dependency. /activities and /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 declares security: []; the backend dev MUST add @Public() to CompaniesClientController.findOne AND project the response to the narrowed CompanyResponseDto (no email, no ownerId). See Decision 1 “Field-visibility rule” above. This is no longer an open question — the prescription is fixed.
  • *** /whitelabel-apps/{id}/config smoke. 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 (see tktspace-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.

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:

  1. CreateClientBookingDto.sessionId field. Dev shape has an optional sessionId: string on the body. The local contract adds sessionId as an optional property. See “M4 — sessionId duplication semantics” below for the full prescription.

  2. PaymentSettingsResponseDto field set (BLOCKER 3 — resolved). Re-read of tktspace-backend/libs/features/payments/src/lib/services/payment-settings.service.ts shows the client-callable findAllWithoutPagination(companyId?) method (lines 124+) does NOT join the per-platform settings tables (liqpay, mono) at all. The earlier draft framed missing liqpayPublicKey as 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 in payment-settings.service.ts), LEFT JOIN the liqpay table on payment_settings.id and project liqpay.publicKey onto each row’s liqpayPublicKey field when platform = 'liqpay' AND payment_settings.active = true. For platform = 'mono' rows (or liqpay rows where no liqpay settings record exists yet), liqpayPublicKey is null.
    • The mono settings 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 liqpayPrivateKey or monoToken to 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.yaml PaymentSettingsResponseDto schema.

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 sessionId and the body sessionId are 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 returns 400 with errors.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 omit sessionId from 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-shape requestBody schema 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:

  1. Phase A (this ADR + the contract patch on this branch adr/web-mobile-logic-port) must land on main first.
  2. Backend AC-43 (the @Public() decorators on the two activity handlers and CompaniesClientController.findOne, plus the one-line ClientJwtGuard change to honour IS_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.
  3. Web Phase B/C must NOT start until ALL of the following are true. The parent /build driver MUST verify each one explicitly (do not start any web work on an assumption):
    • (a) Contract MR is merged into main of this repo.
    • (b) Backend AC-43 MR is merged into main of tktspace-backend AND deployed to api.dev.tktspace.co by 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: 200
      curl -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 401
      curl -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
    If any of (a)–(c) is not satisfied, the /build driver MUST HALT the web build and surface the deployment gap (which gate failed + what HTTP code was returned). DO NOT proceed with placeholder workarounds.
  4. 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 to main first. It does not block web build start.

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 two ActivitiesGlobalClientController activity handlers and CompaniesClientController.findOne, (ii) the one-line ClientJwtGuard change to honour IS_PUBLIC_KEY, and (iii) the /companies/{id} PII projection (Decision 1) and /payment-settings liqpay LEFT 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).

  1. @supabase/ssr vs. @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/ssr gives 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.md exists on main.
  • contracts/client.openapi.yaml declares every in-scope path with the runtime-verified security: block.
  • A backend ticket tracks the AC-43 work described in Decision 1: @Public() on the two activity handlers and on CompaniesClientController.findOne; one-line ClientJwtGuard change to honour IS_PUBLIC_KEY; PII projection on the client-surface company response (drop email / ownerId); LEFT JOIN against the liqpay settings 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