Scanner credential
Purpose
TBD by human — at a code level: a scanner credential is the actor identity used by the scanner mobile app at the venue gate. It is not a User — there is no Supabase auth row, no email, no profile. It is a long-lived login + bcrypt-hashed password pair owned by a single Company, provisioned by a business admin, and consumed by POST /api/scanner/auth/login to mint short-lived JWTs that authorise the scanner surface (see specs/scanner-ecosystem.md for the product framing).
Identity & key fields
Two tables in the dedicated scanner Postgres schema:
scanner.scanner_credentials — the credential itself.
- Primary key:
id(uuid, defaultgen_random_uuid()). companyId(uuid, FK →companies.companies.id,ON DELETE CASCADE) — the tenant that owns the credential. Deleting the company removes its credentials.login(varchar 64, globally unique) — admin-supplied; DTO-level pattern/^[a-z0-9_-]{3,60}$/. Uniqueness is enforced platform-wide, not per-company.passwordHash(text, NOT NULL) — bcrypt cost-12 hash of the server-generated initial password. The plaintext is returned once by the create endpoint (ScannerCredentialCreateResponseDto.initialPassword, 16-char base32 RFC 4648 §6 alphabet) and is never re-emitted by any other endpoint.label(varchar 128, NOT NULL) — human-readable identifier (e.g. “Main entrance — kiosk 1”) shown in the business admin list.isActive(boolean, defaulttrue) — soft-revocation flag honoured per-request byScannerJwtStrategy(a stolen JWT becomes useless within one request ofisActiveflipping false; see ADR D2).createdBy(nullable uuid, FK →users.users.id,ON DELETE SET NULL) — admin who provisioned the credential. Set-null on user delete preserves the audit row.lastUsedAt(nullable timestamp) — best-effort updated on successful login / refresh.revokedAt(nullable timestamp) — set whenisActiveflips to false.createdAt/updatedAt—updatedAtis bumped on every UPDATE via Drizzle$onUpdatehook (ADR D3).
scanner.scanner_refresh_tokens — sibling table holding rotating refresh tokens.
- Primary key:
id(uuid, defaultgen_random_uuid()). credentialId(uuid, FK →scanner.scanner_credentials.id,ON DELETE CASCADE) — parent credential. Hard-deleting the credential drops all its refresh tokens.tokenHash(char(64), unique) — SHA-256 hex digest of the 32-byte random refresh token. The raw token is returned once by login/refresh and never again — only the digest is stored.deviceLabel(varchar 128, nullable) — optional client-supplied device hint.expiresAt(NOT NULL) —createdAt + 90 days.revokedAt(nullable) — set on logout or rotation; NULL means active.lastUsedAt(nullable).
Invariants
loginis globally unique across the platform — enforced byscanner_credentials_login_unique(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/scanner.schema.ts:71).companyIdON DELETE CASCADE — deleting a Company removes its scanner credentials (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/scanner.schema.ts:39).createdByON DELETE SET NULL — deleting the provisioning admin keeps the credential intact, just discards the audit pointer (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/scanner.schema.ts:54).passwordHashis bcrypt cost-12; the initial 16-char plaintext password (RFC 4648 §6 base32, server-generated) is emitted once in the create response and never recoverable — admins must save it on first display (enforced in tktspace-backend/libs/features/companies/src/lib/scanners-admin/dto/scanner-credential-create.response.dto.ts).isActiveis honoured per request byScannerJwtStrategy— flipping it to false invalidates issued JWTs within at most one request (spec invariant; see ADR scanner-ecosystem D2 andtktspace-backend/libs/features/auth/src/lib/strategies/scanner-jwt.strategy.ts).scanner_refresh_tokens.tokenHashis globally unique — collision-safe SHA-256 digest of the raw refresh token (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/scanner.schema.ts:102).scanner_refresh_tokens.credentialIdON DELETE CASCADE — hard-deleting a credential drops every refresh token under it (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/scanner.schema.ts:83).- Tenancy at verify —
POST /api/scanner/bookings/verifyrefuses to check-in a booking whose session’scompanyIddiffers from the calling scanner credential’scompanyId(spec only — seespecs/scanner-ecosystem.md; verify implementation intktspace-backend/libs/features/activities/src/lib/bookings-scanner.module.ts).
Lifecycle
created (isActive=true, revokedAt=NULL) — admin POSTs create; server generates initial passwordisActive=true → isActive=false — admin PATCHes credential; revokedAt = now()created → deleted — admin DELETEs credential; CASCADE drops refresh tokens- Create.
POST /api/business/companies/{companyId}/scanners(companyScannerCredentialsCreate). Admin suppliesloginandlabel; server generates a 16-char base32 password, bcrypt-hashes it, returns the plaintext exactly once inScannerCredentialCreateResponseDto.initialPasswordfor an admin “copy and save” dialog. - Read / list.
GET /api/business/companies/{companyId}/scanners(companyScannerCredentialsList) — does not returnpasswordHashor any plaintext. - Update.
PATCH /api/business/companies/{companyId}/scanners/{id}(companyScannerCredentialsUpdate) — updateslabelandisActive. FlippingisActive=falsesetsrevokedAt = now(); future logins are rejected and existing JWTs fail per-request asScannerJwtStrategyre-queries the row. - Delete.
DELETE /api/business/companies/{companyId}/scanners/{id}(companyScannerCredentialsDelete) — hard delete. CASCADEs refresh tokens. ON DELETE SET NULL onbookings.verifier_scanner_credential_idpreserves already-checked-in bookings but loses the operator audit pointer (see Booking Invariants). - Login. Scanner app calls
POST /api/scanner/auth/loginwith{ login, password }; service compares against bcrypt hash, returns{ accessToken (~7 day TTL), refreshToken (90 day TTL) }. Failed-login attempts are throttled byScannerLoginThrottlerGuard. - Refresh.
POST /api/scanner/auth/refreshrotates the refresh token — the old row’srevokedAtis set, a new row is inserted. - Logout.
POST /api/scanner/auth/logoutrevokes the current refresh token (setsrevokedAt). Expired and revoked tokens are pruned byScannerRefreshTokenPrunerService.
Relationships
- Company (ENT-016) —
companyId→companies.companies.id, on-delete cascade. N:1. The credential lives in exactly one company’s tenancy. - User (ENT-021) —
createdBy→users.users.id, on-delete set null. N:1, optional. Admin who provisioned the credential. - Booking (ENT-003) — referenced by
bookings.bookings.verifier_scanner_credential_id(FK, on-delete set null). 1:N. Each verify writes the calling credential id onto the booking. - Scanner refresh token — internal sibling table (
scanner.scanner_refresh_tokens), 1:N, on-delete cascade. Not exposed as a standalone entity.
API surfaces
| Surface | Exposed | Notes |
|---|---|---|
| client | no | — |
| business | yes — admin CRUD: companyScannerCredentialsList, companyScannerCredentialsCreate (returns one-time plaintext password), companyScannerCredentialsUpdate (label + isActive), companyScannerCredentialsDelete. Mounted at /api/business/companies/{companyId}/scanners. | Swagger UI |
| scanner | yes — used as the authenticator of the entire scanner surface (the 4th OpenAPI contract, _workflow/contracts/scanner.openapi.yaml). Operations: scannerAuthLogin, scannerAuthRefresh, scannerAuthLogout, and downstream scannerBookingsVerify. The credential row itself is not directly readable on the scanner surface — only its JWT is. | Swagger UI |
| super-admin | no | — |
Note: the surfaces frontmatter array above only contains business because the build-time schema’s surface enum is [client, business, superadmin] and does not yet include scanner. The scanner surface is documented in prose here and in the API surfaces table.
Known gotchas / open questions
- No
Userrow. A scanner credential is its own actor type —ScannerJwtStrategyissues a{ credentialId, companyId }JWT, not a Supabase user JWT. Code paths that key offreq.user.idon the business/client surfaces do not generalise to the scanner surface. - Globally unique
login. Two companies cannot independently pick the same login string. Admin UI must surface uniqueness conflicts clearly. (Design choice — see ADR scanner-ecosystem.) - One-time password reveal.
initialPasswordis in the create response and nowhere else. If the admin closes the dialog before saving it, the credential must be DELETEd and re-created (no “re-show” or “reset” endpoint exists today). isActive=falsedoes not delete refresh tokens. They remain in the DB until pruned for expiry or explicitly revoked. The strategy’s per-requestisActivecheck is what blocks reuse — not row removal.- Booking audit when credential is hard-deleted.
bookings.verifier_scanner_credential_idisON DELETE SET NULL. ACHECKED_INbooking survives credential deletion but loses the “who verified” pointer. The CHECK constraintbookings_verifier_exactly_oneaccepts both columns NULL precisely to allow this state (see Booking Invariants). - OPEN: TBD by human — password rotation strategy when a credential is suspected leaked but deletion would also wipe its
bookings.verifier_scanner_credential_idaudit trail.
Recommendations
Forward-looking improvements suggested while filling this doc — not currently in place.
- Password rotation endpoint. A
POST /api/business/.../scanners/{id}/rotate-passwordthat issues a new one-time password would avoid the “delete-and-recreate” workflow (which also wipes the audit pointer on past verifies). - Per-company
loginuniqueness. Todayloginis globally unique. Scoping uniqueness to(companyId, login)would let independent tenants pick the same human-friendly login (e.g.entrance-1) without collision. - Bulk-revoke endpoint. When a device is lost, revoking all refresh tokens for a credential currently requires either flipping
isActive=false(which also blocks new logins) or manual row updates. A dedicated “kill all sessions” endpoint would be cleaner. - Last-used-by-token metric.
lastUsedAtis updated on the credential row but not per-refresh-token. Per-token last-used would help admins distinguish “this device hasn’t been used in 30 days” from “this credential hasn’t been used at all”.