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)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /api/v1/auth/login | AuthController.login | Email/password login, returns JWT |
| POST | /api/v1/invites/validate | InviteController.validate | Validate invite token (check expiry, status) |
| POST | /api/v1/invites/accept | InviteController.accept | Accept invite, create user account + memberships |
| POST | /api/v1/stores/:store_id/auth/pin-login | PinAuthController.login | PIN-based login for store staff |
Authenticated (JWT Required)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /api/v1/auth/me | AuthController.me | Get current user profile |
| POST | /api/v1/auth/change-password | AuthController.changePassword | Change password (requires current password) |
Organization-Scoped (Org Admin)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /api/v1/orgs/:org_id/invites | OrgInviteController.list | List org-level invites |
| POST | /api/v1/orgs/:org_id/invites | OrgInviteController.create | Create org invite |
| POST | /api/v1/orgs/:org_id/invites/:invite_id/revoke | OrgInviteController.revoke | Revoke invite |
| DELETE | /api/v1/orgs/:org_id/invites/:invite_id | OrgInviteController.delete | Delete invite |
Store-Scoped (Store Admin)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /api/v1/stores/:store_id/invites | StoreInviteController.list | List store invites |
| POST | /api/v1/stores/:store_id/invites | StoreInviteController.create | Create store invite |
| POST | /api/v1/stores/:store_id/invites/:invite_id/revoke | StoreInviteController.revoke | Revoke invite |
| DELETE | /api/v1/stores/:store_id/invites/:invite_id | StoreInviteController.delete | Delete invite |
SQLDelight Tables & Queries
users table (minimal for auth)
Key queries from User.sq:
findById(user_id)- Lookup by UUIDfindByEmail(email)- Login lookupfindByPinAndStoreId(pin, store_id)- PIN login (joins store_memberships)insert(...)- Create user (invite acceptance)updatePassword(password_hash, user_id)- Change passwordupdateLastLogin(user_id)- Track login time
invites table
Key queries from Invite.sq:
findById(invite_id)- Lookup by UUIDfindByToken(token)- Token validationfindByOrgId(org_id, limit, offset)- List org invites (paginated)findByStoreId(store_id, limit, offset)- List store invites (paginated)countByOrgId(org_id)- Pagination countcountByStoreId(store_id)- Pagination countinsert(...)- Create inviteupdateStatus(status, invite_id)- Accept/revokedelete(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
POST /auth/loginreturns a valid JWT for correct credentials, 401 for incorrectGET /auth/mereturns the authenticated user's profile with org/store membershipsPOST /auth/change-passwordvalidates current password before updatingPOST /stores/:store_id/auth/pin-loginauthenticates store staff via PINPOST /invites/validatereturns invite details for valid tokens, errors for expired/usedPOST /invites/acceptcreates a user, adds memberships, and issues JWT- Org invite CRUD works with pagination (
?page=0&size=20) - Store invite CRUD works with pagination
- Invite tokens are cryptographically random (UUID or SecureRandom)
- Invites expire after configurable duration (default 7 days)
- All endpoints return
ApiResponse<T>envelope - All request DTOs have Jakarta Bean Validation annotations
- Bazel build passes:
bazel build //apps/microservices/merchant-api:core - Unit tests pass for AuthService (login, PIN login, change password)
- Unit tests pass for InviteService (validate, accept, create, revoke)