Skip to content

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, default gen_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, default true) — soft-revocation flag honoured per-request by ScannerJwtStrategy (a stolen JWT becomes useless within one request of isActive flipping 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 when isActive flips to false.
  • createdAt / updatedAtupdatedAt is bumped on every UPDATE via Drizzle $onUpdate hook (ADR D3).

scanner.scanner_refresh_tokens — sibling table holding rotating refresh tokens.

  • Primary key: id (uuid, default gen_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

  • login is globally unique across the platform — enforced by scanner_credentials_login_unique (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/scanner.schema.ts:71).
  • companyId ON 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).
  • createdBy ON 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).
  • passwordHash is 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).
  • isActive is honoured per request by ScannerJwtStrategy — flipping it to false invalidates issued JWTs within at most one request (spec invariant; see ADR scanner-ecosystem D2 and tktspace-backend/libs/features/auth/src/lib/strategies/scanner-jwt.strategy.ts).
  • scanner_refresh_tokens.tokenHash is 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.credentialId ON 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 verifyPOST /api/scanner/bookings/verify refuses to check-in a booking whose session’s companyId differs from the calling scanner credential’s companyId (spec only — see specs/scanner-ecosystem.md; verify implementation in tktspace-backend/libs/features/activities/src/lib/bookings-scanner.module.ts).

Lifecycle

created (isActive=true, revokedAt=NULL) — admin POSTs create; server generates initial password
isActive=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 supplies login and label; server generates a 16-char base32 password, bcrypt-hashes it, returns the plaintext exactly once in ScannerCredentialCreateResponseDto.initialPassword for an admin “copy and save” dialog.
  • Read / list. GET /api/business/companies/{companyId}/scanners (companyScannerCredentialsList) — does not return passwordHash or any plaintext.
  • Update. PATCH /api/business/companies/{companyId}/scanners/{id} (companyScannerCredentialsUpdate) — updates label and isActive. Flipping isActive=false sets revokedAt = now(); future logins are rejected and existing JWTs fail per-request as ScannerJwtStrategy re-queries the row.
  • Delete. DELETE /api/business/companies/{companyId}/scanners/{id} (companyScannerCredentialsDelete) — hard delete. CASCADEs refresh tokens. ON DELETE SET NULL on bookings.verifier_scanner_credential_id preserves already-checked-in bookings but loses the operator audit pointer (see Booking Invariants).
  • Login. Scanner app calls POST /api/scanner/auth/login with { login, password }; service compares against bcrypt hash, returns { accessToken (~7 day TTL), refreshToken (90 day TTL) }. Failed-login attempts are throttled by ScannerLoginThrottlerGuard.
  • Refresh. POST /api/scanner/auth/refresh rotates the refresh token — the old row’s revokedAt is set, a new row is inserted.
  • Logout. POST /api/scanner/auth/logout revokes the current refresh token (sets revokedAt). Expired and revoked tokens are pruned by ScannerRefreshTokenPrunerService.

Relationships

  • Company (ENT-016) — companyIdcompanies.companies.id, on-delete cascade. N:1. The credential lives in exactly one company’s tenancy.
  • User (ENT-021) — createdByusers.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

SurfaceExposedNotes
clientno
businessyes — admin CRUD: companyScannerCredentialsList, companyScannerCredentialsCreate (returns one-time plaintext password), companyScannerCredentialsUpdate (label + isActive), companyScannerCredentialsDelete. Mounted at /api/business/companies/{companyId}/scanners.Swagger UI
scanneryes — 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-adminno

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 User row. A scanner credential is its own actor type — ScannerJwtStrategy issues a { credentialId, companyId } JWT, not a Supabase user JWT. Code paths that key off req.user.id on 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. initialPassword is 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=false does not delete refresh tokens. They remain in the DB until pruned for expiry or explicitly revoked. The strategy’s per-request isActive check is what blocks reuse — not row removal.
  • Booking audit when credential is hard-deleted. bookings.verifier_scanner_credential_id is ON DELETE SET NULL. A CHECKED_IN booking survives credential deletion but loses the “who verified” pointer. The CHECK constraint bookings_verifier_exactly_one accepts 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_id audit trail.

Recommendations

Forward-looking improvements suggested while filling this doc — not currently in place.

  • Password rotation endpoint. A POST /api/business/.../scanners/{id}/rotate-password that issues a new one-time password would avoid the “delete-and-recreate” workflow (which also wipes the audit pointer on past verifies).
  • Per-company login uniqueness. Today login is 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. lastUsedAt is 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”.