Skip to content

ADR: Scanner ecosystem (P1 mobile #14b)

“Provision dedicated scanner devices with long-lived device credentials (separate from human admin accounts), reachable via a new 4th API surface /api/scanner/*, integrated with a purpose-built Flutter scanner_app, with company-scoped tenancy enforced at the credential level.” — specs/scanner-ecosystem.md §1

Direct continuation of #14a (869drv9gw, merged 2026-06-18). #14a delivered the customer-side QR + BookingVerifyTokenService (sign/verify), the CHECKED_IN status invariant, and the audit column bookings.verifier_user_id (migration 0050_skinny_deathbird.sql). The token plumbing is unchanged — this ticket only moves the HTTP wrapper from admin-jwt//api/business/* to scanner-jwt//api/scanner/* and adds the credential + device-app stack around it.

24 ACs; all 10 OQs locked in spec §13. Locked decisions (not re-litigated here): globally-unique login, one-time-reveal 16-char base32 password, bcrypt cost 12, mobile_scanner Flutter lib, new scanner Flutter flavor, settings/scanners/ business UI umbrella, DB-row refresh tokens, Verifier Option A (nullable column + CHECK exactly-one).

D1. Scanner surface plumbing (4th surface)

Section titled “D1. Scanner surface plumbing (4th surface)”

Four touchpoints, all additive — no migrations of existing surfaces required.

  1. apps/api/src/app/app.module.ts:88 — append { path: 'scanner', module: ScannerAuthModule } and { path: 'scanner', module: BookingsScannerModule } to the RouterModule.register([...]) block. Global prefix api (set at main.ts:42) auto-prepends → /api/scanner/*.
  2. apps/api/src/main.ts — add a 4th Swagger block mirroring the existing three (lines 53–119): DocumentBuilder (title TKTSpace — Scanner API, addBearerAuth()), SwaggerModule.createDocument(app, cfg, { include: [ScannerAuthModule, BookingsScannerModule] }), app.use('/api/scanner/openapi.json', …), app.use('/api/scanner/docs', apiReference({…})). Add a logger.log('Scanner Docs: …') line in the boot summary.
  3. libs/features/auth/src/lib/guards/jwt-auth.guard.ts:43-47 — extend the URL-prefix ternary to a 4-way chain:
    const strategyName = req.url?.startsWith('/api/superadmin') ? 'superadmin-jwt'
    : req.url?.startsWith('/api/business') ? 'admin-jwt'
    : req.url?.startsWith('/api/scanner') ? 'scanner-jwt'
    : 'client-jwt';
  4. tktspace-backend/scripts/sync-contracts.ts:54-58 — append { name: 'scanner', routePath: '/api/scanner/openapi.json', outFile: 'scanner.openapi.yaml' } to ALL_SURFACES. This births _workflow/contracts/scanner.openapi.yaml on first npm run sync:contracts run.

D2. ScannerJwtStrategy — first non-Supabase strategy in the repo

Section titled “D2. ScannerJwtStrategy — first non-Supabase strategy in the repo”

All three existing strategies (client-jwt, admin-jwt, superadmin-jwt) extend the custom SupabaseStrategy base in libs/features/auth/src/lib/strategies/jwt.strategy.ts because their tokens come from external Supabase projects. Scanner credentials are NOT Supabase users — they’re DB rows with bcrypt password hashes, and tokens are signed by the backend itself. So:

libs/features/auth/src/lib/strategies/scanner-jwt.strategy.ts
@Injectable()
export class ScannerJwtStrategy extends PassportStrategy(Strategy, 'scanner-jwt') {
// Strategy from 'passport-jwt' — NOT SupabaseStrategy.
}

JWT payload (access token, 7-day TTL — exp - iat == 604800, locked spec §4 + AC-4 / OQ-7):

{ sub: string /* scanner_credential.id */, login: string, companyId: string,
kind: 'scanner', iat: number, exp: number }

validate() must DB-look-up the credential on every request to honour revocation: SELECT by sub, reject if is_active = false or row missing. This per-request isActive check is what makes revocation instantaneous regardless of access-token TTL — flipping is_active=false in DB invalidates outstanding tokens on the very next request. This is slightly more expensive than Supabase strategies (which trust the upstream JWT verifier) but is required because we control revocation locally — no second IDP. Cache-by-id with a 30s TTL is acceptable optimisation later; out of scope here.

Signing secret: new env var SCANNER_JWT_SECRET (256-bit random). Distinct from any Supabase keys. Rotation playbook in §5.

Schema namespace decision: the codebase uses flat pgTable(...) exports per file (bookings.schema.ts, users.schema.ts, etc.). Migration 0050 uses "bookings"."bookings" syntax, implying Drizzle pgSchema('bookings') namespacing elsewhere. We pick a new file scanner.schema.ts exported via libs/shared/data-access-db/src/lib/schema/index.ts, using pgSchema('scanner') to mirror the pattern. The new schema groups both scanner_credentials and scanner_refresh_tokens (D4) into a tidy namespace, future-proofing for scanner-side audit / event log tables.

libs/shared/data-access-db/src/lib/schema/scanner.schema.ts
export const scannerSchema = pgSchema('scanner');
export const scannerCredentials = scannerSchema.table('scanner_credentials', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id').notNull().references(() => companies.id, { onDelete: 'cascade' }),
login: varchar('login', { length: 64 }).notNull(),
passwordHash: text('password_hash').notNull(), // bcrypt cost 12
label: varchar('label', { length: 128 }), // e.g. "Main entrance — kiosk 1"
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
createdBy: uuid('created_by').references(() => users.id), // admin who provisioned
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
}, (t) => ({
loginUnique: uniqueIndex('scanner_credentials_login_unique').on(t.login),
companyIdx: index('scanner_credentials_company_idx').on(t.companyId),
activeIdx: index('scanner_credentials_active_idx').on(t.isActive).where(sql`is_active`),
}));

UNIQUE(login) is global per spec OQ-1 lock — and is also the sole authority on login uniqueness (see D8: login is admin-supplied, no server-side prefix scheme). Soft-delete via is_active=false + revokedAt; hard-delete on cascade with the company. Table physically resolves to scanner.scanner_credentials (matches spec AC-11 wording).

PATCH endpoints rely on Drizzle’s $onUpdate hook (updatedAt column above) to auto-bump the timestamp on every UPDATE — no Postgres trigger needed. Without $onUpdate, defaultNow() fires only on INSERT and updated_at would equal created_at forever.

Server-revocable refresh tokens (locked spec decision). Stored as SHA-256 hashes, not bcrypt — they’re long random tokens (32 bytes base64url ≈ 256 bits entropy), not user-chosen passwords, so a fast HMAC is correct: bcrypt would add 100 ms latency per refresh for no security gain. Rotation: on POST /auth/refresh we delete the row and write a new one (single-use).

export const scannerRefreshTokens = scannerSchema.table('scanner_refresh_tokens', {
id: uuid('id').primaryKey().defaultRandom(),
credentialId: uuid('credential_id').notNull().references(() => scannerCredentials.id, { onDelete: 'cascade' }),
tokenHash: char('token_hash', { length: 64 }).notNull(), // sha256 hex
deviceLabel: varchar('device_label', { length: 128 }), // optional, set on login
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), // createdAt + 90d
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
}, (t) => ({
hashIdx: uniqueIndex('scanner_refresh_tokens_hash_unique').on(t.tokenHash),
credentialIdx:index('scanner_refresh_tokens_credential_idx').on(t.credentialId),
expiresIdx: index('scanner_refresh_tokens_expires_idx').on(t.expiresAt),
}));

Table physically resolves to scanner.scanner_refresh_tokens (matches spec AC-11 wording style). TTL 90 days; a daily Bull cron prunes expired rows (added as a follow-up if not in scope here — spec §10 leaves this in operational hygiene).

D5. bookings.verifier_scanner_credential_id (Option A)

Section titled “D5. bookings.verifier_scanner_credential_id (Option A)”

#14a added bookings.verifier_user_id (migrations/0050_skinny_deathbird.sql:36-37). The doc comment in that migration explicitly anticipates this: “verifier_user_id — user id of the operator (admin in #14a, scanner in #14b)”. Per spec OQ-7 lock, we pick Option A — separate nullable column + CHECK exactly-one (not Option B “union column with discriminator”):

ALTER TABLE "bookings"."bookings"
ADD COLUMN "verifier_scanner_credential_id" uuid;
ALTER TABLE "bookings"."bookings"
ADD CONSTRAINT "bookings_verifier_scanner_credential_id_fk"
FOREIGN KEY ("verifier_scanner_credential_id")
REFERENCES "scanner"."scanner_credentials"("id") ON DELETE SET NULL;
ALTER TABLE "bookings"."bookings"
ADD CONSTRAINT "bookings_verifier_exactly_one"
CHECK (
checked_in_at IS NULL OR
(
(verifier_user_id IS NOT NULL AND verifier_scanner_credential_id IS NULL) OR
(verifier_user_id IS NULL AND verifier_scanner_credential_id IS NOT NULL)
)
);

Reads as: “checked-in row (i.e. checked_in_at IS NOT NULL) has exactly one verifier set; not-yet-checked-in row may have neither.” Predicate gates on checked_in_at IS NULL (timestamp-based, per spec AC-13) rather than status <> 'CHECKED_IN' — the timestamp column is the canonical “have we been checked in?” signal and avoids enum coupling.

Rationale: two FK columns let us preserve referential integrity to two disjoint tables (users vs scanner.credentials). Discriminator column would require loose typing or polymorphic joins.

MethodPathoperationIdAuth
POST/api/scanner/auth/loginscannerAuthLoginpublic
POST/api/scanner/auth/refreshscannerAuthRefreshpublic (RT)
POST/api/scanner/auth/logoutscannerAuthLogoutscanner-jwt

Lives in libs/features/auth/src/lib/scanner-auth/: scanner-auth.module.ts, scanner-auth.controller.ts, scanner-auth.service.ts, DTOs (login.dto.ts, refresh.dto.ts, auth-response.dto.ts).

Responses: { accessToken, refreshToken, expiresIn: 604800, scanner: { id, login, companyId, label } } (expiresIn aligns with the 7-day access-token TTL locked in D2). Login failures: generic 401 Invalid credentials (no enumeration). Throttle: 10 attempts / 15 minutes / login (use @nestjs/throttler decorator).

@nestjs/throttler is NOT currently in backend deps (verified via tktspace-backend/package.json). This MR adds it: pnpm add @nestjs/throttler, wire ThrottlerModule.forRoot({ ttl: 900, limit: 10 }) globally in apps/api/src/app/app.module.ts, and apply @Throttle({ default: { limit: 10, ttl: 900_000 } })

  • a custom ThrottlerGuard keyed on login (not IP) to the scannerAuthLogin handler. See Files-to-touch §3.

Storage backend: default in-memory store is fine for the current single-replica deploy; multi-replica deploy will require @nestjs/throttler-storage-redis wired against the existing ioredis instance (see §6 R8).

  • NEW POST /api/scanner/bookings/verifyoperationId: scannerBookingsVerify. Lives at libs/features/bookings/src/lib/scanner/bookings-scanner.controller.ts inside a new BookingsScannerModule.
  • DELETE the temporary BookingsVerifyAdminController shipped in #14a (under libs/features/bookings/src/lib/admin/). Also drop its route registration from the admin RouterModule entries and from the business Swagger include: [...].
  • Request body unchanged from #14a: { token: string }.
  • Tenancy check (new, per spec §5): after decoding the booking via BookingVerifyTokenService.verify(token), assert req.user.companyId === booking.companyId. Mismatch → 403 Forbidden (NOT 404, to make cross-company misconfig debuggable).
  • Audit write: stamp verifier_scanner_credential_id = req.user.sub (not verifier_user_id).
MethodPathoperationId
POST/api/business/companies/:companyId/scannerscompanyScannerCredentialsCreate
GET/api/business/companies/:companyId/scannerscompanyScannerCredentialsList
PATCH/api/business/companies/:companyId/scanners/:idcompanyScannerCredentialsUpdate
DELETE/api/business/companies/:companyId/scanners/:idcompanyScannerCredentialsDelete

Lives at libs/features/companies/src/lib/scanners-admin/. Added to the existing CompaniesAdminModule’s controller list. Soft-delete via is_active=false + revokedAt; cascade-revoke all refresh tokens for the credential on DELETE.

Login format (admin-supplied, per spec OQ-6 lock + §5.2): the admin types the login directly in the create form. No server-side prefix scheme — the companies table has no slug column (verified: only name, email, ownerId, logoUrl, specialization, type, timestamps), so there is nothing to prefix with.

  • Validation: [a-z0-9-_]+, min 3 chars, max 60 chars (a class-validator @Matches(/^[a-z0-9_-]{3,60}$/)).
  • Uniqueness: enforced solely by the UNIQUE(login) constraint on scanner.scanner_credentials. On INSERT conflict (Postgres error code 23505 on the scanner_credentials_login_unique index), the controller returns 409 Conflict { code: 'SCANNER_LOGIN_TAKEN', login } and the UI surfaces an inline form error inviting the admin to pick another login.
  • No server-generated suffix, no retry loop, no slug join.

Create response (one-time): { id, login, password: '<16-char-base32>', companyId, label, createdAt }. The plaintext password is never returned again — UI shows the reveal-dialog once.

New umbrella client/src/app/features/dashboard/settings/:

settings/
├── settings-routing.module.ts # child route 'scanners' → ScannersComponent
├── settings.module.ts
├── settings.component.html|ts # umbrella shell with side-nav
└── scanners/
├── scanners.component.html|ts # list + "Add scanner" button
├── create-scanner-modal.component.ts # POST → reveal-password-dialog
└── reveal-password-dialog.component.ts # one-time plaintext display + copy

Routing: settings wired into dashboard-routing.module.ts as a sibling of activities, analytics, etc. Side-nav entry “Settings” in dashboard.component.html. Components use Taiga UI v5.4 primitives (tui-input, tui-button, tui-dialog). API access via the regenerated api.invoke(companyScannerCredentialsList) pattern (ng-openapi-gen v1).

New app at apps/scanner_app/, mirroring apps/gym_app/ minus brand-specific plumbing. Registered as a Melos package via the existing apps/* glob in melos.yaml (no manifest edit required). New flavor scanner configured in android/app/build.gradle + iOS schemes once flutter create --platforms=ios,android . seeds the native dirs.

pubspec.yaml — minimal deps (a strict subset of gym_app):

dependencies:
flutter:
sdk: flutter
tktspace_api: { path: ../../packages/api }
tktspace_core: { path: ../../packages/core }
tktspace_i18n: { path: ../../packages/i18n }
tktspace_ui: { path: ../../packages/ui }
mobile_scanner: ^5.0.0 # camera QR (locked spec choice)
flutter_secure_storage: ^9.2.0 # access/refresh token storage
go_router: ^17.0.0
chopper: ^8.0.0

Omits: tktspace_auth (Supabase, not relevant), tktspace_checkout, tktspace_notifications, tktspace_profile, tktspace_favorites, qr_flutter, firebase_core. Auth lives in-app (scanner auth is NOT Supabase) as lib/auth/scanner_auth_service.dart calling the regenerated tktspace_api client.

Router (go_router):

  • /login — LoginPage (login + password inputs, “remember device” toggle stores RT).
  • /scan — ScanPage (mobile_scanner view → POST verify → green tick / red cross).
  • /settings — minimal: server URL (dev/staging/prod toggle), sign out.

Auth-state-aware initial route (GoRouter.redirect): a top-level redirect callback runs on every navigation including the first frame. It reads flutter_secure_storage for a refresh-token entry:

  • RT present → navigate to /scan. The app fires a silent refresh (POST /api/scanner/auth/refresh) in parallel on app boot; on failure the refresh service clears storage and pushes /login.
  • RT absent → navigate to /login.

This gives a Supabase-style “stay signed in across launches” experience without exposing the AT to disk — only the long-lived rotating RT persists, AT lives in memory only.

Implementation note (async storage gotcha): flutter_secure_storage reads are async (Keychain on iOS, KeyStore on Android, 10-50 ms cold) but GoRouter.redirect is synchronous. App boot reads the RT once into a sync ValueNotifier<bool> _hasSession (or signals if already in repo). GoRouter.redirect consults the notifier; refreshListenable: _hasSession triggers a redirect after async boot completes. A splash screen (or MaterialApp.builder gate) shows a logo while the bool initialises. This avoids racing the first navigation against the secure-storage read.

iOS Podfile min-target (deferred NIT-4): mobile_scanner requires iOS deployment target ≥ 12. After flutter create --platforms=ios,android . seeds apps/scanner_app/ios/, verify Podfile line platform :ios, '12.0' (or higher). If flutter create defaults to a lower target the scanner_app dev should bump it and re-run pod install.

packages/api/ regeneration — multi-surface plan (NIT-3 resolved): The scanner_app consumes the existing tktspace_api package extended with scanner endpoints generated from scanner.openapi.yaml. We parametrise the existing sync:spec script via a SPEC_URL env var (default = the current client spec URL):

melos.yaml
scripts:
sync:spec:
description: Sync OpenAPI spec — uses SPEC_URL (default client surface) + SPEC_OUT (default swagger-api.json)
run: |
curl -fsSL "${SPEC_URL:-https://api.dev.tktspace.co/api/client/openapi.json}" \
> packages/api/lib/swagger/${SPEC_OUT:-swagger-api.json}.raw && \
dart scripts/normalize_spec.dart \
packages/api/lib/swagger/${SPEC_OUT:-swagger-api.json}.raw \
packages/api/lib/swagger/${SPEC_OUT:-swagger-api.json}.normalized && \
dart scripts/dart_compat_spec.dart \
packages/api/lib/swagger/${SPEC_OUT:-swagger-api.json}.normalized \
packages/api/lib/swagger/${SPEC_OUT:-swagger-api.json}

This preserves the two real post-fetch steps the current melos.yaml:128-133 pipeline runs (normalize_spec.dart + dart_compat_spec.dart) — a hypothetical single-shot wrapper would drop them and break codegen.

The scanner_app’s tooling step calls:

Terminal window
SPEC_URL=https://api.dev.tktspace.co/api/scanner/openapi.json \
SPEC_OUT=scanner-spec.json \
melos run sync:spec

Both swagger-api.json and scanner-spec.json end up in packages/api/lib/swagger/; build.yaml’s input_folder: "lib/swagger" picks both up, emitting two Chopper clients side-by-side (api_client.dart

  • scanner_api_client.dart). Resolves AC-23 (flutter build --flavor scanner green) without forking the package.

tktspace-backend (Nx + NestJS + Drizzle):

  • apps/api/src/app/app.module.ts — add 2 RouterModule entries.
  • apps/api/src/main.ts — add 4th Swagger block + boot logger.
  • libs/features/auth/src/lib/guards/jwt-auth.guard.ts — extend strategy ternary.
  • libs/features/auth/src/lib/strategies/scanner-jwt.strategy.ts (NEW).
  • libs/features/auth/src/lib/scanner-auth/ (NEW: module/controller/service/DTOs).
  • libs/features/bookings/src/lib/scanner/bookings-scanner.{module,controller,service}.ts (NEW).
  • libs/features/bookings/src/lib/admin/bookings-verify.admin.controller.ts (DELETE).
  • libs/features/companies/src/lib/scanners-admin/ (NEW: controller/service/DTOs).
  • libs/shared/data-access-db/src/lib/schema/scanner.schema.ts (NEW).
  • libs/shared/data-access-db/src/lib/schema/index.ts — export the new file.
  • libs/shared/data-access-db/src/lib/schema/bookings.schema.ts — add column + CHECK.
  • libs/shared/data-access-db/migrations/0051_*.sql (generated by drizzle-kit generate).
  • scripts/sync-contracts.ts — add 4th ALL_SURFACES entry.
  • package.json — add bcrypt + @types/bcrypt, passport-jwt (verify presence), and @nestjs/throttler (NEW — not currently in deps; required by D6 login throttle).
  • apps/api/src/app/app.module.ts — wire ThrottlerModule.forRoot({ ttl: 900, limit: 10 }) globally alongside the new RouterModule entries.
  • .env.exampleSCANNER_JWT_SECRET= placeholder.

tktspace-business:

  • client/src/app/features/dashboard/settings/** (NEW umbrella + scanners/).
  • client/src/app/features/dashboard/dashboard-routing.module.ts — register route.
  • client/src/app/features/dashboard/dashboard.component.html — side-nav entry.
  • client/src/app/core/api/** — regenerated via npm run sync:contracts.
  • i18n strings (en/uk/ru/de/fr) — emit CSV at task end.

tktspace-mobile-app:

  • apps/scanner_app/** (NEW Flutter app, mirror gym_app shell).
  • apps/scanner_app/pubspec.yaml (NEW, deps per D10).
  • apps/scanner_app/lib/router.dart — GoRouter with auth-state-aware redirect (RT presence → /scan, absent → /login).
  • packages/api/lib/swagger/scanner-spec.json (NEW — emitted by SPEC_URL=… SPEC_OUT=… melos run sync:spec).
  • packages/api/build.yaml — register 2nd spec input (scanner-spec.json).
  • melos.yaml — parametrise sync:spec via SPEC_URL / SPEC_OUT; add build/run scripts for scanner_app flavor.

_workflow:

  • contracts/scanner.openapi.yaml (NEW — written on first sync:contracts).
  • contracts/business.openapi.yaml (UPDATED — verify endpoint removed, scanner CRUD endpoints added).
  • contracts/client.openapi.yaml (UNCHANGED — client surface untouched).
ACVerify
AC1 4th surface livecurl http://localhost:5005/api/scanner/docs returns Scalar HTML
AC2 sync-contracts emits scanner.yamlnpm run sync:contracts && test -f _workflow/contracts/scanner.openapi.yaml
AC3 login happy-pathPOST /api/scanner/auth/login with seeded creds → 200 + access/refresh
AC4 access token TTL = 7dDecode AT JWT body, assert exp - iat === 604800
AC4b login throttled11th failed attempt within 15 min → 429 (@nestjs/throttler)
AC5 refresh rotatesPOST /auth/refresh with RT → new RT; replay old RT → 401
AC6 logout revokesPOST /auth/logout then any AT call → 401 after token expiry; RT row deleted
AC7 verify happyscanner-jwt → POST /api/scanner/bookings/verify with valid token → 200 CHECKED_IN
AC8 verify cross-companyscanner-jwt (company A) verify booking from company B → 403
AC9 verify audit columnAfter AC7, bookings.verifier_scanner_credential_id = scanner.id, user_id IS NULL
AC10 old admin verify removedPOST /api/business/bookings/verify → 404
AC11 CHECK exactly-onemanual DB insert setting both verifier cols → CHECK violation
AC12 admin create credentialPOST /companies/:id/scanners returns plaintext pwd ONCE
AC13 list never exposes pwdGET /companies/:id/scanners body has no password* field anywhere
AC14 update revokePATCH ... { isActive: false } → next login attempt 401
AC15 delete cascades RTsAfter DELETE, SELECT FROM scanner.refresh_tokens WHERE credential_id = x → 0 rows
AC16 login UNIQUE collisionTwo POSTs with identical admin-typed login → second returns 409 SCANNER_LOGIN_TAKEN
AC17 business UI list+createCypress: navigate settings/scanners → add scanner → reveal dialog → password copy works
AC18 business UI list pagingMock 30 rows → table paginates
AC19 scanner_app loginIntegration test: launches scanner flavor → login screen → RT cached securely (AT in memory only)
AC19b GoRouter initial redirectLaunch with seeded RT in flutter_secure_storage → lands on /scan; launch with empty storage → lands on /login
AC20 scanner_app scanMock camera frame with valid QR → success animation, verify call made
AC21 scanner_app offlineAirplane mode + scan → “Network unavailable” UX, no crash
AC22 scanner_app logoutLogout button → secure storage cleared → routed to /login
AC23 contracts/business.yaml updatedgit diff _workflow/contracts/business.openapi.yaml shows verify removed + CRUD added
AC24 contracts/scanner.yaml committedFile exists in _workflow/contracts/ with auth + verify operations

Rollout (single MR per repo, deployed in order):

  1. Backend MR ships D1-D8 + Drizzle migration 0051_*. Deploy to dev first; run npm run sync:contracts against dev to populate _workflow/contracts/scanner.openapi.yaml. CI gate sync:contracts:check will then enforce drift detection going forward.
  2. Business MR (D9) merges after step 1’s contract is committed to _workflow. Regenerates API client via npm run sync:contracts.
  3. Mobile MR (D10) — registers scanner_app in Melos, ships login + scan flows. Deployed as internal-only TestFlight / Firebase distribution initially.

No feature flag — the scanner surface has no consumer until step 2/3 ships; nothing to gate. The old /api/business/bookings/verify deletion (D7) is a breaking change for any in-flight admin-driven verify clients; spec confirms none exist outside #14a’s preview window.

Rollback: if scanner surface itself is broken, revert backend MR — the /api/business/bookings/verify re-appears with the revert, restoring the temporary path. If only the credential CRUD UI is broken, ship a forward-fix; the backend stays online. Drizzle migration is forward-only — rollback drops the scanner schema (orphans any inserted credentials, acceptable for pre-prod).

SCANNER_JWT_SECRET rotation playbook: generate new secret, deploy with both old+new accepted for one access-token lifetime (7 days per D2), then remove old. Codify in auth-sync.service.ts as a oldSecrets: string[] array. For emergency revocation (e.g. suspected secret leak) flip is_active=false on all affected scanner.scanner_credentials rows — per-request DB check in D2 ensures instantaneous invalidation regardless of token lifetime.

#RiskMitigation
R17-day AT TTL means a leaked AT is usable for up to a week before natural expiryAccepted trade-off (locked spec OQ-7 / §4 / AC-4). Instantaneous revocation comes from the per-request isActive DB check in ScannerJwtStrategy.validate() (D2) — flipping is_active=false invalidates the AT on the next request, independent of TTL. SCANNER_JWT_SECRET rotation uses a dual-secret window per §5 rotation playbook.
R2Mass-credential DOS — attacker hammers /auth/login with random logins@nestjs/throttler (newly added — see D6 / Files-to-touch §3) 10/15-min per login + per-IP; generic 401 (no enumeration); pre-warmed bcrypt hash for unknown login to defeat timing oracle
R3Cross-company verify race — scanner from company A scans freshly-issued booking from B during a company move/mergeTenancy check is on booking.companyId (the post-move value); race window is sub-second; explicit 403 with structured log for audit
R4BookingVerifyTokenService HMAC secret reuse between #14a (admin) and #14b (scanner)Spec §5 confirms the token plumbing is unchanged — same HMAC secret. Acceptable: the token bearer changes, not the signer. If signer compromise required, rotate via existing BOOKING_VERIFY_SECRET env.
R5Admin-typed login collisions across companiesUNIQUE(login) enforces globally; on collision the API returns 409 SCANNER_LOGIN_TAKEN and the create form surfaces an inline error. No server-side retry — the admin picks another login. Collision space is the admin-chosen string, not a random suffix (no slug prefix scheme).

Beyond R1-R5 — flagged for awareness, not for resolution here:

  • R6mobile_scanner requires camera permission; first-launch UX must handle denial gracefully (out of scope, defer to scanner_app dev phase).
  • R7 — Refresh-token table grows unbounded without the cron prune. In scope for this MR: add a cron prune to the existing Bull queue setup — daily job that deletes rows where revokedAt IS NOT NULL OR expiresAt < now(). Without it scanner_refresh_tokens grows monotonically (one row per login + one per refresh rotation).
  • R8@nestjs/throttler default in-memory storage is per-process; a multi-replica deploy would let an attacker round-robin replicas to bypass the 10/15-min limit. Defer: add @nestjs/throttler-storage-redis against the existing ioredis instance when we scale beyond a single api replica.

STATUS: READY_FOR_REVIEW