Signus backend built with Kotlin and Ktor. It manages JWT authentication, linking between users, shared semaphore state, realtime delivery over WebSocket, and push fallback through FCM when the partner does not have an active websocket session.
The backend is the source of truth for:
- users and authentication
- linking and unlinking between two users
- current semaphore state (
AVAILABLE,BUSY,OFFLINE) - websocket sessions for realtime events
- FCM token registration per device
- push fallback through FCM when realtime does not deliver
The Android app consumes this backend through HTTP + JWT + WebSocket. Firebase is no longer used for auth or database; currently it is only kept as push notification transport through FCM.
Already migrated to the custom backend:
- authentication
- user persistence and partner relationships
- linking through sessions and codes
- reading
/meand/partner - state change with
PATCH /status - realtime events over WebSocket
- unified websocket event contract:
PARTNER_STATUS_CHANGEDPARTNER_UNLINKED
- FCM token registration and removal per device
- backend fallback
realtime -> pushfor status changes
Firebase currently keeps only this role:
- Firebase Cloud Messaging (FCM) to deliver push notifications
It is not part of the current system:
- Firebase Auth
- Firestore
- Firebase Realtime Database
The project follows a feature-based structure:
src/main/kotlin/
core/ # config, DI, plugins, security, database
features/
auth/
devicetoken/
linking/
notification/
semaphore/
user/
Responsibilities:
- routes: HTTP/WebSocket endpoints, basic validation, auth extraction
- services: use cases and orchestration
- repositories: persistence and DB mapping
- core: cross-cutting infrastructure
General dependency flow:
routes -> services -> repositories -> database
The backend uses Koin for DI, Flyway for migrations, and Exposed for data access.
- Kotlin
- Ktor
- PostgreSQL
- Exposed
- Flyway
- Koin
- JWT
- WebSockets
- Firebase Cloud Messaging
- Docker / Docker Compose
- Testcontainers
- Copy the example file:
cp .env.example .env- Configure the required variables:
| Variable | Description |
|---|---|
DB_HOST |
PostgreSQL host |
DB_PORT |
PostgreSQL port |
DB_NAME |
Database name |
DB_USER |
Database user |
DB_PASSWORD |
Database password |
PORT |
Backend HTTP port |
JWT_SECRET |
Secret used to sign JWT |
JWT_ISSUER |
JWT issuer |
JWT_AUDIENCE |
JWT audience |
JWT_REALM |
Realm configured in Ktor auth |
JWT_EXPIRATION_TIME |
Token duration in ms |
FCM_SERVER_KEY |
Credential used by the backend FCM provider |
Full Docker mode:
docker compose up --buildHybrid mode:
docker compose up -d db
./gradlew runStop services:
docker compose downRemove volumes:
docker compose down -v- Protected endpoints require
Authorization: Bearer <jwt>. - The websocket requires
tokenin the query string:/ws?token=<jwt>. - The JWT must include the
userIdclaim. - Secrets must be provided through environment variables.
Local base URL:
- HTTP:
http://localhost:8080 - WebSocket:
ws://localhost:8080
Creates a user and returns JWT.
Request:
{
"email": "user@example.com",
"password": "secret123",
"displayName": "User"
}Response 201 Created:
{
"accessToken": "<jwt>"
}Authenticates a user and returns JWT.
Request:
{
"email": "user@example.com",
"password": "secret123"
}Response 200 OK:
{
"accessToken": "<jwt>"
}Creates a linking session for the authenticated user.
Response 201 Created:
{
"sessionId": "a6a21519-5d42-43d4-b6ea-e7f0c8187f32",
"linkCode": "ABC123",
"expiresAt": "2026-03-20T12:34:56Z"
}Confirms an existing session using linkCode.
Request:
{
"linkCode": "ABC123"
}Response 200 OK:
{
"sessionId": "a6a21519-5d42-43d4-b6ea-e7f0c8187f32",
"status": "CONFIRMED"
}Relevant errors:
400 Bad Requestif the code is invalid or if the user tries to link with themselves404 Not Foundif the session does not exist410 Goneif the session expired409 Conflictif the session was already confirmed
Checks the current state of a session.
Response 200 OK:
{
"sessionId": "a6a21519-5d42-43d4-b6ea-e7f0c8187f32",
"status": "PENDING"
}Actual states: PENDING, CONFIRMED, EXPIRED.
Returns the current state of the authenticated user.
Response 200 OK:
{
"id": "user-1",
"status": "BUSY",
"statusExpiration": null,
"statusDuration": null,
"partnerId": "user-2"
}Returns the current state of the linked partner.
Response 200 OK:
{
"id": "user-2",
"status": "AVAILABLE",
"statusExpiration": null,
"statusDuration": null,
"partnerId": "user-1"
}If the user does not have a linked partner, the route returns 404 Not Found.
Unlinks the authenticated user from their current partner.
Response 204 No Content.
If there is an active websocket on the partner, the backend emits the PARTNER_UNLINKED event. This flow currently does not use push fallback.
Updates the state of the authenticated user.
Request:
{
"status": "BUSY"
}Supported states: AVAILABLE, BUSY, OFFLINE.
Response 200 OK:
{
"status": "BUSY",
"userId": "user-1",
"expiration": null,
"duration": null
}Registers or updates the FCM token of the authenticated device.
Request:
{
"deviceId": "android-device-1",
"fcmToken": "fcm-token-value",
"platform": "android",
"appVersion": "1.0.0"
}Response 201 Created when it creates a new record, or 200 OK when it updates an existing one:
{
"created": true,
"token": {
"id": "token-row-id",
"deviceId": "android-device-1",
"platform": "android",
"appVersion": "1.0.0",
"active": true,
"createdAt": 1710930000000,
"updatedAt": 1710930000000,
"lastRegisteredAt": 1710930000000,
"deactivatedAt": null
}
}Real notes:
platformonly acceptsandroid- the backend stores tokens by
userId + deviceId - if the same
fcmTokenwas active on another user or device, that previous record is deactivated
Deactivates the active token associated with the authenticated user's deviceId.
Response 204 No Content.
Returns the tokens of the authenticated user. By default only active ones.
Optional query param:
includeInactive=true|false
Registers the authenticated user's websocket session to receive server-push events.
The client does not need to send business messages; the backend uses the connection to push events.
Real events:
{
"type": "PARTNER_STATUS_CHANGED",
"partnerId": "user-1",
"status": "AVAILABLE",
"statusExpiration": null,
"timestamp": 1710930000000
}{
"type": "PARTNER_UNLINKED",
"partnerId": "user-1",
"timestamp": 1710930000000
}- User A creates
POST /linking/sessions. - Backend generates
sessionId,linkCode, andexpiresAt. - User B calls
POST /linking/sessions/confirmwithlinkCode. - Backend validates the session, expiration, and that it is not the same user.
- Backend links both users and marks the session as
CONFIRMED.
- A user calls
DELETE /partner. - Backend removes the partner relationship in DB.
- If the partner has an active websocket, backend sends
PARTNER_UNLINKED. - This flow does not perform push fallback in the current state.
- The app calls
PATCH /status. - Backend persists the new state.
StatusServiceImpldelegates toNotificationOrchestrator.- The orchestration looks up the partner.
- If there is a partner, it tries to send
PARTNER_STATUS_CHANGEDover websocket. - If there is no active session or realtime delivery fails, it uses push fallback through FCM.
- If there is no partner, there are no active tokens, or a specific token fails, the state update is not reverted.
- The app gets or refreshes its FCM token.
- The app calls
PUT /devices/fcm-tokenwithdeviceId,fcmToken,platform, andappVersion. - Backend creates or updates the record in
user_device_tokens. - When the device is no longer valid or the user logs out, the app can call
DELETE /devices/fcm-token/{deviceId}to deactivate the token.
Run tests:
./gradlew testCompile backend:
./gradlew --no-daemon clean compileKotlinThe tests cover:
- feature services and contracts
- HTTP and WebSocket routes
- repositories with PostgreSQL through Testcontainers
- OpenAPI/Swagger is present as a dependency, but there is no public route exposed by default.
- Realtime/push migration documentation is kept in
REALTIME_NOTIFICATIONS_PLAN.md.
This project is source-available but not open source.
You may view and study the code, but you are not allowed to use it for commercial purposes or deploy it as a service without explicit permission.
See the LICENSE file for details.