This commit is contained in:
spaced4ndy
2026-05-11 16:42:42 +04:00
parent 6db30bcf87
commit 75bf9a09d7
2 changed files with 401 additions and 471 deletions
@@ -1,16 +1,8 @@
# Public groups via relays — plan summary
Companion overview for `2026-05-08-public-groups-via-relays.md`.
## What
Add a third kind of group: relay-mediated like channels, but every member can post like a
A third kind of group: relay-mediated like channels, but every member can post like a
secret group. Resolves the scale ceiling of full-mesh groups without the broadcast-only
governance of channels.
## Concept
Two orthogonal axes, already in the model:
governance of channels. Two orthogonal axes, already in the model:
| `useRelays` | `groupType` | Name |
|-------------|--------------|--------------|
@@ -19,95 +11,35 @@ Two orthogonal axes, already in the model:
| true | `GTGroup` | Public group | ← new
| true | `GTUnknown` | refuse | ← older client sees this for `"group"`
`useRelays` = transport (topology, batch format, signing). `groupType` = governance (joiner
role, profile dissemination, member-DM affordances).
`useRelays` is transport; `groupType` is the governance model (broadcast vs
participatory). The joiner role is a per-group value the owner sets at creation,
carried on the (owner-signed) channel profile, so every relay derives the same role for
the same group — not from a relay-side global config and not from `groupType`. The
blocker is narrow: no path produces `GTGroup` today, and the channel profile carries
no joiner-role field yet.
## Code blocker (narrow)
## Shape of the work
No path produces `groupType = GTGroup` (`Commands.hs:2514` always writes `GTChannel`), and
the relay's joiner role comes from `channelSubscriberRole` config instead of `groupType`.
Backend: wire/version bump, type helpers, create command, owner-configured joiner-role
field on the channel profile, relay role derivation from that field. Clients (iOS +
Kotlin mirror): model, audit splitting transport-vs-governance call sites, unified
create flow with a Channel/Public-group toggle that picks the joiner-role default,
views, connect-plan messaging.
## Backend changes (Haskell)
## Threat model deltas vs. channels
- **Wire**: bump `currentChatVersion` and add `publicGroupsVersion`. No new fields, no new
messages — `groupType = "group"` already round-trips through `textEncode`/`textDecode`.
- **Types** (`Types.hs`): add helpers `groupType'`, `isPublicGroup'`, `subscriberRoleFor`
alongside `useRelays'`. Used everywhere the audit touches a `groupType` decision.
- **Command** (`Commands.hs`): add `groupType` field to `APINewPublicGroup`/`NewPublicGroup`;
`/public group` parser gains `type=channel|group` (default `channel`). Substitute
`subscriberRoleFor gType` for `channelSubscriberRole` reads at four sites.
- **Prefs defaults** (`Commands.hs:5380` area): `relayGroupProfile :: GroupType → Parser
GroupProfile` collapses to `GTChannel → channelProfile, GTGroup → groupProfile`. Public
group prefs equal secret-group prefs (only channel deviates with `support = OFF`).
- **Role derivation** (`Subscriber.hs`): `unknownMemberRole` and `createRelayLink` read
`subscriberRoleFor (groupType' gInfo)` instead of config. Each relay derives the same
default from the channel's immutable `groupType`.
- **Member-to-member DMs**: forward `XGrpDirectInv` through the relay scoped to a single
recipient. (1) add to `isForwardedGroupMsg`; (2) add dispatch arm in `processForwardedMsg`;
(3) introduce new `DJSDirectInv` job scope (parallel to `DJSMemberSupport` but no
moderator broadcast — reusing support-scope would leak DM intent to mods, defeating the
point); (4) gate on `directMessages` group preference. `xGrpDirectInv` parameterized via
`DirectInvSource = DISDirect Connection | DISForwarded ForwardedMeta`.
- **Migrations**: none for type plumbing. The `sent_profile_vector` migration from the
prerequisite dissemination plan is required.
- **Tests**: 13 new cases covering member posting, dissemination integration, member
edits/deletes/reactions, member-DM creation, role/block changes, multi-relay,
history-on-join, `asGroup=true` rejection, receipts above-limit, older-client refusal,
incognito posting, incognito DM.
**Relay can fabricate content as any member** (broader than channels, where it could
only forge as owners). Same deniability property as channels by design; future fix is
opt-in content signing.
## iOS + Kotlin changes (mirror each other)
Everything else in the channel threat model carries over unchanged. Out of scope for
now: member-to-member DMs in relay-mediated groups — deferred, not killed.
- **Model**: add `Group` variant to `GroupType`; add `isPublicGroup` and `groupType`
accessors. iOS already has `isChannel`; Kotlin has it too — both stay channel-only.
- **Audit**: ~73 sites iOS, ~74 Kotlin where `useRelays` is used as a proxy for `isChannel`.
Per-site rule: transport (topology/relay-management/icon) → keep `useRelays`; governance
(titles, "subscribers", "channel preferences", member-vs-channel UX) → switch to
`isChannel`. Concrete picks listed per-file with line numbers.
- **Create flow**: unified `AddChannelView` with a "Channel / Public group" segmented
toggle; default to Channel. `groupType` parameter threads to `apiNewPublicGroup`. Help
text on the create screen surfaces both threat-model trade-offs (relay can fabricate any
member's content; relay sees DM-graph metadata).
- **Strings**: ~10 new keys per platform (`add_public_group`, `create_public_group`,
`public_group_link`, threat-model + DM-metadata help text, etc.). Reuse `group_members_*`
for "members" framing; channels keep `_subscriber*`.
- **Icons**: distinct icon for Public groups (separate from channel-antenna and
secret-group-people). Pending design review.
- **Members view**: same relay-known list channels use; section header says "subscribers"
for channels, "members" for Public groups.
- **ConnectPlan** (Kotlin): `ConnectPlan.kt:634` currently equates "uses relays" with "is
channel" — fix to branch on `groupType` (Channel / Group / null / Unknown→reject).
## Sequencing & boundary
## Threat model deltas (new §6)
Two changes from the channel threat model:
1. **Relay sees member DM graph** (new property). Relay learns (sender, target, time) on
every `XGrpDirectInv`. Cannot read DM content (post-acceptance is P2P), cannot observe
DM activity after invitation. Mitigations: incognito join; owner can disable
`directMessages` preference. Default ON; create-flow help text surfaces the implication.
2. **Relay can fabricate content as any member** (broader than channels, where it could
only fabricate as owners). Same deniability property as channels by design (unsigned
content); future fix is opt-in content signing. Help text surfaces the trade-off.
Everything else (signed events, owner impersonation, participant privacy) carries over
unchanged.
## Migration & sequencing
- Existing channels untouched. Older clients decode `"group"` as `GTUnknown` and refuse to
join with a "needs newer version" alert. Owner client and member clients need
`publicGroupsVersion`. Older relays forward the wire correctly but assign `GRObserver`
from config until upgraded — owner is warned at create time if any selected relay is
pre-`publicGroupsVersion`.
- **Hard prerequisite**: member-profile dissemination plan
(`2026-04-29-member-profile-sending-channels.md`) must land first.
- Ship order: backend types/command/role derivation → relay-forwarded `XGrpDirectInv` →
iOS plumbing/audit/create → iOS views/icons/ConnectPlan → Kotlin plumbing/audit/create →
Kotlin views/icons/ConnectPlan → older-client refusal + version-bump release notes.
Platforms ship independently.
## Adjacent work, not planned
Owner→relay protocol-level role/rejection-rule communication, and owner-signature
verification on the channel profile by relays. Both real disparities, both apply to
channels equally, neither blocks Public groups.
Hard prerequisite: the member-profile dissemination plan
(`2026-04-29-member-profile-sending-channels.md`) lands first. Then backend → iOS →
Kotlin; platforms ship independently; older clients refuse to join. Owner→relay
role/rejection-rule communication and owner-signature verification on the channel
profile by relays are not planned here — both apply to channels equally; neither blocks
Public groups.
File diff suppressed because it is too large Load Diff