Skip to main content

Phase 1: Auth & Invites

Depends on: Phase 0b (Security Foundation) New Endpoints: 11 New Files: 22


Goal

Implement user authentication (email/password login, PIN login, JWT issuance), invite-based onboarding (validate, accept, create, revoke), and the authenticated user profile endpoints. After this phase, users can log in, view their profile, change passwords, and be invited to organizations/stores.


Endpoints

Public (No Auth)

MethodPathHandlerDescription
POST/api/v1/auth/loginAuthController.loginEmail/password login, returns JWT
POST/api/v1/invites/validateInviteController.validateValidate invite token (check expiry, status)
POST/api/v1/invites/acceptInviteController.acceptAccept invite, create user account + memberships
POST/api/v1/stores/:store_id/auth/pin-loginPinAuthController.loginPIN-based login for store staff

Authenticated (JWT Required)

MethodPathHandlerDescription
GET/api/v1/auth/meAuthController.meGet current user profile
POST/api/v1/auth/change-passwordAuthController.changePasswordChange password (requires current password)

Organization-Scoped (Org Admin)

MethodPathHandlerDescription
GET/api/v1/orgs/:org_id/invitesOrgInviteController.listList org-level invites
POST/api/v1/orgs/:org_id/invitesOrgInviteController.createCreate org invite
POST/api/v1/orgs/:org_id/invites/:invite_id/revokeOrgInviteController.revokeRevoke invite
DELETE/api/v1/orgs/:org_id/invites/:invite_idOrgInviteController.deleteDelete invite

Store-Scoped (Store Admin)

MethodPathHandlerDescription
GET/api/v1/stores/:store_id/invitesStoreInviteController.listList store invites
POST/api/v1/stores/:store_id/invitesStoreInviteController.createCreate store invite
POST/api/v1/stores/:store_id/invites/:invite_id/revokeStoreInviteController.revokeRevoke invite
DELETE/api/v1/stores/:store_id/invites/:invite_idStoreInviteController.deleteDelete invite

SQLDelight Tables & Queries

users table (minimal for auth)

Key queries from User.sq:

  • findById(user_id) - Lookup by UUID
  • findByEmail(email) - Login lookup
  • findByPinAndStoreId(pin, store_id) - PIN login (joins store_memberships)
  • insert(...) - Create user (invite acceptance)
  • updatePassword(password_hash, user_id) - Change password
  • updateLastLogin(user_id) - Track login time

invites table

Key queries from Invite.sq:

  • findById(invite_id) - Lookup by UUID
  • findByToken(token) - Token validation
  • findByOrgId(org_id, limit, offset) - List org invites (paginated)
  • findByStoreId(store_id, limit, offset) - List store invites (paginated)
  • countByOrgId(org_id) - Pagination count
  • countByStoreId(store_id) - Pagination count
  • insert(...) - Create invite
  • updateStatus(status, invite_id) - Accept/revoke
  • delete(invite_id) - Hard delete

Files to Create

Controllers (4)

controller/auth/AuthController.java
POST /auth/login -> authService.login(request)
GET /auth/me -> authService.getCurrentUser(authenticatedUser)
POST /auth/change-password -> authService.changePassword(authenticatedUser, request)

controller/auth/PinAuthController.java
POST /stores/{storeId}/auth/pin-login -> authService.pinLogin(storeId, request)

controller/invite/InviteController.java
POST /invites/validate -> inviteService.validate(request)
POST /invites/accept -> inviteService.accept(request)

controller/org/OrgInviteController.java
GET /orgs/{orgId}/invites -> inviteService.listByOrg(orgId, page, size)
POST /orgs/{orgId}/invites -> inviteService.create(orgId, null, request)
POST /orgs/{orgId}/invites/{id}/revoke -> inviteService.revoke(id)
DELETE /orgs/{orgId}/invites/{id} -> inviteService.delete(id)

controller/store/StoreInviteController.java
GET /stores/{storeId}/invites -> inviteService.listByStore(storeId, page, size)
POST /stores/{storeId}/invites -> inviteService.create(null, storeId, request)
POST /stores/{storeId}/invites/{id}/revoke -> inviteService.revoke(id)
DELETE /stores/{storeId}/invites/{id} -> inviteService.delete(id)

Services (3)

service/AuthService.java
- login(LoginRequest) -> AuthResponse (validate email/password, issue JWT)
- pinLogin(UUID storeId, PinLoginRequest) -> AuthResponse (validate PIN, issue JWT)
- getCurrentUser(AuthenticatedUser) -> MeResponse
- changePassword(AuthenticatedUser, ChangePasswordRequest) -> void
- Uses: BCryptPasswordEncoder (internal), JwtService, UserRepository

service/InviteService.java
- validate(ValidateInviteRequest) -> InviteResponse
- accept(AcceptInviteRequest) -> AuthResponse (create user + memberships + issue JWT)
- listByOrg(UUID orgId, int page, int size) -> Page<InviteResponse>
- listByStore(UUID storeId, int page, int size) -> Page<InviteResponse>
- create(UUID orgId, UUID storeId, CreateInviteRequest) -> InviteResponse
- revoke(UUID inviteId) -> void
- delete(UUID inviteId) -> void
- Uses: InviteRepository, UserRepository, BCryptPasswordEncoder, JwtService

service/UserService.java
- findById(UUID userId) -> User (SQLDelight type)
- findByEmail(String email) -> User
- Uses: UserRepository

Repositories (2)

repository/UserRepository.java
- Wraps SQLDelight UserQueries
- findById, findByEmail, findByPinAndStoreId, insert, updatePassword, updateLastLogin

repository/InviteRepository.java
- Wraps SQLDelight InviteQueries
- findById, findByToken, findByOrgId, findByStoreId, countByOrgId, countByStoreId, insert, updateStatus, delete

Request DTOs (6)

dto/request/LoginRequest.java
{ email: @NotBlank String, password: @NotBlank String }

dto/request/ChangePasswordRequest.java
{ currentPassword: @NotBlank String, newPassword: @NotBlank @Size(min=8) String }

dto/request/PinLoginRequest.java
{ pin: @NotBlank String }

dto/request/ValidateInviteRequest.java
{ token: @NotBlank String }

dto/request/AcceptInviteRequest.java
{ token: @NotBlank String, password: @NotBlank @Size(min=8) String,
firstName: @NotBlank String, lastName: @NotBlank String }

dto/request/CreateInviteRequest.java
{ email: @NotBlank @Email String, firstName: String, lastName: String,
orgRole: String, storeRole: String, storeId: UUID }

Response DTOs (3)

dto/response/AuthResponse.java
{ token: String, userId: UUID, email: String, firstName: String, lastName: String }

dto/response/MeResponse.java
{ userId: UUID, email: String, firstName: String, lastName: String,
orgMemberships: List<OrgMembershipInfo>, storeMemberships: List<StoreMembershipInfo> }

dto/response/InviteResponse.java
{ inviteId: UUID, orgId: UUID, storeId: UUID, email: String,
firstName: String, lastName: String, orgRole: String, storeRole: String,
status: String, expiresAt: Instant, acceptedAt: Instant, createdAt: Instant }

Mappers (2)

mapper/UserMapper.java
- static toMeResponse(User, List<OrgMembership>, List<StoreMembership>) -> MeResponse
- static toAuthResponse(User, String token) -> AuthResponse

mapper/InviteMapper.java
- static toResponse(Invite) -> InviteResponse
- static toResponseList(List<Invite>) -> List<InviteResponse>

Other (2)

exception/ConflictException.java       - 409 Conflict (duplicate email, etc.)
exception/BadRequestException.java - 400 Bad Request (validation failures)

security/service/AuthWrapperService.java - BCrypt hashing + token generation helper

Request/Response Schemas

POST /api/v1/auth/login

Request:

{ "email": "user@example.com", "password": "secretpass" }

Response (200):

{
"success": true,
"data": {
"token": "eyJhbG...",
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"first_name": "Jane",
"last_name": "Doe"
}
}

Errors: 401 (invalid credentials), 400 (missing fields)

POST /api/v1/stores/:store_id/auth/pin-login

Request:

{ "pin": "1234" }

Response (200): Same shape as login. JWT claims include storeId.

Errors: 401 (invalid PIN), 404 (store not found)

POST /api/v1/invites/validate

Request:

{ "token": "abc123-invite-token" }

Response (200):

{
"success": true,
"data": {
"invite_id": "...",
"email": "newuser@example.com",
"org_role": "org_member",
"store_role": "cashier",
"status": "pending",
"expires_at": "2026-03-01T00:00:00Z"
}
}

Errors: 404 (token not found), 400 (expired/already accepted)

POST /api/v1/invites/accept

Request:

{
"token": "abc123-invite-token",
"password": "newpassword123",
"first_name": "Jane",
"last_name": "Doe"
}

Response (200): Same as login (JWT issued immediately).

Errors: 400 (expired/used token), 409 (email already registered)


Acceptance Criteria

  1. POST /auth/login returns a valid JWT for correct credentials, 401 for incorrect
  2. GET /auth/me returns the authenticated user's profile with org/store memberships
  3. POST /auth/change-password validates current password before updating
  4. POST /stores/:store_id/auth/pin-login authenticates store staff via PIN
  5. POST /invites/validate returns invite details for valid tokens, errors for expired/used
  6. POST /invites/accept creates a user, adds memberships, and issues JWT
  7. Org invite CRUD works with pagination (?page=0&size=20)
  8. Store invite CRUD works with pagination
  9. Invite tokens are cryptographically random (UUID or SecureRandom)
  10. Invites expire after configurable duration (default 7 days)
  11. All endpoints return ApiResponse<T> envelope
  12. All request DTOs have Jakarta Bean Validation annotations
  13. Bazel build passes: bazel build //apps/microservices/merchant-api:core
  14. Unit tests pass for AuthService (login, PIN login, change password)
  15. Unit tests pass for InviteService (validate, accept, create, revoke)