Core
Core is the backend of the Trident platform: a single Quarkus project, core, that contains the API every touchpoint shares. It knows who each member is, keeps their points in an append-only ledger, issues the one-time codes that move a guest between the app and partners, and records every gate scan. It deliberately does not hold bookings, rewards or payments: those belong to partners.
Repository: core (plus core/keycloak-extension, a separate jar deployed into Keycloak).
Tech stack
Section titled “Tech stack”- Language
- Java 25
- Framework
- Quarkus 3.39Quarkus REST (RESTEasy Reactive)JacksonHibernate ValidatorQuarkus SchedulerSmallRye Health
- Persistence
- PostgreSQL 18Hibernate ORM + PanacheFlywaySchema owned by Flyway; Hibernate only validates it. Postgres 18 is required for uuidv7().
- Security
- Quarkus OIDC (Keycloak JWT)Bearer-token filtersSHA-256 hashing
- Outbound HTTP
- java.net.http.HttpClient5 s timeouts; HTTPS only (localhost excepted).
- Build
- MavenGraalVM / Mandrel native imageUBI 9 micro container
- Runs on
- DigitalOcean App PlatformDigitalOcean Managed Postgres
- Tests
- JUnit (Quarkus)REST AssuredSmallRye JWT (test tokens)Postgres Dev Services
Inside core
Section titled “Inside core”flowchart TB
classDef ours fill:#13686C,stroke:#0B4A4D,color:#ffffff
classDef area fill:#E3F4F3,stroke:#13686C,color:#12191B
classDef ext fill:#F3ECDF,stroke:#B9A88B,color:#12191B
classDef staff fill:#FDE9E2,stroke:#E9724C,color:#12191B
classDef store fill:#ffffff,stroke:#13686C,color:#12191B
classDef job fill:#FFF4E8,stroke:#E9724C,color:#12191B
app["Guest app"]:::ours
admin["Admin console"]:::staff
partners["Gate · Booking engine<br/>Sota Platforms"]:::ext
kchook["Keycloak extension"]:::ext
subgraph core["Core (Quarkus)"]
oidc["<b>OIDC</b><br/>Keycloak JWT, azp=trident-app"]:::area
filters["<b>Bearer-token filters</b><br/>one token per prefix,<br/>checked before routing"]:::area
appapi["<b>App API</b><br/>/api/v1/app"]:::ours
adminapi["<b>Admin API</b><br/>/api/v1/admin"]:::ours
partnerapi["<b>Partner APIs</b><br/>/gate · /booking<br/>/points · /keycloak"]:::ours
domain["<b>Domain</b> (active records)<br/>Customer · Qr · CheckIn · Handoff<br/>PointsTxn · PointsAccount · PointsEntry · PointsEvent<br/>Staff · StaffEvent"]:::area
jobs["<b>Scheduled</b><br/>HandoffPurge · PointsCheck<br/>CustomerSync · StaffBootstrap"]:::job
out["<b>Outbound HTTP</b><br/>KeycloakUsers<br/>SignOutNotifier"]:::area
end
db[("PostgreSQL 18")]:::store
kc["Keycloak<br/>Admin API"]:::ext
rev["Partners'<br/>/auth/revoke"]:::ext
app --> oidc --> appapi
admin --> filters
partners --> filters
kchook --> filters
filters --> adminapi
filters --> partnerapi
appapi --> domain
adminapi --> domain
partnerapi --> domain
jobs --> domain
domain --> db
appapi --> out
partnerapi --> out
out --> kc
out --> rev
API surface
Section titled “API surface”| Prefix | Caller | Auth | Endpoints |
|---|---|---|---|
/api/v1/app |
Guest app | Keycloak JWT, azp=trident-app |
POST /qrs · POST /handoffs · POST /widget-launches · GET /points · POST /logout |
/api/v1/admin |
Admin console Worker | Admin token + X-Finns-Actor-Sub |
GET /staff-access/{sub} · POST /staff-access/sign-ins · staff CRUD with If-Match · GET /customers · GET /customers/{sub} · POST /customers/{sub}/credits|debits (customers are addressed by Keycloak subject) |
/api/v1/gate |
Gates | Gate token | POST /check-ins |
/api/v1/booking |
Booking engine | Booking token | POST /handoffs/peek · POST /handoffs/redeem · POST /bookings |
/api/v1/points |
Engagement partner | Points token | GET /customers/{extId} · POST /credits|debits · POST /transactions/{id}/refund · GET /events · POST /widget-launches/redeem · GET /reconciliation/{date} |
/api/v1/keycloak |
Keycloak extension | Webhook token | POST /user-changes |
/q/health/ready |
Platform | none | Readiness, including the database |
Errors everywhere are RFC 9457 problem details with a stable code (see Integration conventions).
Background work
Section titled “Background work”| Job | Schedule | Multi-instance safety | What it does |
|---|---|---|---|
HandoffPurge |
every 60 s | Idempotent | Deletes one-time codes an hour after they expire |
PointsCheck |
02:30 Bali (0 30 18 * * ? UTC) |
Advisory lock, one instance | Recomputes every balance from the ledger; logs points.check_failed on any mismatch |
CustomerSync.reconcile |
every 10 min (1 min after boot) | Idempotent | Re-reads Keycloak users to repair missed webhooks and deletions |
StaffBootstrap |
at startup | Advisory lock | Seeds the first admins from FINNS_STAFF_BOOTSTRAP_ADMINS |
Keycloak extension
Section titled “Keycloak extension”A small Java jar (finns-core event listener, built against Keycloak 26.8) that tells core when a guest account changes, so core never has to poll for new members.
sequenceDiagram
autonumber
box rgb(246,241,233) Keycloak
participant KC as Keycloak
participant EXT as finns-core listener
end
box rgb(227,244,243) Core
participant CORE as Core
end
KC->>EXT: User event (REGISTER, LOGIN, UPDATE_PROFILE,<br/>UPDATE_EMAIL, DELETE_ACCOUNT, IdP link…)<br/>or admin event on users/<id>
Note over EXT: nowrap:Wait for Keycloak's<br/>transaction to commit
EXT-)CORE: POST /api/v1/keycloak/user-changes {userId}
alt Core can't reach Keycloak right now
CORE-->>EXT: 503
Note over EXT: nowrap:Retry after 1 s, 5 s, 30 s
else
CORE->>KC: GET /admin/realms/finns/users/{id} (finns-core-sync)
KC-->>CORE: User (or 404 if deleted)
Note over CORE: nowrap:Upsert customer, or mark it deleted.<br/>New customer → customer.created event
CORE-->>EXT: 204
end
The notice carries only the user id; core fetches the user itself, so a forged notice can’t inject data.
Configuration
Section titled “Configuration”Core reads its settings from environment variables and refuses to start if a required one is missing, a token is shorter than 32 characters, or a partner URL isn’t HTTPS.
| Variable | Purpose |
|---|---|
QUARKUS_DATASOURCE_* |
PostgreSQL connection |
FINNS_AUTH_ISSUER |
Keycloak realm URL; the app’s tokens must come from here |
FINNS_ADMIN_API_TOKEN |
Admin console’s token |
FINNS_GATE_API_TOKEN |
Gates’ token |
FINNS_BOOKING_API_TOKEN / FINNS_BOOKING_REVOKE_TOKEN / FINNS_BOOKING_URL |
Booking engine: inbound token, outbound revoke token, origin |
FINNS_POINTS_API_TOKEN |
Engagement partner’s token |
FINNS_WIDGET_URL / FINNS_WIDGET_REVOKE_TOKEN |
Engagement partner’s widget origin and revoke token |
FINNS_KEYCLOAK_WEBHOOK_TOKEN / FINNS_KEYCLOAK_CLIENT_SECRET |
Keycloak extension’s token; finns-core-sync secret |
FINNS_KEYCLOAK_RECONCILE_EVERY |
Reconciliation interval (default 10 min) |
FINNS_CORS_ORIGINS |
Origins allowed to call /api/v1/app from a browser (the app’s web build) |
FINNS_STAFF_BOOTSTRAP_ADMINS |
First admins’ emails |
Tunables with defaults: QR lifetime 60 s, handoff and widget code lifetime 60 s, earn rates (finns.points.earn.*), reconciliation time zone Asia/Makassar.
Engineering notes
Section titled “Engineering notes”- Active-record domain. Entities are Panache active records with plain id fields, no JPA associations; each use case is one explicit, short transaction.
- Concurrency by the database. Single-use codes are consumed by one conditional
UPDATE; balances move with a conditional update that doubles as the member’s lock; staff changes take an advisory lock; staff edits use optimistic locking (If-Match/412). - Outbox-style feed.
points_eventis written in the same transaction as the change and served in commit order belowpg_snapshot_xmin, so readers never miss a late commit. - No broker, no cache. At current scale every call is a few indexed queries. Revoke callbacks are async fire-and-forget with one retry.
- Logs are structured
key=valuelines without tokens or bodies. There are no metrics or traces yet.
Internal and confidential. Share partner sections only as partner builds (pnpm build:partner).

