Skip to content

ADR: Customer ticket verify foundation (#14a)

ADR — Customer ticket verify foundation (#14a)

Section titled “ADR — Customer ticket verify foundation (#14a)”

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.

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 of bookingsClientListMineGlobal). Operation id: bookingsClientIssueVerifyToken.
  • (b) Add to per-company ActivitiesClientController next to the per-company bookingsClientListMine (this is what predated 869dr5gj6).
  • (c) New BookingsVerifyClientController at me/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 BookingsVerifyAdminController at bookings, @Post('verify'), no controller-level guard. Operation id: bookingsAdminVerify.
  • (b) Add @Post('verify') to existing BookingsAdminController (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 in libs/features/activities/, so a sibling under features/ would also work, but the service has zero domain knowledge (no DB, no Drizzle, no booking shape — just bytes + secret + clock), so shared/ is the right home. Mirrors the placement of shared/common, shared/storage, etc.
  • (b) Co-locate inside libs/features/activities/src/lib/services/ as booking-verify-token.service.ts. Rejected: #14b’s scanner surface will also need the verify half. Keeping it in shared/ means features/scanner can 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 })) where bid is a UUID and iat/exp are 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 columns
ALTER 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.

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

libs/features/activities/src/lib/dto/booking-verify-token-client.response.dto.ts
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.ts
export 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.ts
export 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.

GET /api/client/me/bookings/:id/verify-token:

StatusReasonBody message key
200happy path
404booking not found OR not owned by caller (single shape — 869d5knx4 isolation pattern: do not distinguish “not yours” from “not exists”)errors.bookings.not_found
409booking 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:

StatusReasonBody message key
200happy path — booking transitioned to CHECKED_IN
400malformed token / invalid HMAC / expired tokenerrors.bookings.verify_token_invalid
400non-verifiable status (CANCELLED / REFUNDED / PENDING / PENDING_PAYMENT)errors.bookings.not_verifiable_status
404tokenpayload’s bookingId doesn’t exist anymore (defensive)errors.bookings.not_found
409already 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 in app_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-bookings cross-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”.

New files:

  • libs/shared/booking-verify-token/src/index.ts (barrel)
  • libs/shared/booking-verify-token/src/lib/booking-verify-token.module.ts
  • libs/shared/booking-verify-token/src/lib/booking-verify-token.service.ts
  • libs/shared/booking-verify-token/src/lib/booking-verify-token.error.ts
  • libs/shared/booking-verify-token/project.json + tsconfig.json (Nx scaffold)
  • libs/features/activities/src/lib/controllers/bookings-verify-admin.controller.ts
  • libs/features/activities/src/lib/services/bookings-verify.service.ts
  • libs/features/activities/src/lib/dto/booking-verify-token-client.response.dto.ts
  • libs/features/activities/src/lib/dto/booking-verify-admin.request.dto.ts
  • libs/features/activities/src/lib/dto/booking-verify-admin.response.dto.ts
  • libs/shared/data-access-db/migrations/0050_<drizzle-name>.sql (auto)
  • Plus the matching 0050_<drizzle-name>.json snapshot + _journal.json bump.

Edits:

  • libs/shared/data-access-db/src/lib/schema/bookings.schema.ts — add CHECKED_IN to enum, add checked_in_at + verifier_user_id columns.
  • 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 @ApiExtraModels with BookingVerifyTokenClientResponseDto.
  • libs/features/activities/src/lib/services/activities-client.service.ts (or the existing bookings-client.service.ts — Phase B picks; both are imported by the controller’s module) — add findBookingByIdForUser(userId, bookingId).
  • libs/features/activities/src/lib/activities-admin.module.ts — register BookingsVerifyAdminController + BookingsVerifyService, import BookingVerifyTokenModule.
  • libs/features/activities/src/lib/activities-client.module.ts — import BookingVerifyTokenModule (used by the issue endpoint).
  • tktspace-backend/.env.example — add BOOKING_VERIFY_SIGNING_SECRET with 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 new MyTicketPage widget (StatefulWidget + WidgetsBindingObserver).

Edits:

  • pubspec.yaml — add qr_flutter: ^4.1.0 (pin minor; spec says ^4.x).
  • lib/router/app_router.dart — register the new /my-bookings/:companyId/:activityId/ticket/:bookingId route.
  • lib/pages/my_bookings/my_bookings_page.dart
    • Add “Show Ticket” button to _BookingCard; visibility guard booking.status == 'CONFIRMED'.
    • Extend _BookingCard constructor with companyId: String + activityId: String; pass both through from MyBookingsPage at the construction site.
    • Add BookingStatusEnum.CHECKED_IN arm to _StatusBadge switch (my_bookings_page.dart:386-394) rendering bookings#status_checked_in.
  • i18n CSV additions — bookings#show_ticket, bookings#status_checked_in, plus the my_ticket_page keys (emitted at task end per workflow memory).
  • client.openapi.yaml — add path /me/bookings/{id}/verify-token (GET), new component BookingVerifyTokenClientResponseDto, enum value CHECKED_IN on BookingStatus if it’s referenced.
  • business.openapi.yaml — add path /bookings/verify (POST), new components BookingVerifyAdminRequestDto + BookingVerifyAdminResponseDto, enum value CHECKED_IN.

Both YAMLs are produced by npm run sync:contracts against the running backend in Phase C — not hand-edited.

  • tktspace-mobile-app: melos run sync:spec (canonical YAML) + melos run generate:api — emits the new BookingVerifyTokenClientResponseDto on the Dart side; the issue endpoint and the CHECKED_IN enum value land in the generated ApiClient.

Backend e2e (apps/api-e2e/):

  • AC-1GET /api/client/me/bookings/:id/verify-token for a seeded CONFIRMED booking → 200, response shape matches BookingVerifyTokenClientResponseDto. Decode payload_b64, assert bid/iat/exp and that HMAC(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 with errors.bookings.not_eligible_for_verify.
  • AC-4POST /api/business/bookings/verify with 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 → 400 errors.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 past exp → 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_status includes CHECKED_IN, new columns present on bookings.bookings, existing rows unaffected.
  • AC-9npm run sync:contracts:check exit 0 in CI after the backend MR.

Mobile (apps/gym_app/):

  • AC-8 — widget test pumps MyTicketPage(bookingId: <fixture>) with a mocked ApiClient. First fetch returns {token: 'AAA', expiresAt: <now+30s>, refreshIn: 25_000}. Assert QrImageView is 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-11flutter build apk --debug exit 0 for the tktspace flavor.
  • Additive only. No breaking change. Existing booking-status consumers (web, business panel, scanner-stand-in admin tokens) will encounter CHECKED_IN values 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_SECRET MUST be set in every backend environment (dev, staging, prod) before deploy. Add to deploy checklist; backend boots with getOrThrow so 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 = NULL as the canonical pre-verify state.
  • Deprecation marker for #14b: the verify endpoint gets an @ApiOperation description 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 exp and every QR is born expired. Mitigation: the mobile auto-refresh timer uses the server’s expiresAt from the previous response (treat it as expiresAt - now() = 30s) and schedules refreshIn = max(5_000, expiresAt - now() - 5_000) ms. This decouples refresh cadence from local-clock drift entirely; only the very first fetch is “blind” (uses refreshIn from 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_SECRET leaks, 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/verify has no per-IP / per-token rate limit in #14a. The HMAC + 30-second TTL + row-level WHERE 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-breakpoint separators between SQL statements: cosmetic, drizzle-kit generate emits them automatically. Not authored by hand.

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