Merge branch 'master' into f/channel-multi-owner

This commit is contained in:
spaced4ndy
2026-07-21 14:50:52 +04:00
22 changed files with 845 additions and 59 deletions
+14 -3
View File
@@ -1813,6 +1813,15 @@ struct ServerRoles: Equatable, Codable {
var storage: Bool
var proxy: Bool
var names: Bool
// roles applied when a server matches no operator, mirrors core resolveServerRoles (Operators.hs)
static let noOperatorDefault = ServerRoles(storage: true, proxy: true, names: false)
}
struct ServerRolesOverride: Equatable, Codable, Hashable {
var storage: Bool?
var proxy: Bool?
var names: Bool?
}
struct UserOperatorServers: Identifiable, Equatable, Codable {
@@ -1839,8 +1848,8 @@ struct UserOperatorServers: Identifiable, Equatable, Codable {
serverDomains: [],
conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false),
enabled: false,
smpRoles: ServerRoles(storage: true, proxy: true, names: true),
xftpRoles: ServerRoles(storage: true, proxy: true, names: false)
smpRoles: ServerRoles.noOperatorDefault,
xftpRoles: ServerRoles.noOperatorDefault
)
}
set { `operator` = newValue }
@@ -1962,11 +1971,12 @@ struct UserServer: Identifiable, Equatable, Codable, Hashable {
var tested: Bool?
var enabled: Bool
var deleted: Bool
var roles: ServerRolesOverride = ServerRolesOverride()
var createdAt = Date()
static func == (l: UserServer, r: UserServer) -> Bool {
l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested &&
l.enabled == r.enabled && l.deleted == r.deleted
l.enabled == r.enabled && l.deleted == r.deleted && l.roles == r.roles
}
var id: String { "\(server) \(createdAt)" }
@@ -2026,6 +2036,7 @@ struct UserServer: Identifiable, Equatable, Codable, Hashable {
case tested
case enabled
case deleted
case roles
}
}
@@ -81,6 +81,9 @@ struct ProtocolServerView: View {
.textSelection(.enabled)
}
useServerSection(true)
if let inherited = serverRolesInherited {
serverRolesSection(inherited: inherited)
}
}
}
}
@@ -110,6 +113,9 @@ struct ProtocolServerView: View {
}
}
useServerSection(valid)
if let inherited = serverRolesInherited {
serverRolesSection(inherited: inherited)
}
if valid {
Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) {
MutableQRCode(uri: $serverToEdit.server, small: true)
@@ -120,6 +126,33 @@ struct ProtocolServerView: View {
}
}
// inherited SMP roles for the per-server roles section, nil when the section should not be shown
private var serverRolesInherited: ServerRoles? {
guard let (serverProtocol, serverOperator) = serverProtocolAndOperator(serverToEdit, userServers),
serverProtocol == .smp && serverToEdit.enabled, !serverToEdit.preset || serverToEdit.roles != ServerRolesOverride()
else { return nil }
return serverOperator?.smpRoles ?? ServerRoles.noOperatorDefault
}
private func serverRolesSection(inherited: ServerRoles) -> some View {
Section {
rolePicker("To receive", $serverToEdit.roles.storage, defaultOn: inherited.storage)
rolePicker("For private routing", $serverToEdit.roles.proxy, defaultOn: inherited.proxy)
rolePicker("To resolve names", $serverToEdit.roles.names, defaultOn: inherited.names)
} header: {
Text("Use for messages").foregroundColor(theme.colors.secondary)
}
}
private func rolePicker(_ title: LocalizedStringKey, _ selection: Binding<Bool?>, defaultOn: Bool) -> some View {
Picker(title, selection: selection) {
Text(String.localizedStringWithFormat(NSLocalizedString("default (%@)", comment: "pref value"), NSLocalizedString(defaultOn ? "yes" : "no", comment: "pref value"))).tag(Bool?.none)
Text("yes").tag(Bool?.some(true))
Text("no").tag(Bool?.some(false))
}
.frame(height: 36)
}
private func useServerSection(_ valid: Bool) -> some View {
Section(header: Text("Use server").foregroundColor(theme.colors.secondary)) {
HStack {
@@ -4647,6 +4647,18 @@ data class ServerRoles(
val storage: Boolean,
val proxy: Boolean,
val names: Boolean
) {
companion object {
// roles applied when a server matches no operator, mirrors core resolveServerRoles (Operators.hs)
val noOperatorDefault = ServerRoles(storage = true, proxy = true, names = false)
}
}
@Serializable
data class ServerRolesOverride(
val storage: Boolean? = null,
val proxy: Boolean? = null,
val names: Boolean? = null
)
@Serializable
@@ -4668,8 +4680,8 @@ data class UserOperatorServers(
serverDomains = emptyList(),
conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false),
enabled = false,
smpRoles = ServerRoles(storage = true, proxy = true, names = true),
xftpRoles = ServerRoles(storage = true, proxy = true, names = false)
smpRoles = ServerRoles.noOperatorDefault,
xftpRoles = ServerRoles.noOperatorDefault
)
companion object {
@@ -4801,7 +4813,8 @@ data class UserServer(
val preset: Boolean,
val tested: Boolean? = null,
val enabled: Boolean,
val deleted: Boolean
val deleted: Boolean,
val roles: ServerRolesOverride = ServerRolesOverride(),
) {
@Transient
private val createdAt: Date = Date()
@@ -19,6 +19,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -88,6 +89,7 @@ fun ProtocolServerView(
ProtocolServerLayout(
draftServer,
serverProtocol,
userServers,
testing.value,
testServer = {
testing.value = true
@@ -120,6 +122,7 @@ fun ProtocolServerView(
private fun ProtocolServerLayout(
server: MutableState<UserServer>,
serverProtocol: ServerProtocol,
userServers: MutableState<List<UserOperatorServers>>,
testing: Boolean,
testServer: () -> Unit,
onDelete: () -> Unit,
@@ -130,7 +133,7 @@ private fun ProtocolServerLayout(
if (server.value.preset) {
PresetServer(server, testing, testServer)
} else {
CustomServer(server, testing, testServer, onDelete)
CustomServer(server, testing, testServer, onDelete, serverProtocol, userServers)
}
SectionBottomSpacer()
}
@@ -164,15 +167,11 @@ fun CustomServer(
testing: Boolean,
testServer: () -> Unit,
onDelete: (() -> Unit)?,
serverProtocol: ServerProtocol? = null,
userServers: MutableState<List<UserOperatorServers>>? = null
) {
val serverAddress = remember { mutableStateOf(server.value.server) }
val valid = remember {
derivedStateOf {
with(parseServerAddress(serverAddress.value)) {
this?.valid == true
}
}
}
val valid = remember { derivedStateOf { parseServerAddress(serverAddress.value)?.valid == true } }
SectionView(
stringResource(MR.strings.smp_servers_your_server_address),
icon = painterResource(MR.images.ic_error),
@@ -196,6 +195,12 @@ fun CustomServer(
UseServerSection(server, valid.value, testing, testServer, onDelete)
val op = remember(server.value.server) { serverProtocolAndOperator(server.value, userServers?.value ?: listOf())?.second }
if (serverProtocol == ServerProtocol.SMP && server.value.enabled && (!server.value.preset || server.value.roles != ServerRolesOverride())) {
SectionDividerSpaced()
ServerRolesSection(server, op?.smpRoles ?: ServerRoles.noOperatorDefault)
}
if (valid.value) {
SectionDividerSpaced()
SectionView(stringResource(MR.strings.smp_servers_add_to_another_device)) {
@@ -204,6 +209,38 @@ fun CustomServer(
}
}
@Composable
private fun ServerRolesSection(server: MutableState<UserServer>, inherited: ServerRoles) {
SectionView(stringResource(MR.strings.operator_use_for_messages)) {
RoleDropDown(stringResource(MR.strings.operator_use_for_messages_receiving), server.value.roles.storage, defaultOn = inherited.storage) {
server.value = server.value.copy(roles = server.value.roles.copy(storage = it))
}
RoleDropDown(stringResource(MR.strings.operator_use_for_messages_private_routing), server.value.roles.proxy, defaultOn = inherited.proxy) {
server.value = server.value.copy(roles = server.value.roles.copy(proxy = it))
}
RoleDropDown(stringResource(MR.strings.operator_use_for_names), server.value.roles.names, defaultOn = inherited.names) {
server.value = server.value.copy(roles = server.value.roles.copy(names = it))
}
}
}
@Composable
private fun RoleDropDown(title: String, value: Boolean?, defaultOn: Boolean, onSelected: (Boolean?) -> Unit) {
val values = remember(defaultOn, appPrefs.appLanguage.state.value) {
listOf(
null to String.format(generalGetString(MR.strings.chat_preferences_default), generalGetString(if (defaultOn) MR.strings.chat_preferences_yes else MR.strings.chat_preferences_no)),
true to generalGetString(MR.strings.chat_preferences_yes),
false to generalGetString(MR.strings.chat_preferences_no)
)
}
ExposedDropDownSettingRow(
title,
values,
rememberUpdatedState(value),
onSelected = onSelected
)
}
@Composable
private fun UseServerSection(
server: MutableState<UserServer>,
@@ -0,0 +1,342 @@
# 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 near `operatorRoles` (`:174`)
- [ ] **Step 1: Add the field to `UserServer'`** (after `enabled`, before `deleted`)
```haskell
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`)
```haskell
-- 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 = Nothing` in the sole constructor** `newUserServer_` (`:333`)
```haskell
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 (no `OverloadedRecordDot`; bare `roles` selector is ambiguous with `ServerCfg.roles`).
```haskell
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 `allRoles` import.** `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:
```haskell
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..))
```
- [ ] **Step 6: Build.** `fromMaybe` is already imported in `Operators.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`)
```haskell
{-# 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|...|]` and `Text`)
```haskell
{-# 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`): add `import ...M20260714_server_roles` after the `M20260707_file_digest` import (`:166`), and append to the list after the `20260707_file_digest` entry (`:330`) — add a comma to that line:
```haskell
("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.sql` in sync with migrations (see the "ci: update query plans" commits). Regenerate both SQLite and Postgres `chat_schema.sql` using the repo's schema-dump script rather than hand-editing. If no script is found, hand-add the three columns to the `protocol_servers` block in both `chat_schema.sql` files (SQLite `INTEGER`, Postgres `smallint`).
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 and `toUserServer`. Store roles as three nullable `BoolInt` columns; reconstruct `Maybe ServerRoles` (all present → `Just`, else `Nothing`).
**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 `:.`.
```haskell
[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 = ?
|]
```
```haskell
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 (extract `roles` via record pattern):
```haskell
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 `updateProtocolServer`** similarly:
```haskell
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 `roleColumns` helper** (private, in `Profiles.hs`). Map each selector over the `Maybe` — no two-branch `case`, so the all-or-none property is structural (a `Nothing` maps to three `Nothing`s automatically):
```haskell
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`/`namesEnabled` return `True` for the custom bucket (`operator' = Nothing`) and are group-level `u -> Bool` filters combined with `noServers 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):
```haskell
-- 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`):
```haskell
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`/`noNamesServers` now 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. under `tests/` — search `agentServerCfgs`, `validateUserServers`, `Operators`). If none, add `tests/ChatTests/ServerRolesTest.hs` and register it in the test runner.
- [ ] **Step 1: Write failing tests.**
- `agentServerCfgs` for a self-hosted SMP server with `roles = 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` → uses `operatorRoles`.
- store round-trip: insert a self-hosted server with `Just` roles, read back equal; insert with `Nothing`, read back `Nothing`.
- `validateUserServers`: custom-bucket-only user with all servers `names = False``USWNoNamesServers` present; enabling `names` on one → absent.
- [ ] **Step 2: Run, expect FAIL.** `cabal test --test-option=--match="/ServerRoles/"`
- [ ] **Step 3: (Implementation already done in Tasks 14.)** 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 `roles` to `UserServer`** with default to avoid breaking constructors:
```kotlin
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`, inside `CustomServer` / `UseServerSection`, when `server.protocol == smp && !server.preset`, add a `SectionView(MR.strings.operator_use_for_messages)` with three `PreferenceToggle` rows bound to the server's roles (receive → `storage`, private routing → `proxy`, names → `names`), mirroring `OperatorView.kt:261-320`. Editing writes back a new `ServerRoles` onto the edited `UserServer` state. Default the displayed roles to `ServerRoles(storage = true, proxy = true, names = false)` when `roles == 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.
- [ ] **Step 3: New-server default.** In `NewServerView.kt`, construct the new SMP `UserServer` with `roles = 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` (`UserServer` struct + `CodingKeys` `:2017`)
- Modify: `apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift` (`customServer()` / `useServerSection` `:88,123`) and `NewServerView.swift`
- Reference: `.../NetworkAndServers/OperatorView.swift:106-114` (toggle pattern), `ServerRoles` (`AppAPITypes.swift:1807`)
- [ ] **Step 1: Add `roles` to `UserServer`.**
```swift
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`, inside `customServer()` / `useServerSection`, when the server is SMP and `!preset`, add a `Section("Use for messages")` with three `Toggle`s bound to the server's roles (`"To receive"``storage`, `"For private routing"``proxy`, `"To resolve names"``names`), mirroring `OperatorView.swift:106-114`. Bind to the edited server's `roles`, defaulting to `ServerRoles(storage: true, proxy: true, names: false)` when `nil`.
- iOS string keys are the literal English text (already used by `OperatorView.swift`); `"To resolve names"` may still need adding to `Localizable.strings` translations (English == key).
- Do NOT show for XFTP or preset/operator servers.
- [ ] **Step 3: New-server default.** In `NewServerView.swift`, default the new SMP server's `roles` to `ServerRoles(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_roles` on a copy DB (SQLite + Postgres); confirm existing rows read as `roles = Nothing` → resolved to `defaultUserServerRoles`.
- [ ] 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: `APIGetUserServers` returns `roles` for edited servers and omits it (Nothing) for untouched ones.
@@ -0,0 +1,138 @@
# Per-server roles for self-hosted servers — Product / Design
**Date:** 2026-07-14
**Status:** Design (approved for planning)
**Scope:** simplex-chat backend + DB + Desktop/Android (Kotlin) + iOS (Swift). **No simplexmq changes.**
## Problem
Self-hosted SMP servers cannot be configured individually for what they are used
for. The three capabilities — **receiving** messages (`storage` role), **private
routing** (`proxy` role), and **name resolution** (`names` role) — are controlled
only per *operator* (SimpleX / Flux), via the operator toggles in `OperatorView`.
Servers the user adds themselves fall into the "custom" / no-operator bucket and
are hard-coded to `allRoles` (all three on) in `agentServerCfgs`
(`src/Simplex/Chat/Operators.hs:448`). There is no UI, storage, or type to give a
single self-hosted server a subset of roles.
This is a problem because a self-hosted server is usually not a name resolver
(name resolution needs an Ethereum/SNRC endpoint via `[NAMES] enable: on` on the
smp-server). Today such a server is still offered to the agent as names-capable,
so a name lookup can be routed to it and fail. Users also may want a self-hosted
server used only for receiving, or only for private routing.
## Goal
Give **each individual added self-hosted SMP server** its own independent toggles:
**To receive** / **For private routing** / **To resolve names**, stored per server
and plumbed to the agent as that server's `ServerRoles`.
## Current state (verified)
- **Agent (simplexmq) — already complete.** `ServerCfg { server, operator :: Maybe
OperatorId, enabled, roles :: ServerRoles }` and `ServerRoles { storage, proxy,
names }` exist (`Simplex/Messaging/Agent/Env/SQLite.hs:98,106`). The agent
partitions servers into `storageSrvs / proxySrvs / nameSrvs` in `mkUserServers`
and consumes them (`getNextServer`, `getSMPProxyClient`, `getNextNameServer` /
`resolveName` / RSLV). The agent always receives **concrete** `ServerRoles` and
never applies defaults.
- **simplex-chat backend.** `UserServer'` (`Operators.hs:242`) has **no roles
field**. Roles are resolved only in `agentServerCfgs` (`Operators.hs:438-448`):
operator servers → `operatorRoles p op` (read from `server_operators` columns);
self-hosted servers → hard-coded `allRoles`.
- **DB.** `protocol_servers` (`chat_schema.sql:548`) stores all user servers with
only `enabled / preset / tested`**no operator link and no role columns**.
Roles live only per-operator in `server_operators`
(`smp_role_storage/proxy/names`, `xftp_role_storage/proxy`).
- **UI.** Operator screens (`OperatorView.kt:260-320`, `OperatorView.swift:106-114`)
have the three toggles. The individual server screens
(`ProtocolServerView`, `NewServerView` on both platforms) show only
address / test / enabled / delete. Client `UserServer`
(`SimpleXAPI.kt:4787`, `AppAPITypes.swift:1953`) has no roles field.
## Decisions
1. **Where the logic lives — no simplexmq change.** The accurate, consistent
approach follows the existing layering: the agent always gets a concrete
`ServerRoles`; the only place roles are resolved/defaulted is simplex-chat's
`agentServerCfgs`. We keep the `Maybe ServerRoles → ServerRoles` resolution
there, alongside the existing `allRoles` / `operatorRoles` handling. No core
version bump, no wire/protocol change.
2. **SMP self-hosted only.** SMP has all three roles; XFTP has no `names` role.
The toggles are shown only for custom (non-preset) SMP servers. XFTP
self-hosted servers are functionally unchanged.
3. **Legacy default `NULL → names OFF`.** For rows without stored roles (existing
servers after upgrade, or servers not yet edited) the resolved value is
`defaultUserServerRoles = ServerRoles { storage = True, proxy = True, names =
False }`. This is also the default for newly added servers, so migrated and new
servers behave identically: receive + private routing on, name resolution off.
## Design
### Types (`Operators.hs`)
- Add `roles :: Maybe ServerRoles` to `UserServer'` (per-server; `Nothing` = use
default). `DuplicateRecordFields` is already enabled (both `ServerCfg` and
`UserServer'` will have a `roles` field).
- Add `defaultUserServerRoles :: ServerRoles = ServerRoles True True False`.
- `agentServerCfgs`, self-hosted branch (`:448`):
`roles = fromMaybe defaultUserServerRoles (roles srv)`. Operator branch
unchanged — the per-server field is ignored for operator-matched servers, so
operator roles still win.
Per-server, not global: `roles` is one value per `UserServer`; each server
resolves to its own `ServerCfg`.
### Storage (`protocol_servers`)
Three **nullable** columns (mirrors `server_operators`): `role_storage`,
`role_proxy`, `role_names` (INTEGER, no default → existing rows NULL). Read into
`Maybe ServerRoles` (all three present → `Just`, else `Nothing`); write all three
from `roles`.
### chat↔UI API
`UserServer'` JSON is derived with `defaultJSON` (`omitNothingFields = True`), so
the new `Maybe` `roles` field is omitted when `Nothing` — exactly like the
existing `tested :: Maybe Bool`. Older clients/servers remain compatible.
### Validation (`Operators.hs:519-577`)
`noNamesServersWarns` (`:564-569`) and `noServersErrs`'s `hasRole` (`:531`)
currently derive coverage from the *operator* and treat the no-operator (custom)
bucket as `True` for every role. With per-server roles and names-off default this
would be wrong (a self-hosted-only user would never see "no name servers"). These
must evaluate the custom bucket from each server's resolved per-server `roles`.
### UI (Kotlin + Swift)
- Add `roles: ServerRoles?` to the client `UserServer`.
- On custom (non-preset) **SMP** server screens only — `ProtocolServerView` and
`NewServerView`, both platforms — add a "Use for messages" section with the
three toggles, mirroring `OperatorView`. Reuse existing string keys:
`operator_use_for_messages`, `operator_use_for_messages_receiving`,
`operator_use_for_messages_private_routing`, `operator_use_for_names`.
- New server default: `storage = on, proxy = on, names = off`.
## Behavior / backward compatibility
- Existing self-hosted servers keep receive + private routing; name resolution is
off after upgrade (intentional — avoids routing lookups to non-resolver
servers). A user who wants a self-hosted resolver enables the toggle.
- No protocol / wire change; no simplexmq change; operator behavior unchanged.
## Out of scope
- Per-server roles for operator (SimpleX/Flux) servers — they keep operator-level
roles.
- XFTP per-server roles.
- Any change to the name-resolution protocol or the agent.
## Testing strategy
- Haskell: unit tests for `agentServerCfgs` (self-hosted `Just`/`Nothing`
correct `ServerCfg.roles`; operator servers ignore per-server roles), store
round-trip of `roles` through `protocol_servers`, and `validateUserServers`
names/storage/proxy coverage for the custom bucket.
- UI: manual verification that toggles render only for custom SMP servers, persist
per server, default names-off, and survive save/reload.
+2
View File
@@ -151,6 +151,7 @@ library
Simplex.Chat.Store.Postgres.Migrations.M20260714_member_security_code
Simplex.Chat.Store.Postgres.Migrations.M20260715_profile_description
Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history
Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles
else
exposed-modules:
Simplex.Chat.Archive
@@ -319,6 +320,7 @@ library
Simplex.Chat.Store.SQLite.Migrations.M20260714_member_security_code
Simplex.Chat.Store.SQLite.Migrations.M20260715_profile_description
Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history
Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles
other-modules:
Paths_simplex_chat
hs-source-dirs:
+1 -1
View File
@@ -156,7 +156,7 @@ createActiveUser cc CoreChatOpts {chatRelay, headless} createBot_ userDisplayNam
\It is only stored on your device and you can change it later."
where
loop = do
displayName <- T.pack <$> withPrompt "display name" getLine
displayName <- T.pack <$> withPrompt "display name: " getLine
createUser loop False $ mkProfile displayName
mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
createUser onError clientService p =
+30 -12
View File
@@ -49,7 +49,7 @@ import Simplex.Chat.Operators.Conditions
import Simplex.Chat.Protocol (RelayCapabilities (..), RelayProfile (..))
import Simplex.Chat.Types (ShortLinkContact, User)
import Simplex.Chat.Types.Shared (RelayStatus)
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..))
import Simplex.Messaging.Agent.Protocol (sameShortLinkContact)
import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..), fromTextField_)
import Simplex.Messaging.Agent.Store.Entity
@@ -176,6 +176,22 @@ operatorRoles p op = case p of
SPSMP -> smpRoles op
SPXFTP -> xftpRoles op
data ServerRolesOverride = ServerRolesOverride
{ storage :: Maybe Bool,
proxy :: Maybe Bool,
names :: Maybe Bool
}
deriving (Eq, Show)
emptyServerRolesOverride :: ServerRolesOverride
emptyServerRolesOverride = ServerRolesOverride {storage = Nothing, proxy = Nothing, names = Nothing}
-- each role: override if set, else the operator's role (if any), else default (receive on, proxy on, names off)
resolveServerRoles :: UserProtocol p => SProtocolType p -> Maybe ServerOperator -> ServerRolesOverride -> ServerRoles
resolveServerRoles p op ServerRolesOverride {storage, proxy, names} =
ServerRoles {storage = fromMaybe s storage, proxy = fromMaybe pr proxy, names = fromMaybe n names}
where ServerRoles {storage = s, proxy = pr, names = n} = maybe (ServerRoles True True False) (operatorRoles p) op
conditionsAccepted :: ServerOperator -> Bool
conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAcceptance of
CAAccepted {} -> True
@@ -245,6 +261,7 @@ data UserServer' s (p :: ProtocolType) = UserServer
preset :: Bool,
tested :: Maybe Bool,
enabled :: Bool,
roles :: ServerRolesOverride,
deleted :: Bool
}
deriving (Show)
@@ -330,7 +347,7 @@ newUserServer = newUserServer_ False True
newUserServer_ :: Bool -> Bool -> ProtoServerWithAuth p -> NewUserServer p
newUserServer_ preset enabled server =
UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False}
UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, roles = emptyServerRolesOverride, deleted = False}
presetChatRelay :: Bool -> RelayProfile -> [Text] -> ShortLinkContact -> NewUserChatRelay
presetChatRelay = newChatRelay_ True
@@ -439,13 +456,13 @@ agentServerCfgs :: UserProtocol p => SProtocolType p -> [(Text, ServerOperator)]
agentServerCfgs p opDomains = mapMaybe agentServer
where
agentServer :: UserServer' s p -> Maybe (ServerCfg p)
agentServer srv@UserServer {server, enabled} =
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}
| opEnabled -> Just ServerCfg {server, enabled, operator = Just opId, roles = resolveServerRoles p (Just op) srvRoles}
| otherwise -> Nothing
Nothing ->
Just ServerCfg {server, enabled, operator = Nothing, roles = allRoles}
Just ServerCfg {server, enabled, operator = Nothing, roles = resolveServerRoles p Nothing srvRoles}
matchingHost :: Text -> TransportHost -> Bool
matchingHost d = \case
@@ -524,11 +541,10 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other
noServersErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> Maybe User -> [u] -> [UserServersError]
noServersErrs p user uss
| noServers opEnabled = [USENoServers p' user]
| otherwise = [USEStorageMissing p' user | noServers (hasRole storage)] <> [USEProxyMissing p' user | noServers (hasRole proxy)]
| otherwise = [USEStorageMissing p' user | not (any (hasRole p (\ServerRoles {storage} -> storage)) uss)] <> [USEProxyMissing p' user | not (any (hasRole p (\ServerRoles {proxy} -> proxy)) uss)]
where
p' = AProtocolType p
noServers cond = not $ any srvEnabled $ userServers p $ filter cond uss
hasRole roleSel = maybe True (\op@ServerOperator {enabled} -> enabled && roleSel (operatorRoles p op)) . operator'
srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted
serverErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [u] -> [UserServersError]
serverErrs p uss = mapMaybe duplicateErr_ srvs
@@ -542,6 +558,10 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other
allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs
userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p]
userServers p = map aUserServer' . concatMap (servers' p)
-- a group covers a role if its operator is enabled and some enabled server resolves it on
hasRole :: (UserServersClass u, UserProtocol p) => SProtocolType p -> (ServerRoles -> Bool) -> u -> Bool
hasRole p roleSel u =
opEnabled u && any (\(AUS _ UserServer {enabled, deleted, roles}) -> enabled && not deleted && roleSel (resolveServerRoles p (operator' u) roles)) (map aUserServer' (servers' p u))
chatRelayErrs :: UserServersClass u => [u] -> [UserServersError]
chatRelayErrs uss = concatMap duplicateErrs_ cRelays
where
@@ -562,11 +582,7 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other
noChatRelays cond = not $ any relayEnabled $ userChatRelays $ filter cond uss
relayEnabled (AUCR _ UserChatRelay {deleted, enabled}) = enabled && not deleted
noNamesServersWarns :: UserServersClass u => Maybe User -> [u] -> [UserServersWarning]
noNamesServersWarns user uss = [USWNoNamesServers user | noNamesServers]
where
noNamesServers = not $ any srvEnabled $ userServers SPSMP $ filter namesEnabled uss
srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted
namesEnabled = maybe True (\op@ServerOperator {enabled} -> enabled && names (operatorRoles SPSMP op)) . operator'
noNamesServersWarns user uss = [USWNoNamesServers user | not (any (hasRole SPSMP (\ServerRoles {names} -> names)) uss)]
userChatRelays :: UserServersClass u => [u] -> [AUserChatRelay]
userChatRelays = map aUserChatRelay' . concatMap chatRelays'
opEnabled :: UserServersClass u => u -> Bool
@@ -591,6 +607,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction)
$(JQ.deriveJSON defaultJSON ''ServerOperatorConditions)
$(JQ.deriveJSON defaultJSON ''ServerRolesOverride)
instance ProtocolTypeI p => ToJSON (UserServer' s p) where
toEncoding = $(JQ.mkToEncoding defaultJSON ''UserServer')
toJSON = $(JQ.mkToJSON defaultJSON ''UserServer')
@@ -44,6 +44,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260707_file_digest
import Simplex.Chat.Store.Postgres.Migrations.M20260714_member_security_code
import Simplex.Chat.Store.Postgres.Migrations.M20260715_profile_description
import Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history
import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -87,7 +88,8 @@ schemaMigrations =
("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest),
("20260714_member_security_code", m20260714_member_security_code, Just down_m20260714_member_security_code),
("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description),
("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history)
("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history),
("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,23 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20260720_server_roles :: Text
m20260720_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_m20260720_server_roles :: Text
down_m20260720_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;
|]
@@ -1187,7 +1187,10 @@ CREATE TABLE test_chat_schema.protocol_servers (
user_id bigint NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
protocol text DEFAULT 'smp'::text NOT NULL
protocol text DEFAULT 'smp'::text NOT NULL,
role_storage smallint,
role_proxy smallint,
role_names smallint
);
+30 -14
View File
@@ -642,41 +642,45 @@ getProtocolServers db p User {userId} =
<$> DB.query
db
[sql|
SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled
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 = ?
|]
(userId, decodeLatin1 $ strEncode p)
where
toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, BoolInt, Maybe BoolInt, BoolInt) -> UserServer p
toUserServer (serverId, host, port, keyHash, auth_, BI preset, tested, BI enabled) =
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_)
in UserServer {serverId, server, preset, tested = unBI <$> tested, enabled, deleted = False}
roles = ServerRolesOverride (unBI <$> rStorage) (unBI <$> rProxy) (unBI <$> rNames)
in UserServer {serverId, server, preset, tested = unBI <$> tested, enabled, roles, deleted = False}
insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p)
insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled} = do
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, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
(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, userId, ts, ts))
(serverColumns p server :. (BI preset, BI <$> tested, BI enabled) :. roleColumns roles :. (userId, ts, ts))
sId <- insertedRowId db
pure (srv :: NewUserServer p) {serverId = DBEntityId sId}
updateProtocolServer :: ProtocolTypeI p => DB.Connection -> SProtocolType p -> UTCTime -> UserServer p -> IO ()
updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabled} =
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 = ?, updated_at = ?
preset = ?, tested = ?, enabled = ?,
role_storage = ?, role_proxy = ?, role_names = ?, updated_at = ?
WHERE smp_server_id = ?
|]
(serverColumns p server :. (BI preset, BI <$> tested, BI enabled, ts, serverId))
(serverColumns p server :. (BI preset, BI <$> tested, BI enabled) :. roleColumns roles :. (ts, serverId))
serverColumns :: ProtocolTypeI p => SProtocolType p -> ProtoServerWithAuth p -> (Text, NonEmpty TransportHost, String, C.KeyHash, Maybe Text)
serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) =
@@ -684,6 +688,9 @@ serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_)
auth = safeDecodeUtf8 . unBasicAuth <$> auth_
in (protocol, host, port, keyHash, auth)
roleColumns :: ServerRolesOverride -> (Maybe BoolInt, Maybe BoolInt, Maybe BoolInt)
roleColumns ServerRolesOverride {storage, proxy, names} = (BI <$> storage, BI <$> proxy, BI <$> names)
getChatRelays :: DB.Connection -> User -> IO [UserChatRelay]
getChatRelays db User {userId} =
map toChatRelay
@@ -772,7 +779,10 @@ updateServerOperator db currentTs ServerOperator {operatorId, enabled, smpRoles,
SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, smp_role_names = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ?
WHERE server_operator_id = ?
|]
(BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (names smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles), currentTs, operatorId)
(BI enabled, BI smpStorage, BI smpProxy, BI smpNames, BI xftpStorage, BI xftpProxy, currentTs, operatorId)
where
ServerRoles {storage = smpStorage, proxy = smpProxy, names = smpNames} = smpRoles
ServerRoles {storage = xftpStorage, proxy = xftpProxy} = xftpRoles
getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [(Maybe PresetOperator, Maybe ServerOperator)]
getUpdateServerOperators db presetOps newUser = do
@@ -810,7 +820,10 @@ getUpdateServerOperators db presetOps newUser = do
SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, smp_role_names = ?, xftp_role_storage = ?, xftp_role_proxy = ?
WHERE server_operator_id = ?
|]
(tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (names smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles), operatorId)
(tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI smpStorage, BI smpProxy, BI smpNames, BI xftpStorage, BI xftpProxy, operatorId)
where
ServerRoles {storage = smpStorage, proxy = smpProxy, names = smpNames} = smpRoles
ServerRoles {storage = xftpStorage, proxy = xftpProxy} = xftpRoles
insertOperator :: NewServerOperator -> IO ServerOperator
insertOperator op@ServerOperator {operatorTag, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} = do
DB.execute
@@ -820,9 +833,12 @@ getUpdateServerOperators db presetOps newUser = do
(server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, smp_role_names, xftp_role_storage, xftp_role_proxy)
VALUES (?,?,?,?,?,?,?,?,?,?)
|]
(operatorTag, tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (names smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles))
(operatorTag, tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI smpStorage, BI smpProxy, BI smpNames, BI xftpStorage, BI xftpProxy)
opId <- insertedRowId db
pure op {operatorId = DBEntityId opId}
where
ServerRoles {storage = smpStorage, proxy = smpProxy, names = smpNames} = smpRoles
ServerRoles {storage = xftpStorage, proxy = xftpProxy} = xftpRoles
autoAcceptConditions op UsageConditions {conditionsCommit} now =
acceptConditions_ db op conditionsCommit now True
$> op {conditionsAcceptance = CAAccepted (Just now) True}
+3 -1
View File
@@ -167,6 +167,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260707_file_digest
import Simplex.Chat.Store.SQLite.Migrations.M20260714_member_security_code
import Simplex.Chat.Store.SQLite.Migrations.M20260715_profile_description
import Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history
import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -333,7 +334,8 @@ schemaMigrations =
("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest),
("20260714_member_security_code", m20260714_member_security_code, Just down_m20260714_member_security_code),
("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description),
("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history)
("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history),
("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,22 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20260720_server_roles :: Query
m20260720_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_m20260720_server_roles :: Query
down_m20260720_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;
|]
@@ -4064,7 +4064,8 @@ SCAN s
SEARCH r USING AUTOMATIC PARTIAL COVERING INDEX (local_display_name=?)
Query:
SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled
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 = ?
@@ -4901,8 +4902,9 @@ Plan:
Query:
INSERT INTO protocol_servers
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled,
role_storage, role_proxy, role_names, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
@@ -5433,7 +5435,8 @@ SEARCH msg_deliveries USING INDEX idx_msg_deliveries_agent_msg_id (connection_id
Query:
UPDATE protocol_servers
SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?,
preset = ?, tested = ?, enabled = ?, updated_at = ?
preset = ?, tested = ?, enabled = ?,
role_storage = ?, role_proxy = ?, role_names = ?, updated_at = ?
WHERE smp_server_id = ?
Plan:
@@ -565,6 +565,9 @@ CREATE TABLE IF NOT EXISTS "protocol_servers"(
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now')),
protocol TEXT NOT NULL DEFAULT 'smp',
role_storage INTEGER,
role_proxy INTEGER,
role_names INTEGER,
UNIQUE(user_id, host, port)
) STRICT;
CREATE TABLE xftp_file_descriptions(
+9 -9
View File
@@ -1659,12 +1659,12 @@ viewUserServers UserOperatorServers {operator, smpServers, xftpServers, chatRela
testedInfo = maybe [] (\t -> ["test: " <> if t then "passed" else "failed"]) tested
viewRoles op@ServerOperator {enabled}
| not enabled = "disabled"
| storage rs && proxy rs = "enabled"
| storage rs = "enabled storage"
| proxy rs = "enabled proxy"
| rStorage && rProxy = "enabled"
| rStorage = "enabled storage"
| rProxy = "enabled proxy"
| otherwise = "disabled (servers known)"
where
rs = operatorRoles p op
ServerRoles {storage = rStorage, proxy = rProxy} = operatorRoles p op
viewChatRelays :: [UserChatRelay] -> [StyledString]
viewChatRelays [] = []
viewChatRelays cRelays
@@ -1752,12 +1752,12 @@ viewOpEnabled ServerOperator {enabled, smpRoles, xftpRoles}
| both smpRoles && both xftpRoles = "enabled"
| otherwise = "SMP " <> viewRoles smpRoles <> ", XFTP " <> viewRoles xftpRoles
where
no rs = not $ storage rs || proxy rs
both rs = storage rs && proxy rs
viewRoles rs
no ServerRoles {storage, proxy} = not $ storage || proxy
both ServerRoles {storage, proxy} = storage && proxy
viewRoles rs@ServerRoles {storage, proxy}
| both rs = "enabled"
| storage rs = "enabled storage"
| proxy rs = "enabled proxy"
| storage = "enabled storage"
| proxy = "enabled proxy"
| otherwise = "disabled (servers known)"
viewConditionsAction :: UsageConditionsAction -> [StyledString]
+3
View File
@@ -1760,6 +1760,7 @@ withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup webFolder test = do
withNewTestChatCfg ps cfg serviceDbPrefix directoryProfile $ \ds ->
withNewTestChatCfg ps cfg "super_user" aliceProfile $ \superUser -> do
connectUsers ds superUser
enableNamesRole ds
when createOwnersGroup $ do
superUser ##> "/g owners"
superUser <## "group #owners is created"
@@ -2131,6 +2132,7 @@ testDirectoryChannelName ps = withSmpServerAndNames $ \reg ->
withDirectoryServiceCfg ps testCfg $ \superUser dsLink ->
withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob ->
withRelay ps $ \relay -> do
enableNamesRole bob
bob `connectVia` dsLink
(shortLink, _fullLink) <- prepareChannel1Relay "news" bob relay
registerName reg newsName (channelNameRecord "news" (T.pack shortLink))
@@ -2171,6 +2173,7 @@ testDirectoryChannelNameNotVerified ps = withSmpServerAndNames $ \reg ->
withDirectoryServiceCfg ps testCfg $ \superUser dsLink ->
withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob ->
withRelay ps $ \relay -> do
enableNamesRole bob
bob `connectVia` dsLink
(shortLink, _fullLink) <- prepareChannel1Relay "news" bob relay
registerName reg newsName (channelNameRecord "news" (T.pack shortLink))
+26 -1
View File
@@ -1,4 +1,5 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
@@ -23,15 +24,17 @@ import Control.Monad.Except
import Control.Monad.Reader
import Data.Functor (($>))
import Data.List (dropWhileEnd, find)
import qualified Data.List.NonEmpty as L
import Data.Maybe (isNothing)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (getCurrentTime)
import Network.Socket
import Simplex.Chat
import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), WebPreviewConfig (..), defaultSimpleNetCfg)
import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), ChatResponse (..), WebPreviewConfig (..), defaultSimpleNetCfg)
import Simplex.Chat.Core
import Simplex.Chat.Library.Commands
import Simplex.Chat.Operators
import Simplex.Chat.Options
import Simplex.Chat.Options.DB
import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion)
@@ -50,6 +53,7 @@ import Simplex.Messaging.Agent (disposeAgentClient)
import Simplex.Messaging.Agent.Env.SQLite
import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange)
import Simplex.Messaging.Agent.RetryInterval
import Simplex.Messaging.Agent.Store.Entity (SDBStored (..))
import Simplex.Messaging.Agent.Store.Interface (closeDBStore)
import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError)
import qualified Simplex.Messaging.Agent.Store.DB as DB
@@ -57,6 +61,7 @@ import Simplex.Messaging.Client (ProtocolClientConfig (..))
import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig)
import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange)
import qualified Simplex.Messaging.Crypto.Ratchet as CR
import Simplex.Messaging.Protocol (ProtocolType (..))
import Simplex.Messaging.Server (runSMPServerBlocking)
import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration)
import NameResolver (NameRegistry, resolverNamesConfig, withNameResolver)
@@ -394,6 +399,26 @@ withTestChatCfgOpts ps cfg opts dbPrefix = bracket (startTestChat ps cfg opts db
withTestOutput :: HasCallStack => (HasCallStack => TestParams -> IO ()) -> TestParams -> IO ()
withTestOutput test ps = test ps {printOutput = True}
-- Opt the client's SMP servers into name resolution (self-hosted servers default names off).
enableNamesRole :: HasCallStack => TestCC -> IO ()
enableNamesRole TestCC {chatController = cc} = do
r <- execChatCommand' (APIGetUserServers 1) 0 `runReaderT` cc
case r of
Right (CRUserServers _ uoss) -> do
r' <- execChatCommand' (APISetUserServers 1 (L.fromList (map toUpdated uoss))) 0 `runReaderT` cc
either (fail . show) (const $ pure ()) r'
Right other -> fail $ "enableNamesRole: unexpected response " <> show other
Left e -> fail $ "enableNamesRole: APIGetUserServers failed " <> show e
where
toUpdated UserOperatorServers {operator, smpServers, xftpServers, chatRelays} =
UpdatedUserOperatorServers
{ operator,
smpServers = map (AUS SDBStored . enableNames) smpServers,
xftpServers = map (AUS SDBStored) xftpServers,
chatRelays = map (AUCR SDBStored) chatRelays
}
enableNames srv@UserServer {roles} = (srv :: UserServer 'PSMP) {roles = (roles :: ServerRolesOverride) {names = Just True}}
readTerminalOutput :: VirtualTerminal -> TQueue String -> IO ()
readTerminalOutput t termQ = do
let w = virtualWindow t
+11
View File
@@ -33,6 +33,7 @@ testConnectByName ps = withSmpServerAndNames $ \reg ->
where
aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" [])
test reg alice bob = do
mapM_ enableNamesRole [alice, bob]
alice ##> "/ad"
(shortLink, _) <- getContactLinks alice True
registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink))
@@ -67,6 +68,7 @@ testConnectByNameNotClaimed ps = withSmpServerAndNames $ \reg ->
where
aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" [])
test reg alice bob = do
mapM_ enableNamesRole [alice, bob]
alice ##> "/ad"
(shortLink, _) <- getContactLinks alice True
registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink))
@@ -79,6 +81,7 @@ testConnectByNameKnownContactNotClaimed ps = withSmpServerAndNames $ \reg ->
where
aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" [])
test reg alice bob = do
mapM_ enableNamesRole [alice, bob]
alice ##> "/ad"
(shortLink, _) <- getContactLinks alice True
bob ##> ("/c " <> shortLink)
@@ -100,6 +103,7 @@ testConnectByNameNotFound ps = withSmpServerAndNames $ \_reg ->
testChat2 aliceProfile bobProfile test ps
where
test _alice bob = do
enableNamesRole bob
bob ##> "/c @nobody.simplex"
bob .<## "smpErr = NAME {nameErr = NOT_FOUND}}"
@@ -109,6 +113,7 @@ testSetNameNotOwnAddress ps = withSmpServerAndNames $ \reg ->
where
aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" [])
test reg alice bob = do
mapM_ enableNamesRole [alice, bob]
bob ##> "/ad"
(bobShortLink, _) <- getContactLinks bob True
registerName reg aliceName (contactNameRecord "alice" (T.pack bobShortLink))
@@ -123,6 +128,7 @@ testChannelDomainLinkJoinUnverified ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
mapM_ enableNamesRole [alice, cath, bob]
(shortLink, fullLink) <- prepareChannel1Relay "team" alice cath
registerName reg teamName (channelNameRecord "team" (T.pack shortLink))
alice ##> "/public group access #team domain=team.simplex"
@@ -142,6 +148,7 @@ testChannelDomainVerify ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
mapM_ enableNamesRole [alice, cath, bob]
(shortLink, fullLink) <- prepareChannel1Relay "team" alice cath
registerName reg teamName (channelNameRecord "team" (T.pack shortLink))
alice ##> "/public group access #team domain=team.simplex"
@@ -171,6 +178,7 @@ testConnectByChannelName ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
mapM_ enableNamesRole [alice, cath, bob]
(shortLink, _) <- prepareChannel1Relay "team" alice cath
registerName reg teamName (channelNameRecord "team" (T.pack shortLink))
alice ##> "/public group access #team domain=team.simplex"
@@ -204,6 +212,7 @@ testConnectByNameChannelAndContact ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
mapM_ enableNamesRole [alice, cath, bob]
(channelLink, _) <- prepareChannel1Relay "team" alice cath
alice ##> "/ad"
(contactLink, _) <- getContactLinks alice True
@@ -242,6 +251,7 @@ testConnectByNameContactAndChannel ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
mapM_ enableNamesRole [alice, cath, bob]
(channelLink, _) <- prepareChannel1Relay "acme" alice cath
alice ##> "/ad"
(contactLink, _) <- getContactLinks alice True
@@ -260,6 +270,7 @@ testConnectByNameBusinessAndChannel ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
mapM_ enableNamesRole [alice, cath, bob]
(channelLink, _) <- prepareChannel1Relay "biz" alice cath
alice ##> "/ad"
(contactLink, fullLink) <- getContactLinks alice True
+80 -1
View File
@@ -23,7 +23,7 @@ import Simplex.Chat.Operators.Presets
import Simplex.Chat.Protocol (RelayProfile (..), mkRelayProfile)
import Simplex.Chat.Types
import Simplex.FileTransfer.Client.Presets (defaultXFTPServers)
import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles)
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
import Simplex.Messaging.Agent.Store.Entity
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Protocol
@@ -33,6 +33,7 @@ operatorTests :: Spec
operatorTests = describe "managing server operators" $ do
validateServersTest
updatedServersTest
perServerRolesTest
validateServersTest :: Spec
validateServersTest = describe "validate user servers" $ do
@@ -122,6 +123,84 @@ updatedServersTest = describe "validate user servers" $ do
PresetServers {operators} = presetServers defaultChatConfig
customRelayAddr = either error id $ strDecode "https://relay.example.im/r#Pz9qz7ZVljMofoRxiDDpL_w2DZSazK8IgafxqnWKv6Y"
perServerRolesTest :: Spec
perServerRolesTest = describe "per-server roles" $ do
describe "agentServerCfgs resolution" $ do
it "self-hosted server keeps its per-server roles" $
case agentServerCfgs SPSMP opDomains [selfHostedSMP (ServerRolesOverride (Just True) (Just False) (Just True))] of
[ServerCfg {operator, roles}] -> do
operator `shouldBe` Nothing
rolesTuple roles `shouldBe` (True, False, True)
cfgs -> expectationFailure $ "expected one self-hosted ServerCfg, got: " <> show cfgs
it "self-hosted server without roles falls back to default roles" $
case agentServerCfgs SPSMP opDomains [selfHostedSMP emptyServerRolesOverride] of
[ServerCfg {operator, roles}] -> do
operator `shouldBe` Nothing
rolesTuple roles `shouldBe` (True, True, False)
cfgs -> expectationFailure $ "expected one self-hosted ServerCfg, got: " <> show cfgs
it "self-hosted partial override keeps defaults for unset roles" $
case agentServerCfgs SPSMP opDomains [selfHostedSMP (ServerRolesOverride Nothing Nothing (Just True))] of
[ServerCfg {operator, roles}] -> do
operator `shouldBe` Nothing
rolesTuple roles `shouldBe` (True, True, True)
cfgs -> expectationFailure $ "expected one self-hosted ServerCfg, got: " <> show cfgs
it "self-hosted explicit No overrides a default-yes role" $
case agentServerCfgs SPSMP opDomains [selfHostedSMP (ServerRolesOverride (Just False) Nothing Nothing)] of
[ServerCfg {operator, roles}] -> do
operator `shouldBe` Nothing
rolesTuple roles `shouldBe` (False, True, False)
cfgs -> expectationFailure $ "expected one self-hosted ServerCfg, got: " <> show cfgs
it "operator-matched server without override inherits operator roles" $
-- operator roles are all on; no override -> all inherited, including names
case agentServerCfgs SPSMP opDomains [opMatchedSMP emptyServerRolesOverride] of
[ServerCfg {operator, roles}] -> do
operator `shouldBe` Just 1
rolesTuple roles `shouldBe` (True, True, True)
cfgs -> expectationFailure $ "expected one operator-matched ServerCfg, got: " <> show cfgs
it "operator-matched server applies its override over operator roles" $
-- operator roles are all on; override turns storage/names off, proxy inherits (on)
case agentServerCfgs SPSMP opDomains [opMatchedSMP (ServerRolesOverride (Just False) Nothing (Just False))] of
[ServerCfg {operator, roles}] -> do
operator `shouldBe` Just 1
rolesTuple roles `shouldBe` (False, True, False)
cfgs -> expectationFailure $ "expected one operator-matched ServerCfg, got: " <> show cfgs
it "two self-hosted servers resolve their three roles independently" $
-- agentServerCfgs preserves input order
let a = (newUserServer "smp://abcd@self.example.com" :: NewUserServer 'PSMP) {roles = ServerRolesOverride (Just False) (Just True) (Just True)}
b = (newUserServer "smp://abcd@self2.example.com" :: NewUserServer 'PSMP) {roles = ServerRolesOverride (Just True) (Just False) Nothing}
in case agentServerCfgs SPSMP opDomains [a, b] of
[ServerCfg {operator = opA, roles = rolesA}, ServerCfg {operator = opB, roles = rolesB}] -> do
(opA, opB) `shouldBe` (Nothing, Nothing)
rolesTuple rolesA `shouldBe` (False, True, True)
rolesTuple rolesB `shouldBe` (True, False, False)
cfgs -> expectationFailure $ "expected two self-hosted ServerCfgs, got: " <> show cfgs
describe "validateUserServers per-server names coverage" $ do
it "self-hosted-only user without names servers warns USWNoNamesServers" $ do
let (_errs, warns) = validateUserServers [selfHostedUser emptyServerRolesOverride] []
warns `shouldSatisfy` elem (USWNoNamesServers Nothing)
it "self-hosted user with a names server does not warn USWNoNamesServers" $ do
let (_errs, warns) = validateUserServers [selfHostedUser (ServerRolesOverride Nothing Nothing (Just True))] []
warns `shouldSatisfy` notElem (USWNoNamesServers Nothing)
where
testOp = operatorSimpleXChat {operatorId = DBEntityId 1}
opDomains = operatorDomains [testOp]
-- host matches no operator domain -> self-hosted
selfHostedSMP :: ServerRolesOverride -> NewUserServer 'PSMP
selfHostedSMP r = (newUserServer "smp://abcd@self.example.com" :: NewUserServer 'PSMP) {roles = r}
-- host matches operator domain simplex.im
opMatchedSMP :: ServerRolesOverride -> NewUserServer 'PSMP
opMatchedSMP r = (newUserServer "smp://abcd@smp8.simplex.im" :: NewUserServer 'PSMP) {roles = r}
selfHostedUser :: ServerRolesOverride -> UpdatedUserOperatorServers
selfHostedUser r =
UpdatedUserOperatorServers
{ operator = Nothing,
smpServers = [AUS SDBNew $ selfHostedSMP r],
xftpServers = [],
chatRelays = []
}
rolesTuple :: ServerRoles -> (Bool, Bool, Bool)
rolesTuple ServerRoles {storage, proxy, names} = (storage, proxy, names)
deriving instance Eq User
deriving instance Eq UserServersError