ADR: Customer ticket verify foundation (#14a)
ADR — Customer ticket verify foundation (#14a)
Section titled “ADR — Customer ticket verify foundation (#14a)”Context
Section titled “Context”From the spec Goal: “Ship a customer-visible proof-of-purchase QR
(rotating, server-signed token) plus the matching verify endpoint, so an
authorised system can validate a booking at the venue gate and transition
it to a new CHECKED_IN status.” From Background: today there is no
client-visible verification mechanism — the booking-detail view (rendered
inside _BookingCard in apps/gym_app/lib/pages/my_bookings/my_bookings_page.dart
rather than in a dedicated detail page) shows status but no proof
artifact a gate could scan.
This ticket is the foundation half of the ticket-verify arc.
Follow-up #14b introduces a new /api/scanner/* surface, scanner
credential CRUD in the business panel, and a new Flutter scanner_app.
None of that lives here. To keep #14a end-to-end testable without a 4th
OpenAPI contract, the verify endpoint lands temporarily on
/api/business/* so existing admin tokens can drive the e2e replay
test; #14b moves it to /api/scanner/verify with a deprecation period.
Precedent: 869dr5gj6 shipped BookingsGlobalClientController @ me/bookings — the cross-company list at GET /api/client/me/bookings,
authed via ClientJwtGuard, scoped by companyCustomers.userId = $userId. The new “issue verify token” endpoint sits naturally on this
controller as a sibling member route. 869d5knx4 shipped the
cross-user isolation pattern that AC-2 tests against. 869dpxbj6 /
migration 0049 established the text-cast → DROP TYPE → CREATE → cast-
back enum-rebuild template; migration 0050 copies that shape.
Decisions
Section titled “Decisions”D1 — Issue-token endpoint placement: extend BookingsGlobalClientController
Section titled “D1 — Issue-token endpoint placement: extend BookingsGlobalClientController”GET /api/client/me/bookings/:id/verify-token is added to the existing
BookingsGlobalClientController at
tktspace-backend/libs/features/activities/src/lib/controllers/bookings-global-client.controller.ts
(controller path me/bookings, mounted via ActivitiesClientModule at
/api/client/*).
Considered alternatives:
- (a) Chosen — add to
BookingsGlobalClientController(sibling ofbookingsClientListMineGlobal). Operation id:bookingsClientIssueVerifyToken. - (b) Add to per-company
ActivitiesClientControllernext to the per-companybookingsClientListMine(this is what predated 869dr5gj6). - (c) New
BookingsVerifyClientControlleratme/bookings/:id/verify-token.
Why (a): the endpoint is a cross-company member route on a booking
identified solely by UUID — the caller doesn’t carry a companyId. The
auth + tenancy story is exactly the one
BookingsGlobalClientController already implements (resolve userId via
companyCustomers.userId across all companies). Putting it on the
per-company controller (b) would force a redundant :companyId path
param the mobile client doesn’t have at the call site (the
MyTicketPage only knows bookingId). Splitting into a brand-new
controller (c) buys nothing — one extra file for one extra route, with
duplicate @ApiExtraModels and guard setup.
The new endpoint reuses ActivitiesClientService for the booking
lookup (findBookingByIdForUser(userId, bookingId) — to be added to
the same service alongside findMyBookingsGlobal) and delegates token
construction to the new BookingVerifyTokenService (see D3).
D2 — Verify endpoint placement: new BookingsVerifyAdminController
Section titled “D2 — Verify endpoint placement: new BookingsVerifyAdminController”POST /api/business/bookings/verify is added as a new controller:
BookingsVerifyAdminController (controller path bookings,
@Post('verify')), registered in activities-admin.module.ts.
Guard model: the controller carries no controller-level
@UseGuards(...). Auth is enforced by the global JwtAuthGuard
(APP_GUARD), which selects the admin-jwt Passport strategy
automatically based on the /api/business/* URL prefix
(apps/api/src/app/guards/jwt-auth.guard.ts:43-47). This matches the
verified precedent at
libs/features/activities/src/lib/controllers/contributor-reviews-global-admin.controller.ts:30
— that controller likewise has no class-level guard and relies entirely
on the global guard + URL-prefix strategy selection. (Note: no
AdminJwtGuard class exists in the auth lib; the only guards exported
from libs/features/auth/src/lib/guards/index.ts are JwtAuthGuard and
ClientJwtGuard.)
Considered alternatives:
- (a) Chosen — new
BookingsVerifyAdminControlleratbookings,@Post('verify'), no controller-level guard. Operation id:bookingsAdminVerify. - (b) Add
@Post('verify')to existingBookingsAdminController(controller@Controller(), paths nested per-route).
Why (a): BookingsAdminController is decorated with
@UseGuards(CompanyRolesGuard) at the class level and every route
applies a @RequirePermissions(...) decorator. The verify endpoint
intentionally omits CompanyRolesGuard — it has fundamentally
different auth semantics for #14a: “any authenticated admin user can
verify, the company is derived from the booking, NOT from
X-Company-Id” because the scanner stand-in (admin token) is operating
at a physical gate where it may not have the company context resolved
client-side. There is also no company-scoped role to require — the
real scanner-credential model lands in #14b. Co-locating on
BookingsAdminController would force inheriting CompanyRolesGuard +
inventing a @RequirePermissions(...) arg that doesn’t fit the verify
model. A new controller keeps the guard surface clean (global guard
only) and in #14b this controller is what gets renamed / moved under
/api/scanner/* — keeping it isolated makes that follow-up a
single-file relocation rather than a surgical extraction from a
multi-route file.
Tenancy note: the verify service derives the companyId from the
booking (booking.session.activity.companyId) and trusts the HMAC
signature as the auth artefact. The admin token in #14a confirms “this
is a logged-in operator”; the HMAC confirms “this booking actually
exists and the customer just issued this token.” #14b tightens this by
making the scanner JWT carry a companyId claim and asserting it
matches the booking’s company (the multi-tenancy boundary).
D3 — HMAC signing service location & shape
Section titled “D3 — HMAC signing service location & shape”A new shared library is created:
tktspace-backend/libs/shared/booking-verify-token/
(targeted import path @tktspace/shared/booking-verify-token). It
exports BookingVerifyTokenModule providing BookingVerifyTokenService
with the public API:
sign(input: { bookingId: string; ttlSec?: number }): { token: string; iat: number; exp: number };verify(token: string): { bookingId: string; iat: number; exp: number }; // throws BookingVerifyTokenError on malformed / expired / bad-HMAC.ttlSec defaults to 30. sessionId is intentionally not in the
payload — the verify path in D6 keys exclusively off bookingId (the
UPDATE bookings WHERE id = $1 AND status = 'CONFIRMED' doesn’t
consult session). Carrying sid would be signed dead weight. #14b
can reintroduce session-level claims if the scanner ecosystem grows
a per-session gate check.
Considered alternatives:
- (a) Chosen — new
libs/shared/booking-verify-token/. Used by the client controller (sign) AND the admin controller (verify). Both live inlibs/features/activities/, so a sibling underfeatures/would also work, but the service has zero domain knowledge (no DB, no Drizzle, no booking shape — just bytes + secret + clock), soshared/is the right home. Mirrors the placement ofshared/common,shared/storage, etc. - (b) Co-locate inside
libs/features/activities/src/lib/services/asbooking-verify-token.service.ts. Rejected: #14b’s scanner surface will also need the verify half. Keeping it inshared/meansfeatures/scannercan depend on it without a cross-feature import.
Token shape: compact, JWT-like but explicitly NOT JWT (no alg
header — no algorithm negotiation, no none attack surface).
<header_b64>.<payload_b64>.<signature_b64> where each segment is
base64url(no-padding):
header_b64 = base64url('{"v":1}')— version sentinel for future rotation; not used for algorithm selection.payload_b64 = base64url(JSON.stringify({ bid, iat, exp }))wherebidis a UUID andiat/expare epoch seconds.signature_b64 = base64url(HMAC-SHA256(secret, header_b64 + '.' + payload_b64)).
verify() reconstructs the signing input and compares with
crypto.timingSafeEqual (constant-time). Returns the payload on
success; throws BookingVerifyTokenError with one of the discriminated
reasons MALFORMED | BAD_SIGNATURE | EXPIRED so the controller can map
to the right HTTP code (D6).
Secret: BOOKING_VERIFY_SIGNING_SECRET — new env var. Loaded via
ConfigService.getOrThrow<string>('BOOKING_VERIFY_SIGNING_SECRET') in
the service constructor (fail-fast at boot if missing in non-test envs;
test env provides a fixed string fixture). Added to
tktspace-backend/.env.example with a generation hint
(# openssl rand -base64 48).
TTL: exp = iat + 30. refreshIn = 25_000 ms returned in the
issue response — mobile uses the server’s expiresAt to schedule
the next fetch (R1 mitigation).
D4 — DB migration: columns on bookings + enum extension
Section titled “D4 — DB migration: columns on bookings + enum extension”Per OQ-1 (resolved): Option A — columns on bookings, not a
separate booking_checkins audit table. Migration 0050_<auto>.sql,
generated by drizzle-kit generate after editing
libs/shared/data-access-db/src/lib/schema/bookings.schema.ts.
Schema edits:
// bookings.schema.ts (preview)export const BookingStatusEnum = bookingsSchema.enum('booking_status', [ 'PENDING', 'CONFIRMED', 'CANCELLED', 'REFUNDED', 'PENDING_PAYMENT', 'CHECKED_IN', // NEW]);
export const bookings = bookingsSchema.table('bookings', { // ... existing columns ... checkedInAt: timestamp('checked_in_at'), // NEW, nullable verifierUserId: uuid('verifier_user_id').references(() => users.id), // NEW, nullable});Plus a mirroring users import in the bookings schema (users.users.id
is already the FK target for verifier_user_id).
The TS-side enum at
libs/features/activities/src/lib/dto/update-booking.dto.ts must be
extended in lockstep with 'CHECKED_IN' (current 5-value mirror
described in spec §Current state).
Migration shape (mirrors 0049 pattern):
-- Pre-flight assertion (defensive — no rows should already be CHECKED_IN-- since the value doesn't exist yet, but lock against e.g. a manual SQL-- experiment).DO $$BEGIN IF (SELECT count(*) FROM bookings.bookings WHERE status::text NOT IN ('PENDING','CONFIRMED','CANCELLED','REFUNDED','PENDING_PAYMENT')) > 0 THEN RAISE EXCEPTION 'ticket-verify-foundation: unexpected booking_status value present'; END IF;END $$;
-- Enum rebuild (text-cast → DROP → CREATE → cast-back)ALTER TABLE "bookings"."bookings" ALTER COLUMN "status" SET DATA TYPE text;DROP TYPE "bookings"."booking_status";CREATE TYPE "bookings"."booking_status" AS ENUM( 'PENDING','CONFIRMED','CANCELLED','REFUNDED','PENDING_PAYMENT','CHECKED_IN');ALTER TABLE "bookings"."bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING'::"bookings"."booking_status";ALTER TABLE "bookings"."bookings" ALTER COLUMN "status" SET DATA TYPE "bookings"."booking_status" USING "status"::"bookings"."booking_status";
-- New columnsALTER TABLE "bookings"."bookings" ADD COLUMN "checked_in_at" timestamp;ALTER TABLE "bookings"."bookings" ADD COLUMN "verifier_user_id" uuid REFERENCES "users"."users"("id");No data UPDATEs needed — the two new columns are nullable and existing status values are preserved by the text-cast round-trip. PG wraps the whole file in one transaction (drizzle-orm migrator) so a partial failure rolls back.
Index decision: no new index on checked_in_at or
verifier_user_id for #14a. Foreseen access patterns are
single-row-by-id reads (verify) and WHERE bookings.id = $1 exact
lookups (token-issue). verifier_user_id analytics (“how many tickets
did operator X verify last week”) arrive in #14b once scanner
credentials produce meaningful values; add the index then.
D5 — DTOs
Section titled “D5 — DTOs”Three new DTOs, all decorated per the OpenAPI authoring rules
(@ApiProperty, format: 'uuid' on UUID strings, format: 'date-time'
on ISO timestamps, enumName on the status enum reference).
export class BookingVerifyTokenClientResponseDto { @ApiProperty({ description: 'Signed verify token. Opaque to client.' }) token: string;
@ApiProperty({ format: 'date-time', description: 'ISO timestamp when the token stops validating (iat + 30s).' }) expiresAt: string;
@ApiProperty({ type: 'integer', description: 'Milliseconds until the mobile client should fetch the next token. ~25_000.' }) refreshIn: number;}
// libs/features/activities/src/lib/dto/booking-verify-admin.request.dto.tsexport class BookingVerifyAdminRequestDto { @ApiProperty({ description: 'Token presented at the gate (string body field, NOT a header).' }) @IsString() @IsNotEmpty() token: string;}
// libs/features/activities/src/lib/dto/booking-verify-admin.response.dto.tsexport class BookingVerifyAdminResponseDto { @ApiProperty({ format: 'uuid' }) bookingId: string; @ApiProperty({ enum: BookingStatus, enumName: 'BookingStatus' }) status: BookingStatus; @ApiProperty({ format: 'date-time' }) checkedInAt: string; @ApiProperty({ format: 'uuid' }) verifierUserId: string; @ApiProperty({ type: BookingActivityDto }) activity: BookingActivityDto; // reuse from 869dr5gj6 @ApiProperty({ type: BookingSessionDto }) session: BookingSessionDto; @ApiProperty({ type: BookingCompanyDto }) company: BookingCompanyDto;}Both controllers carry @ApiExtraModels(...) listing the new top-level
DTOs so ng-openapi-gen / swagger_dart_code_generator emit them as named
types instead of inlining anonymously.
D6 — Error semantics
Section titled “D6 — Error semantics”GET /api/client/me/bookings/:id/verify-token:
| Status | Reason | Body message key |
|---|---|---|
| 200 | happy path | — |
| 404 | booking not found OR not owned by caller (single shape — 869d5knx4 isolation pattern: do not distinguish “not yours” from “not exists”) | errors.bookings.not_found |
| 409 | booking in non-eligible status: CHECKED_IN, CANCELLED, REFUNDED, PENDING_PAYMENT, PENDING (AC-3 enumerates CHECKED_IN / CANCELLED / REFUNDED explicitly; PENDING / PENDING_PAYMENT collapse to the same code since neither is CONFIRMED) | errors.bookings.not_eligible_for_verify |
POST /api/business/bookings/verify:
| Status | Reason | Body message key |
|---|---|---|
| 200 | happy path — booking transitioned to CHECKED_IN | — |
| 400 | malformed token / invalid HMAC / expired token | errors.bookings.verify_token_invalid |
| 400 | non-verifiable status (CANCELLED / REFUNDED / PENDING / PENDING_PAYMENT) | errors.bookings.not_verifiable_status |
| 404 | tokenpayload’s bookingId doesn’t exist anymore (defensive) | errors.bookings.not_found |
| 409 | already CHECKED_IN (replay within TTL — AC-6) | errors.bookings.already_checked_in |
Body shape mirrors the existing pattern (CheckConstraintExceptionFilter):
{ statusCode, error, message }. No new global filter is needed —
the controller throws NotFoundException / ConflictException /
BadRequestException from @nestjs/common and the default exception
filter renders the right shape.
Status mutation atomicity: the verify service runs the eligibility
check and the CONFIRMED → CHECKED_IN UPDATE inside a single
transaction with UPDATE … WHERE id = $1 AND status = 'CONFIRMED' RETURNING *. If the RETURNING is empty, another verifier already
flipped the row — re-read and return 409. This is the foundation-level
anti-replay guarantee (the row-level lock prevents two simultaneous
verifies from both winning).
D7 — Mobile route: nested under existing per-activity drilldown
Section titled “D7 — Mobile route: nested under existing per-activity drilldown”New route in apps/gym_app/lib/router/app_router.dart:
GoRoute( path: '/my-bookings/:companyId/:activityId/ticket/:bookingId', builder: (_, state) => MyTicketPage( companyId: state.pathParameters['companyId']!, activityId: state.pathParameters['activityId']!, bookingId: state.pathParameters['bookingId']!, ),),Considered alternatives:
- (a) Chosen — nest under existing
/my-bookings/:companyId/:activityId(sibling of the per-activity list). Matches the spec §Mobile bullet “registered inapp_router.dart. Nested under the existing per-activity drilldown”. - (b) Top-level
/tickets/:bookingId— shorter URL but disconnected from the existing booking-list breadcrumb; back-stack restoration in the cold-start hydration unpark (ADR cross-company-me-bookings §D5) is harder to reason about. - (c) Top-level
/my-bookings/:bookingId/ticket— collides with the existing/my-bookingscross-company list.
Why (a): the entry point is the _BookingCard widget, which
already knows companyId + activityId from the page that holds it.
The nested path makes router-level back navigation Just Work
(pop returns to the per-activity bookings list).
MyTicketPage (new file apps/gym_app/lib/pages/my_bookings/my_ticket_page.dart)
is a StatefulWidget. initState fetches the booking detail (re-using
fetchMyBookingsPaginated filtered by bookingId is overkill — call
the new bookingsClientIssueVerifyToken directly; the page only needs
title + start time which the booking-card caller already has and can
pass via constructor / extra). Timer.periodic(refreshIn ms) calls
the issue endpoint again; result drives a QrImageView from
qr_flutter. Timer is cancelled in dispose. App-lifecycle
handling (pause timer while backgrounded): implement with
WidgetsBindingObserver.didChangeAppLifecycleState — pause on
paused/inactive, resume + immediate fetch on resumed. This
addresses an iOS background-task limitation (Timer fires would queue
otherwise) and matches the spec §Mobile bullet.
D8 — Mobile entry point: button on _BookingCard
Section titled “D8 — Mobile entry point: button on _BookingCard”Modify _BookingCard in my_bookings_page.dart to render a “Show
Ticket” button (i18n key bookings#show_ticket) only when
booking.status == 'CONFIRMED'. Hidden for PENDING,
PENDING_PAYMENT, CANCELLED, REFUNDED, and CHECKED_IN.
Inline _StatusBadge arm for CHECKED_IN. The existing
_StatusBadge switch (my_bookings_page.dart:386-394) falls through
to the _ branch and renders bookings#status_pending for any
unknown status — including the new CHECKED_IN after codegen. Fold
the fix in here rather than deferring: add a new switch arm
BookingStatusEnum.CHECKED_IN => (icon: <check-circle>, text: 'bookings#status_checked_in'.tr()) and add the new i18n key
bookings#status_checked_in to the CSV emit at task end.
_BookingCard constructor plumbing. The current _BookingCard
constructor (booking, colors, onCancel) has no access to
companyId / activityId, but the context.go(...) URL needs both.
Extend the constructor with two new required params:
companyId: String + activityId: String. The parent
MyBookingsPage already knows both values via its own constructor
(verified at app_router.dart:266-268) and threads them through at
the _BookingCard(...) construction site.
Button onTap → context.go('/my-bookings/${companyId}/${activityId}/ticket/${booking.id}').
The button sits inside the existing card column, below the
date/time/price row and above the existing “Cancel” link, so the
existing if (!isCancelled && booking.activity.refundable != false)
cancel-link block does not need to move. The “Show Ticket” CTA is
rendered as a primary button (centred, full-width minus padding) since
it is the headline action for confirmed bookings — visually outranks
the cancel link, matches the spec intent (“proof-of-purchase QR”).
Cross-company list entry point (NIT-6 note, deferred). The new
“Show Ticket” CTA lives only on the per-activity _BookingCard. The
cross-company “all my bookings” list (869dr5gj6) currently drills into
the per-activity view, so users still reach the ticket page in two
taps. Adding a direct CTA on the cross-company card is deferred to a
future iteration — revisit if usage telemetry shows the extra tap is a
friction point.
Note on shared package: no changes to packages/ui. qr_flutter
is added directly to apps/gym_app/pubspec.yaml (not packages/ui)
because (a) only gym_app displays customer QRs today, (b) tickets_app
will gain a customer QR in its own #14b-adjacent ticket if/when it
needs one and can add the dep then, (c) shared-package additions
trigger melos bootstrap churn for every app — not worth the cost for
one widget callsite. Confirmed spec §Mobile bullet “QR rendering:
QrImageView from qr_flutter”.
Files to touch (concrete list)
Section titled “Files to touch (concrete list)”Backend (tktspace-backend/)
Section titled “Backend (tktspace-backend/)”New files:
libs/shared/booking-verify-token/src/index.ts(barrel)libs/shared/booking-verify-token/src/lib/booking-verify-token.module.tslibs/shared/booking-verify-token/src/lib/booking-verify-token.service.tslibs/shared/booking-verify-token/src/lib/booking-verify-token.error.tslibs/shared/booking-verify-token/project.json+tsconfig.json(Nx scaffold)libs/features/activities/src/lib/controllers/bookings-verify-admin.controller.tslibs/features/activities/src/lib/services/bookings-verify.service.tslibs/features/activities/src/lib/dto/booking-verify-token-client.response.dto.tslibs/features/activities/src/lib/dto/booking-verify-admin.request.dto.tslibs/features/activities/src/lib/dto/booking-verify-admin.response.dto.tslibs/shared/data-access-db/migrations/0050_<drizzle-name>.sql(auto)- Plus the matching
0050_<drizzle-name>.jsonsnapshot +_journal.jsonbump.
Edits:
libs/shared/data-access-db/src/lib/schema/bookings.schema.ts— addCHECKED_INto enum, addchecked_in_at+verifier_user_idcolumns.libs/features/activities/src/lib/dto/update-booking.dto.ts— mirror enum extension on the TS side.libs/features/activities/src/lib/controllers/bookings-global-client.controller.ts— add@Get(':id/verify-token')route + extend@ApiExtraModelswithBookingVerifyTokenClientResponseDto.libs/features/activities/src/lib/services/activities-client.service.ts(or the existingbookings-client.service.ts— Phase B picks; both are imported by the controller’s module) — addfindBookingByIdForUser(userId, bookingId).libs/features/activities/src/lib/activities-admin.module.ts— registerBookingsVerifyAdminController+BookingsVerifyService, importBookingVerifyTokenModule.libs/features/activities/src/lib/activities-client.module.ts— importBookingVerifyTokenModule(used by the issue endpoint).tktspace-backend/.env.example— addBOOKING_VERIFY_SIGNING_SECRETwith generation hint.tsconfig.base.json— add path mapping for@tktspace/shared/booking-verify-token.
Mobile (tktspace-mobile-app/apps/gym_app/)
Section titled “Mobile (tktspace-mobile-app/apps/gym_app/)”New files:
lib/pages/my_bookings/my_ticket_page.dart— the newMyTicketPagewidget (StatefulWidget +WidgetsBindingObserver).
Edits:
pubspec.yaml— addqr_flutter: ^4.1.0(pin minor; spec says^4.x).lib/router/app_router.dart— register the new/my-bookings/:companyId/:activityId/ticket/:bookingIdroute.lib/pages/my_bookings/my_bookings_page.dart—- Add “Show Ticket” button to
_BookingCard; visibility guardbooking.status == 'CONFIRMED'. - Extend
_BookingCardconstructor withcompanyId: String+activityId: String; pass both through fromMyBookingsPageat the construction site. - Add
BookingStatusEnum.CHECKED_INarm to_StatusBadgeswitch (my_bookings_page.dart:386-394) renderingbookings#status_checked_in.
- Add “Show Ticket” button to
- i18n CSV additions —
bookings#show_ticket,bookings#status_checked_in, plus themy_ticket_pagekeys (emitted at task end per workflow memory).
Contracts (_workflow/contracts/)
Section titled “Contracts (_workflow/contracts/)”client.openapi.yaml— add path/me/bookings/{id}/verify-token(GET), new componentBookingVerifyTokenClientResponseDto, enum valueCHECKED_INonBookingStatusif it’s referenced.business.openapi.yaml— add path/bookings/verify(POST), new componentsBookingVerifyAdminRequestDto+BookingVerifyAdminResponseDto, enum valueCHECKED_IN.
Both YAMLs are produced by npm run sync:contracts against the running
backend in Phase C — not hand-edited.
Consumer regen
Section titled “Consumer regen”tktspace-mobile-app:melos run sync:spec(canonical YAML) +melos run generate:api— emits the newBookingVerifyTokenClientResponseDtoon the Dart side; the issue endpoint and theCHECKED_INenum value land in the generatedApiClient.
Test plan (per AC)
Section titled “Test plan (per AC)”Backend e2e (apps/api-e2e/):
- AC-1 —
GET /api/client/me/bookings/:id/verify-tokenfor a seeded CONFIRMED booking → 200, response shape matchesBookingVerifyTokenClientResponseDto. Decodepayload_b64, assertbid/iat/expand thatHMAC(secret, header.payload)=== signature. - AC-2 — same endpoint with a CLIENT JWT for user B against user A’s booking → 404 (single shape, isolation pattern 869d5knx4).
- AC-3 — same endpoint against booking in each of {
CHECKED_IN,CANCELLED,REFUNDED} → 409 witherrors.bookings.not_eligible_for_verify. - AC-4 —
POST /api/business/bookings/verifywith a fresh token → 200 + booking summary; assert DB row:status='CHECKED_IN',checked_in_at IS NOT NULL,verifier_user_id = <admin's userId>. - AC-5 — same endpoint with: tampered signature → 400;
base64-broken token → 400; token with
exp < now()(fixture clock shift) → 400; valid token for CANCELLED booking → 400errors.bookings.not_verifiable_status. - AC-6 — verify token T → 200; immediately verify T again within
TTL → 409
errors.bookings.already_checked_in. Then advance fixture clock pastexp→ 400 (expired). Status row state asserted unchanged between the 409 and the 400. - AC-7 — migration test: load fresh DB at 0049 head → run 0050 →
assert
\dT+ bookings.booking_statusincludesCHECKED_IN, new columns present onbookings.bookings, existing rows unaffected. - AC-9 —
npm run sync:contracts:checkexit 0 in CI after the backend MR.
Mobile (apps/gym_app/):
- AC-8 — widget test pumps
MyTicketPage(bookingId: <fixture>)with a mockedApiClient. First fetch returns{token: 'AAA', expiresAt: <now+30s>, refreshIn: 25_000}. AssertQrImageViewis present with data'AAA'. Advance fake timer by 25s → assert a second fetch is issued, QR data updates to the new token. Cancel screen → assert timer is cancelled (no further fetches). - AC-11 —
flutter build apk --debugexit 0 for thetktspaceflavor.
Rollout
Section titled “Rollout”- Additive only. No breaking change. Existing booking-status
consumers (web, business panel, scanner-stand-in admin tokens) will
encounter
CHECKED_INvalues only on bookings that have actually been verified — which doesn’t happen until the mobile change + the admin verify endpoint both ship. So consumers that haven’t yet refreshed their codegen will not see the new enum value in the wild. - Env-var required.
BOOKING_VERIFY_SIGNING_SECRETMUST be set in every backend environment (dev, staging, prod) before deploy. Add to deploy checklist; backend boots withgetOrThrowso a missing secret fails the readiness probe loudly. - No feature flag — the endpoint is dormant until the mobile build ships; until then no client calls it. The verify endpoint can be exercised by any admin (no opt-in), but until any client issues tokens there are no tokens to verify.
- No backfill needed. Existing bookings keep
checked_in_at = NULL/verifier_user_id = NULLas the canonical pre-verify state. - Deprecation marker for #14b: the verify endpoint gets an
@ApiOperationdescription that includes “TEMPORARY LOCATION — will move to POST /api/scanner/verify in #14b” so anyone reading the Swagger UI knows this isn’t the long-term home.
- R1 — Clock skew between mobile device and server. TTL is 30s. If
the device’s wall clock drifts ≥15s from server time, locally-scheduled
refreshes fire after
expand every QR is born expired. Mitigation: the mobile auto-refresh timer uses the server’sexpiresAtfrom the previous response (treat it asexpiresAt - now() = 30s) and schedulesrefreshIn = max(5_000, expiresAt - now() - 5_000) ms. This decouples refresh cadence from local-clock drift entirely; only the very first fetch is “blind” (usesrefreshInfrom the response directly). - R2 — Token replay within 30s window. A photographed / screenshotted
QR can be verified by anyone within 30s. The D6 row-level
UPDATE … WHERE status = 'CONFIRMED' RETURNING *blocks second-verifies on the same booking (409), but if the attacker presents the captured token before the legitimate customer, the attacker wins. Acceptable for #14a foundation. Full anti-replay (one-time token nonce store, e.g. Redis SET NX with 30s expiry on the HMAC hash) is deferred to #14b alongside the scanner ecosystem, where the threat model is concrete (gate operator vs. opportunistic attacker). - R3 — Signing-secret rotation. If
BOOKING_VERIFY_SIGNING_SECRETleaks, every in-flight token is forgeable. Mitigation: rotation is non-breaking — issue a new secret, deploy backend, in-flight tokens (≤30s old) fail with 400 on verify, mobile clients re-fetch transparently within 25s. No customer-visible disruption beyond a single QR refresh cycle. Document the rotation procedure in_workflow/docs/(one-line ops runbook) at deploy time. - R4 — Rate-limit on verify endpoint (deferred to #14b).
POST /api/business/bookings/verifyhas no per-IP / per-token rate limit in #14a. The HMAC + 30-second TTL + row-levelWHERE status = 'CONFIRMED'UPDATE already neutralise brute-force forgery (signature bytes are 256-bit) and replay (R2). Per-credential rate limiting becomes meaningful in #14b when each scanner has its own credential and abuse can be attributed to a specific actor. Tracked there.
Notes on deferred nits (not risks):
- Drizzle migration
statement-breakpointseparators between SQL statements: cosmetic,drizzle-kit generateemits them automatically. Not authored by hand.
Open questions
Section titled “Open questions”None — all three from the spec resolved (OQ-1 columns, OQ-2 poll-every-25s,
OQ-3 qr_flutter ^4.x).
STATUS: READY_FOR_REVIEW