* core, ui: plan per-server roles for self-hosted servers * core: add per-server roles field to UserServer * core: add nullable role columns to protocol_servers * core: persist per-server roles * core: validate server coverage using per-server roles * test: cover per-server roles resolution and coverage * multiplatform: per-server role toggles for self-hosted servers * ios: per-server role toggles for self-hosted servers * core: per-server role overrides with per-role defaults * test: cover three-state per-server role resolution * fix: derive Eq for ServerRolesOverride * multiplatform: three-state role dropdowns on saved servers * ios: three-state role pickers on saved servers * test: per-server roles independent across two servers * test: enable names role in name-resolution tests * style: trim comments in per-server roles code * chore: rename server_roles migration to 20260716 (last) * core: per-server roles override operator roles, inherit when unset * multiplatform: per-server role default inherits from operator * ios: per-server role default inherits from operator * refactor(servers): tidy per-server roles per review - dedup no-operator default into ServerRoles.noOperatorDefault (Kotlin/Swift) - iOS: move roles-section control flow to the call site via a named gate, and parse the server address once instead of up to three times - Kotlin: collapse redundant derivedState; revert defaultOn->default rename for cross-platform parity - drop unused Hashable conformance on Swift ServerRoles - add agentServerCfgs test for names inheritance from an operator - remove no-op enableNamesRole calls from dormant DirectoryTests - fix ChatClient import ordering * chore(migration): date server_roles migration 20260720 * only show roles when server is enabled, move section above QR code --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
21 KiB
Per-server roles for self-hosted servers — Implementation Plan
For agentic workers: REQUIRED: Use superpowers-extended-cc:subagent-driven-development (if subagents available) or superpowers-extended-cc:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give each individual self-hosted SMP server its own receive / private-routing / name-resolution toggles, stored per server and plumbed to the agent.
Architecture: Add roles :: Maybe ServerRoles to simplex-chat's UserServer'; store it in three nullable protocol_servers columns; resolve Maybe → ServerRoles in agentServerCfgs (default storage+proxy on, names off). No simplexmq change. Add the three toggles to custom SMP server screens on Kotlin and Swift.
Tech Stack: Haskell (simplex-chat), SQLite + PostgreSQL migrations, Kotlin (multiplatform), Swift (iOS). Tests via cabal test (HSpec).
Design doc: plans/2026-07-14-self-hosted-server-roles-product.md
Companion checkout: simplex-chat builds against simplexmq pinned in cabal.project:24; the working ../simplexmq already contains ServerRoles{storage,proxy,names}. To build/test locally against it, uncomment packages: . ../simplexmq (cabal.project:2). No simplexmq edits are made by this plan.
Chunk 1: Haskell backend (types, resolution, DB, store, validation, tests)
Task 1: Add per-server roles to UserServer' and resolve it in agentServerCfgs
Files:
-
Modify:
src/Simplex/Chat/Operators.hs:242-250(type),:331-333(constructor),:438-448(resolution), add constant nearoperatorRoles(:174) -
Step 1: Add the field to
UserServer'(afterenabled, beforedeleted)
data UserServer' s (p :: ProtocolType) = UserServer
{ serverId :: DBEntityId' s,
server :: ProtoServerWithAuth p,
preset :: Bool,
tested :: Maybe Bool,
enabled :: Bool,
roles :: Maybe ServerRoles,
deleted :: Bool
}
- Step 2: Add the default constant (next to
operatorRoles,:174)
-- Default roles for a self-hosted server without stored roles: receive + private
-- routing on, name resolution off. Also the default for newly added servers.
defaultUserServerRoles :: ServerRoles
defaultUserServerRoles = ServerRoles {storage = True, proxy = True, names = False}
- Step 3: Set
roles = Nothingin the sole constructornewUserServer_(:333)
newUserServer_ preset enabled server =
UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, roles = Nothing, deleted = False}
- Step 4: Resolve per-server roles in
agentServerCfgs(:442-448). Bind the field via record pattern (noOverloadedRecordDot; barerolesselector is ambiguous withServerCfg.roles).
agentServer srv@UserServer {server, enabled, roles = srvRoles} =
case find (\(d, _) -> any (matchingHost d) (srvHost srv)) opDomains of
Just (_, op@ServerOperator {operatorId = DBEntityId opId, enabled = opEnabled})
| opEnabled -> Just ServerCfg {server, enabled, operator = Just opId, roles = operatorRoles p op}
| otherwise -> Nothing
Nothing ->
Just ServerCfg {server, enabled, operator = Nothing, roles = fromMaybe defaultUserServerRoles srvRoles}
- Step 5: Drop the now-unused
allRolesimport.allRoles(Operators.hs:52) had its only use at:448, which Step 4 replaces. Remove it from the import list to avoid an unused-import warning:
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..))
- Step 6: Build.
fromMaybeis already imported inOperators.hs.ServerRoles(..)is imported (:52).
Run: cabal build lib:simplex-chat --ghc-options -O0
Expected: compile errors only at other UserServer{...} construction sites — none exist beyond newUserServer_ and getProtocolServers (fixed in Task 3). Record patterns elsewhere (Operators.hs:410,416,532,537,538,568, Profiles.hs:981) are unaffected.
- Step 7: Commit
feat(servers): add per-server roles field to UserServer
Task 2: Database migrations (SQLite + Postgres) + schema
Files:
-
Create:
src/Simplex/Chat/Store/SQLite/Migrations/M20260714_server_roles.hs -
Create:
src/Simplex/Chat/Store/Postgres/Migrations/M20260714_server_roles.hs -
Modify:
src/Simplex/Chat/Store/SQLite/Migrations.hs(import + list),src/Simplex/Chat/Store/Postgres/Migrations.hs(import + list) -
Modify:
src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql,src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql(regenerated, see Step 5) -
Step 1: SQLite migration (template:
M20260707_file_digest.hs,M20260603_simplex_name.hs)
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260714_server_roles where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20260714_server_roles :: Query
m20260714_server_roles =
[sql|
ALTER TABLE protocol_servers ADD COLUMN role_storage INTEGER;
ALTER TABLE protocol_servers ADD COLUMN role_proxy INTEGER;
ALTER TABLE protocol_servers ADD COLUMN role_names INTEGER;
|]
down_m20260714_server_roles :: Query
down_m20260714_server_roles =
[sql|
ALTER TABLE protocol_servers DROP COLUMN role_storage;
ALTER TABLE protocol_servers DROP COLUMN role_proxy;
ALTER TABLE protocol_servers DROP COLUMN role_names;
|]
- Step 2: Postgres migration (template: Postgres
M20260707_file_digest.hs;[r|...|]andText)
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20260714_server_roles where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20260714_server_roles :: Text
m20260714_server_roles =
[r|
ALTER TABLE protocol_servers ADD COLUMN role_storage SMALLINT;
ALTER TABLE protocol_servers ADD COLUMN role_proxy SMALLINT;
ALTER TABLE protocol_servers ADD COLUMN role_names SMALLINT;
|]
down_m20260714_server_roles :: Text
down_m20260714_server_roles =
[r|
ALTER TABLE protocol_servers DROP COLUMN role_storage;
ALTER TABLE protocol_servers DROP COLUMN role_proxy;
ALTER TABLE protocol_servers DROP COLUMN role_names;
|]
- Step 3: Register SQLite migration (
SQLite/Migrations.hs): addimport ...M20260714_server_rolesafter theM20260707_file_digestimport (:166), and append to the list after the20260707_file_digestentry (:330) — add a comma to that line:
("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest),
("20260714_server_roles", m20260714_server_roles, Just down_m20260714_server_roles)
-
Step 4: Register Postgres migration (
Postgres/Migrations.hs): mirror Step 3 at the import (:43) and list (:84). -
Step 5: Regenerate reference schema. The repo keeps
chat_schema.sqlin sync with migrations (see the "ci: update query plans" commits). Regenerate both SQLite and Postgreschat_schema.sqlusing the repo's schema-dump script rather than hand-editing. If no script is found, hand-add the three columns to theprotocol_serversblock in bothchat_schema.sqlfiles (SQLiteINTEGER, Postgressmallint).
Run: cabal build lib:simplex-chat --ghc-options -O0
Expected: PASS.
- Step 6: Commit
feat(servers): add nullable role columns to protocol_servers
Task 3: Store read/write of roles (Profiles.hs)
Files:
-
Modify:
src/Simplex/Chat/Store/Profiles.hs:639-654(select/read),:656-667(insert),:669-679(update) -
Step 1: Read roles in
getProtocolServers. Extend the SELECT andtoUserServer. Store roles as three nullableBoolIntcolumns; reconstructMaybe ServerRoles(all present →Just, elseNothing).
MUST split with :.. The existing select is 8 columns → an 8-tuple. Adding 3 gives 11, but the SQLite/Postgres FromRow instances cap flat tuples at 10 — an 11-element flat tuple has no instance and will not compile. Parse the 3 role columns as a trailing group via :..
[sql|
SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled,
role_storage, role_proxy, role_names
FROM protocol_servers
WHERE user_id = ? AND protocol = ?
|]
toUserServer :: ((DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, BoolInt, Maybe BoolInt, BoolInt) :. (Maybe BoolInt, Maybe BoolInt, Maybe BoolInt)) -> UserServer p
toUserServer ((serverId, host, port, keyHash, auth_, BI preset, tested, BI enabled) :. (rStorage, rProxy, rNames)) =
let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
roles = ServerRoles <$> (unBI <$> rStorage) <*> (unBI <$> rProxy) <*> (unBI <$> rNames)
in UserServer {serverId, server, preset, tested = unBI <$> tested, enabled, roles, deleted = False}
ServerRoles <$> mA <*> mB <*> mC :: Maybe ServerRoles (Applicative on Maybe) yields Just only when all three are present, else Nothing — matching the all-or-none storage. Constructor arg order (storage, proxy, names) matches the column order. Ensure :. (from the DB simple package) and ServerRoles(..) are imported in Profiles.hs.
- Step 2: Write roles in
insertProtocolServer. Add the columns and values (extractrolesvia record pattern):
insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled, roles} = do
DB.execute
db
[sql|
INSERT INTO protocol_servers
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled,
role_storage, role_proxy, role_names, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
(serverColumns p server :. (BI preset, BI <$> tested, BI enabled) :. roleColumns roles :. (userId, ts, ts))
- Step 3: Write roles in
updateProtocolServersimilarly:
updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabled, roles} =
DB.execute
db
[sql|
UPDATE protocol_servers
SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?,
preset = ?, tested = ?, enabled = ?,
role_storage = ?, role_proxy = ?, role_names = ?, updated_at = ?
WHERE smp_server_id = ?
|]
(serverColumns p server :. (BI preset, BI <$> tested, BI enabled) :. roleColumns roles :. (ts, serverId))
- Step 4: Add the
roleColumnshelper (private, inProfiles.hs). Map each selector over theMaybe— no two-branchcase, so the all-or-none property is structural (aNothingmaps to threeNothings automatically):
roleColumns :: Maybe ServerRoles -> (Maybe BoolInt, Maybe BoolInt, Maybe BoolInt)
roleColumns mr = (BI . storage <$> mr, BI . proxy <$> mr, BI . names <$> mr)
(storage/proxy/names are unambiguous selectors — unique to ServerRoles. If a future duplicate makes them ambiguous under DuplicateRecordFields, fall back to a punned \case.)
- Step 5: Build.
cabal build lib:simplex-chat --ghc-options -O0→ PASS. - Step 6: Commit
feat(servers): persist per-server roles
Task 4: Update validation for the custom (self-hosted) bucket
Files:
- Modify:
src/Simplex/Chat/Operators.hs:524-532(noServersErrs,hasRole),:564-569(noNamesServersWarns,namesEnabled)
This task fuses two parallel role-coverage helpers into one. hasRole (:531, storage/proxy) and namesEnabled (:569, names) are the same group-level check specialised to different role selectors, both returning True for the custom bucket. They cannot express per-server roles, so both are replaced by a single role-parameterised hasRoleCoverage.
Preserve (do not touch): noServers + srvEnabled (still used for the USENoServers empty-check at :526) and opEnabled (also used by noChatRelaysWarns, :560). Remove: hasRole and namesEnabled.
- Step 1: Make coverage checks per-server-role-aware for the no-operator bucket. Today
hasRole/namesEnabledreturnTruefor the custom bucket (operator' = Nothing) and are group-levelu -> Boolfilters combined withnoServers cond = not $ any srvEnabled $ userServers p $ filter cond uss. Group-level filtering cannot express per-server roles for the custom bucket — the evaluation must move to the per-server level.
Effective-role helper — takes the protocol singleton (operatorRoles needs it; noServersErrs runs for BOTH SMP and XFTP at :522-523, so hardcoding SPSMP would read smpRoles for XFTP operator servers — a bug):
-- effective role for coverage: operator servers use operator roles (per protocol),
-- self-hosted servers use per-server roles (default when unset).
serverHasRole :: UserProtocol p => SProtocolType p -> (ServerRoles -> Bool) -> Maybe ServerOperator -> Maybe ServerRoles -> Bool
serverHasRole p roleSel op srvRoles = case op of
Just o@ServerOperator {enabled} -> enabled && roleSel (operatorRoles p o)
Nothing -> roleSel (fromMaybe defaultUserServerRoles srvRoles)
Restructure the checks to per-server evaluation. For a role selector, "coverage exists" = any enabled, non-deleted server in any bucket whose effective role is on. Per bucket u the operator is operator' u and each server's roles come from AUS _ UserServer{enabled, deleted, roles} (the roles field is reachable — same pattern already used at :532,:568):
hasRoleCoverage :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> (ServerRoles -> Bool) -> [u] -> Bool
hasRoleCoverage p roleSel =
any (\u -> any (srvOk (operator' u)) (map aUserServer' (servers' p u)))
where
srvOk op (AUS _ UserServer {enabled, deleted, roles}) =
enabled && not deleted && serverHasRole p roleSel op roles
Then rewrite noServersErrs (:527) storage/proxy branches as [USEStorageMissing p' user | not (hasRoleCoverage p storage uss)] <> [USEProxyMissing p' user | not (hasRoleCoverage p proxy uss)] (keep the noServers opEnabled empty-check at :526 as-is), and noNamesServersWarns (:565) as [USWNoNamesServers user | not (hasRoleCoverage SPSMP names uss)]. Keep the exact constructors (USEStorageMissing, USEProxyMissing, USWNoNamesServers). XFTP names is never checked (names coverage is SMP-only).
Deliberate duplication (do not try to unify with agentServerCfgs). Both this helper and agentServerCfgs (Task 1) resolve "operator-vs-self-hosted → effective roles", but their semantics for a disabled operator differ: agentServerCfgs drops the server entirely (opEnabled … | otherwise -> Nothing), whereas coverage treats it as contributing no roles while USENoServers handles existence. They also run over different shapes ([ServerCfg] per user vs. [UserOperatorServers] across users). Unifying them would grow the blast radius for no clarity gain; the shared logic is a 2-line case — leave it in both. (Flag for future: if a third consumer appears, extract effectiveServerRoles :: SProtocolType p -> Maybe ServerOperator -> Maybe ServerRoles -> Maybe ServerRoles.)
- Step 2: Build + reason through each call site. Ensure
noServers/noNamesServersnow consult per-server roles for the custom bucket.cabal build lib:simplex-chat --ghc-options -O0→ PASS. - Step 3: Commit
fix(servers): validate coverage using per-server roles
Task 5: Haskell tests
Files:
-
Modify/Create: server-config test module (locate the existing
agentServerCfgs/ operators test; e.g. undertests/— searchagentServerCfgs,validateUserServers,Operators). If none, addtests/ChatTests/ServerRolesTest.hsand register it in the test runner. -
Step 1: Write failing tests.
agentServerCfgsfor a self-hosted SMP server withroles = Just (ServerRoles True False True)→ServerCfg.roles == ServerRoles True False True,operator == Nothing.- self-hosted with
roles = Nothing→ServerCfg.roles == defaultUserServerRoles(names == False). - operator-matched server ignores per-server
roles→ usesoperatorRoles. - store round-trip: insert a self-hosted server with
Justroles, read back equal; insert withNothing, read backNothing. validateUserServers: custom-bucket-only user with all serversnames = False→USWNoNamesServerspresent; enablingnameson one → absent.
-
Step 2: Run, expect FAIL.
cabal test --test-option=--match="/ServerRoles/" -
Step 3: (Implementation already done in Tasks 1–4.) Run, expect PASS.
-
Step 4: Commit
test(servers): cover per-server roles resolution and storage
Chunk 2: UI (Kotlin + Swift)
Task 6: Kotlin (Desktop/Android) model + toggles
Files:
-
Modify:
apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt:4787(UserServer),:4808,4818-4845(sample/empty), and.../networkAndServers/ScanProtocolServer.kt:22 -
Modify:
.../networkAndServers/ProtocolServerView.kt(UseServerSection/CustomServer, ~:161-234) and.../networkAndServers/NewServerView.kt -
Reference:
.../networkAndServers/OperatorView.kt:260-320(toggle pattern),ServerRoles(SimpleXAPI.kt:4636) -
Step 1: Add
rolestoUserServerwith default to avoid breaking constructors:
val roles: ServerRoles? = null,
Place it before deleted; update the primary constructor and the named-arg call sites (empty :4808, samples :4818-4845, ScanProtocolServer.kt:22). Since it has a default, unchanged call sites still compile.
-
Step 2: Add the toggles to custom SMP servers. In
ProtocolServerView.kt, insideCustomServer/UseServerSection, whenserver.protocol == smp && !server.preset, add aSectionView(MR.strings.operator_use_for_messages)with threePreferenceTogglerows bound to the server's roles (receive →storage, private routing →proxy, names →names), mirroringOperatorView.kt:261-320. Editing writes back a newServerRolesonto the editedUserServerstate. Default the displayed roles toServerRoles(storage = true, proxy = true, names = false)whenroles == null.- Strings:
operator_use_for_messages,operator_use_for_messages_receiving,operator_use_for_messages_private_routing,operator_use_for_names(already exist,strings.xml:2195-2198). - Do NOT show for XFTP or preset/operator servers.
- Strings:
-
Step 3: New-server default. In
NewServerView.kt, construct the new SMPUserServerwithroles = ServerRoles(storage = true, proxy = true, names = false)so the toggles show the default and persist on save. -
Step 4: Build. Compile the multiplatform common module (repo's gradle/build command).
-
Step 5: Commit
feat(servers): per-server role toggles on Android/desktop
Task 7: Swift (iOS) model + toggles
Files:
-
Modify:
apps/ios/Shared/Model/AppAPITypes.swift:1953(UserServerstruct +CodingKeys:2017) -
Modify:
apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift(customServer()/useServerSection:88,123) andNewServerView.swift -
Reference:
.../NetworkAndServers/OperatorView.swift:106-114(toggle pattern),ServerRoles(AppAPITypes.swift:1807) -
Step 1: Add
rolestoUserServer.
var roles: ServerRoles?
Add case roles to CodingKeys (:2017). Update initializers / sample data accordingly.
-
Step 2: Add the toggles to custom SMP servers. In
ProtocolServerView.swift, insidecustomServer()/useServerSection, when the server is SMP and!preset, add aSection("Use for messages")with threeToggles bound to the server's roles ("To receive"→storage,"For private routing"→proxy,"To resolve names"→names), mirroringOperatorView.swift:106-114. Bind to the edited server'sroles, defaulting toServerRoles(storage: true, proxy: true, names: false)whennil.- iOS string keys are the literal English text (already used by
OperatorView.swift);"To resolve names"may still need adding toLocalizable.stringstranslations (English == key). - Do NOT show for XFTP or preset/operator servers.
- iOS string keys are the literal English text (already used by
-
Step 3: New-server default. In
NewServerView.swift, default the new SMP server'srolestoServerRoles(storage: true, proxy: true, names: false). -
Step 4: Build the iOS target (or type-check the changed files).
-
Step 5: Commit
feat(servers): per-server role toggles on iOS
Verification (whole feature)
cabal test --test-option=--match="/ServerRoles/"passes.- Full backend build:
cabal build --ghc-options -O0. - Migration up/down: apply and roll back
20260714_server_roleson a copy DB (SQLite + Postgres); confirm existing rows read asroles = Nothing→ resolved todefaultUserServerRoles. - Manual UI: add two self-hosted SMP servers, set different role toggles on each, save, reopen — toggles are independent and persisted; name resolution defaults off; toggles absent on XFTP and operator servers.
- Round-trip via API:
APIGetUserServersreturnsrolesfor edited servers and omits it (Nothing) for untouched ones.