ADR: Scanner ecosystem (P1 mobile #14b)
ADR: Scanner ecosystem (P1 mobile #14b)
Section titled “ADR: Scanner ecosystem (P1 mobile #14b)”1. Context
Section titled “1. Context”“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 Flutterscanner_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).
2. Decisions
Section titled “2. Decisions”D1. Scanner surface plumbing (4th surface)
Section titled “D1. Scanner surface plumbing (4th surface)”Four touchpoints, all additive — no migrations of existing surfaces required.
apps/api/src/app/app.module.ts:88— append{ path: 'scanner', module: ScannerAuthModule }and{ path: 'scanner', module: BookingsScannerModule }to theRouterModule.register([...])block. Global prefixapi(set atmain.ts:42) auto-prepends →/api/scanner/*.apps/api/src/main.ts— add a 4th Swagger block mirroring the existing three (lines 53–119):DocumentBuilder(titleTKTSpace — Scanner API,addBearerAuth()),SwaggerModule.createDocument(app, cfg, { include: [ScannerAuthModule, BookingsScannerModule] }),app.use('/api/scanner/openapi.json', …),app.use('/api/scanner/docs', apiReference({…})). Add alogger.log('Scanner Docs: …')line in the boot summary.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';tktspace-backend/scripts/sync-contracts.ts:54-58— append{ name: 'scanner', routePath: '/api/scanner/openapi.json', outFile: 'scanner.openapi.yaml' }toALL_SURFACES. This births_workflow/contracts/scanner.openapi.yamlon firstnpm run sync:contractsrun.
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:
@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.
D3. scanner_credentials table
Section titled “D3. scanner_credentials table”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.
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.
D4. scanner_refresh_tokens table
Section titled “D4. scanner_refresh_tokens table”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.
D6. Auth endpoints
Section titled “D6. Auth endpoints”| Method | Path | operationId | Auth |
|---|---|---|---|
| POST | /api/scanner/auth/login | scannerAuthLogin | public |
| POST | /api/scanner/auth/refresh | scannerAuthRefresh | public (RT) |
| POST | /api/scanner/auth/logout | scannerAuthLogout | scanner-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
ThrottlerGuardkeyed onlogin(not IP) to thescannerAuthLoginhandler. 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).
D7. Verify endpoint move
Section titled “D7. Verify endpoint move”- NEW
POST /api/scanner/bookings/verify—operationId: scannerBookingsVerify. Lives atlibs/features/bookings/src/lib/scanner/bookings-scanner.controller.tsinside a newBookingsScannerModule. - DELETE the temporary
BookingsVerifyAdminControllershipped in #14a (underlibs/features/bookings/src/lib/admin/). Also drop its route registration from the admin RouterModule entries and from the business Swaggerinclude: [...]. - Request body unchanged from #14a:
{ token: string }. - Tenancy check (new, per spec §5): after decoding the booking via
BookingVerifyTokenService.verify(token), assertreq.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(notverifier_user_id).
D8. Business panel CRUD endpoints
Section titled “D8. Business panel CRUD endpoints”| Method | Path | operationId |
|---|---|---|
| POST | /api/business/companies/:companyId/scanners | companyScannerCredentialsCreate |
| GET | /api/business/companies/:companyId/scanners | companyScannerCredentialsList |
| PATCH | /api/business/companies/:companyId/scanners/:id | companyScannerCredentialsUpdate |
| DELETE | /api/business/companies/:companyId/scanners/:id | companyScannerCredentialsDelete |
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 onscanner.scanner_credentials. On INSERT conflict (Postgres error code23505on thescanner_credentials_login_uniqueindex), the controller returns409 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.
D9. Business panel UI (tktspace-business)
Section titled “D9. Business panel UI (tktspace-business)”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 + copyRouting: 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).
D10. Flutter scanner_app
Section titled “D10. Flutter scanner_app”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.0Omits: 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_scannerview → 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):
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:
SPEC_URL=https://api.dev.tktspace.co/api/scanner/openapi.json \SPEC_OUT=scanner-spec.json \ melos run sync:specBoth 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 scannergreen) without forking the package.
3. Files to touch
Section titled “3. Files to touch”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 bydrizzle-kit generate).scripts/sync-contracts.ts— add 4thALL_SURFACESentry.package.json— addbcrypt+@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— wireThrottlerModule.forRoot({ ttl: 900, limit: 10 })globally alongside the new RouterModule entries..env.example—SCANNER_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 vianpm 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-awareredirect(RT presence →/scan, absent →/login).packages/api/lib/swagger/scanner-spec.json(NEW — emitted bySPEC_URL=… SPEC_OUT=… melos run sync:spec).packages/api/build.yaml— register 2nd spec input (scanner-spec.json).melos.yaml— parametrisesync:specviaSPEC_URL/SPEC_OUT; add build/run scripts forscanner_appflavor.
_workflow:
contracts/scanner.openapi.yaml(NEW — written on firstsync:contracts).contracts/business.openapi.yaml(UPDATED — verify endpoint removed, scanner CRUD endpoints added).contracts/client.openapi.yaml(UNCHANGED — client surface untouched).
4. Test plan (per AC)
Section titled “4. Test plan (per AC)”| AC | Verify |
|---|---|
| AC1 4th surface live | curl http://localhost:5005/api/scanner/docs returns Scalar HTML |
| AC2 sync-contracts emits scanner.yaml | npm run sync:contracts && test -f _workflow/contracts/scanner.openapi.yaml |
| AC3 login happy-path | POST /api/scanner/auth/login with seeded creds → 200 + access/refresh |
| AC4 access token TTL = 7d | Decode AT JWT body, assert exp - iat === 604800 |
| AC4b login throttled | 11th failed attempt within 15 min → 429 (@nestjs/throttler) |
| AC5 refresh rotates | POST /auth/refresh with RT → new RT; replay old RT → 401 |
| AC6 logout revokes | POST /auth/logout then any AT call → 401 after token expiry; RT row deleted |
| AC7 verify happy | scanner-jwt → POST /api/scanner/bookings/verify with valid token → 200 CHECKED_IN |
| AC8 verify cross-company | scanner-jwt (company A) verify booking from company B → 403 |
| AC9 verify audit column | After AC7, bookings.verifier_scanner_credential_id = scanner.id, user_id IS NULL |
| AC10 old admin verify removed | POST /api/business/bookings/verify → 404 |
| AC11 CHECK exactly-one | manual DB insert setting both verifier cols → CHECK violation |
| AC12 admin create credential | POST /companies/:id/scanners returns plaintext pwd ONCE |
| AC13 list never exposes pwd | GET /companies/:id/scanners body has no password* field anywhere |
| AC14 update revoke | PATCH ... { isActive: false } → next login attempt 401 |
| AC15 delete cascades RTs | After DELETE, SELECT FROM scanner.refresh_tokens WHERE credential_id = x → 0 rows |
| AC16 login UNIQUE collision | Two POSTs with identical admin-typed login → second returns 409 SCANNER_LOGIN_TAKEN |
| AC17 business UI list+create | Cypress: navigate settings/scanners → add scanner → reveal dialog → password copy works |
| AC18 business UI list paging | Mock 30 rows → table paginates |
| AC19 scanner_app login | Integration test: launches scanner flavor → login screen → RT cached securely (AT in memory only) |
| AC19b GoRouter initial redirect | Launch with seeded RT in flutter_secure_storage → lands on /scan; launch with empty storage → lands on /login |
| AC20 scanner_app scan | Mock camera frame with valid QR → success animation, verify call made |
| AC21 scanner_app offline | Airplane mode + scan → “Network unavailable” UX, no crash |
| AC22 scanner_app logout | Logout button → secure storage cleared → routed to /login |
| AC23 contracts/business.yaml updated | git diff _workflow/contracts/business.openapi.yaml shows verify removed + CRUD added |
| AC24 contracts/scanner.yaml committed | File exists in _workflow/contracts/ with auth + verify operations |
5. Rollout / rollback
Section titled “5. Rollout / rollback”Rollout (single MR per repo, deployed in order):
- Backend MR ships D1-D8 + Drizzle migration
0051_*. Deploy to dev first; runnpm run sync:contractsagainst dev to populate_workflow/contracts/scanner.openapi.yaml. CI gatesync:contracts:checkwill then enforce drift detection going forward. - Business MR (D9) merges after step 1’s contract is committed to
_workflow. Regenerates API client vianpm run sync:contracts. - Mobile MR (D10) — registers
scanner_appin 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.
6. Risks
Section titled “6. Risks”| # | Risk | Mitigation |
|---|---|---|
| R1 | 7-day AT TTL means a leaked AT is usable for up to a week before natural expiry | Accepted 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. |
| R2 | Mass-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 |
| R3 | Cross-company verify race — scanner from company A scans freshly-issued booking from B during a company move/merge | Tenancy check is on booking.companyId (the post-move value); race window is sub-second; explicit 403 with structured log for audit |
| R4 | BookingVerifyTokenService 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. |
| R5 | Admin-typed login collisions across companies | UNIQUE(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:
- R6 —
mobile_scannerrequires 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 itscanner_refresh_tokensgrows monotonically (one row per login + one per refresh rotation). - R8 —
@nestjs/throttlerdefault 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-redisagainst the existing ioredis instance when we scale beyond a single api replica.
STATUS: READY_FOR_REVIEW