mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 17:13:10 +00:00
directory: implementation draft
This commit is contained in:
1 parent
db0d6e2951
commit
db1edeebbd
12 files changed
+1000
-407
No files matched your search
@@ -1,176 +1,176 @@
|
||||
# Directory registration of businesses and service bots (via signed contact card)
|
||||
|
||||
Status: draft plan for review. Grounded against the current tree (branch `ep/improve-names-2`).
|
||||
Status: draft plan for review. Grounded against the current tree (branch `ep/dir-contacts`).
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let a business or a service (chat bot) operator register their **contact `/a` address** in
|
||||
the directory by **forwarding a signed contact card** to the directory bot — exactly the
|
||||
UX we already have for channels (`/share chat #ch @'SimpleX Directory'`), but for a contact
|
||||
address instead of a channel link.
|
||||
Let a business or a service (chat bot) operator register their **contact `/a` address** in the
|
||||
directory by **sending a signed contact card** to the directory bot — the UX already used for
|
||||
channels (`/share chat #ch @'SimpleX Directory'`), for a contact address instead of a channel link.
|
||||
|
||||
**Guiding principle: the flow is the channel registration flow verbatim — owner-signed card, admin
|
||||
approval, re-approval on any profile change, the same periodic link-check loop — differing only in
|
||||
the listing type (a contact `peerType`, not a group). Where a detail is unspecified here, the answer
|
||||
is "whatever channels do."**
|
||||
**Guiding principle: the flow matches channel registration — owner-signed card, admin approval,
|
||||
re-approval on any profile change — differing in that the directory does not connect to or prepare
|
||||
the address, and the listing type is a contact `peerType`.**
|
||||
|
||||
Product decisions from the discussion, baked into this plan:
|
||||
|
||||
- **The owner sends the card, and the signature is the authorization.** The `ownerSig` (signed
|
||||
with the address key) is what proves the address owner authorized the listing — only the key
|
||||
holder can produce it. We deliberately do NOT use a "directory connects and asks the owner to
|
||||
confirm" double opt-in (it is a spam vector, like any mailing-list signup). "Submitter ≠ owner"
|
||||
is handled not by letting non-owners submit, but by giving the owner's tooling a way to send (the
|
||||
support-bot entry point that would cover the headless case is deferred — §B.4).
|
||||
- **The directory prepares an unconnected entity; it does not open a conversation.** On a verified card it calls `APIPrepareContact` to create a prepared contact or, for a business-chat address, a prepared business group, from the fetched `ContactShortLinkData`, and stores a registration referencing it. The periodic loop re-runs `APIConnectPlan … PRMAll` on the address link; under `PRMAll` a new `resolveKnownContact` (contact) or `resolveKnownGroup` (business group) refreshes the entity's profile and domain verification via `updateContactFromLinkData`/`updateGroupFromLinkData` — the analog of `deGroupLinkCheck` for channels (core change, §A/§C.3). No connection is established.
|
||||
- **One table for both businesses and bots** — all are contact `/a` addresses. The registration is typed by the fetched profile's `peerType`: a **bot** from `CPTBot`; a **business** from `CPTHuman` or `CPTBusiness` (unset ≙ `CPTHuman`), stored `CPTBusiness`; `CPTUnknown` is rejected. An admin verifies a business before approving, as for channels.
|
||||
- **Listing type = `ChatPeerType`.** `ChatPeerType` (`Types.hs:711`, now `CPTHuman | CPTBot | CPTBusiness | CPTUnknown Text` — implemented)
|
||||
with **`CPTBusiness`** and **`CPTUnknown Text`** (forward-compat, like `GTUnknown`), and make the
|
||||
decoder **lenient** (unknown tag → `CPTUnknown`) so this version won't choke on future tags.
|
||||
**Wire-compat caveat (verified):** `ChatPeerType` decodes strictly today
|
||||
(`textDecode … _ -> Nothing`, `Profile` via `deriveJSON`), so a present-but-unknown `peerType`
|
||||
makes an *already-deployed* app fail to parse the whole profile — it does **not** downgrade to
|
||||
human. So a business must **not** publish `CPTBusiness` yet (old apps couldn't reach it); a
|
||||
business's profile stays `CPTHuman` in practice, with `CPTBusiness` reserved for later. The MVP
|
||||
types a **bot** from `peerType == CPTBot` and a **business** from `peerType ∈ {CPTHuman,
|
||||
CPTBusiness}` (unset ≙ `CPTHuman`), rejecting `CPTUnknown`; it stores the resolved type
|
||||
(`CPTBot`/`CPTBusiness`) on the listing. When the lenient version is broadly adopted, businesses
|
||||
can publish `CPTBusiness` directly.
|
||||
- **The owner sends the card; the signature is the authorization.** The `ownerSig`, signed with the
|
||||
address root key, proves the sender owns the address, and only the key holder can produce it. The
|
||||
signature is bound to the sending conversation (§3), so a forwarded card carries no signature.
|
||||
- **The directory neither connects to nor prepares the address.** The operator is already a connected
|
||||
contact, because it sends the card over its own conversation with the directory. The registration
|
||||
references that existing contact by `contact_id`. The listing profile is refreshed by resolving the
|
||||
published address link and applying `updateContactFromLinkData` — a library function the directory
|
||||
calls directly, not a connection and not `connectPlan` (§A, §C.3).
|
||||
- **The operator must publish the registered address in its own profile** (`Profile.contactLink`), so
|
||||
a user reading the listing can connect. The directory checks that the card's `connLink` equals the
|
||||
sending contact's `profile.contactLink`; if absent or different, it asks the operator to publish it.
|
||||
This publication is a listing requirement for user convenience, not the ownership proof.
|
||||
- **Short links only.** A registration card carries a `ShortLinkContact` (`MCLContact.connLink`, §3).
|
||||
A raw or full link receives the same response channels give — a request to send the address as a
|
||||
card.
|
||||
- **One table for businesses and bots** — both are contact `/a` addresses. The registration is typed
|
||||
from the fetched profile's `peerType`: a **bot** when `peerType == CPTBot`, a **business**
|
||||
otherwise. `CPTUnknown` is rejected. An admin verifies a business before approving, as for channels.
|
||||
- **Listing type = `ChatPeerType`.** `ChatPeerType` (`Types.hs:711`, now
|
||||
`CPTHuman | CPTBot | CPTBusiness | CPTUnknown Text` — implemented) with a **lenient** decoder
|
||||
(unknown tag → `CPTUnknown`) so this version never fails to parse a profile with a future tag.
|
||||
**Wire-compat caveat (verified):** the *current* decoder is strict and `Profile` is
|
||||
`deriveJSON`-parsed, so an already-deployed app fails the whole profile on an unknown `peerType`;
|
||||
therefore `CPTBusiness` is not published on profiles until the lenient version is broadly adopted.
|
||||
Classification does not depend on this — a business profile stays `CPTHuman` in practice, and the
|
||||
directory types it a business because its `peerType` is not `CPTBot`.
|
||||
- **`peerType` and `businessAddress` are orthogonal, and the directory ignores `businessAddress`.**
|
||||
`businessAddress` chooses the *conversation type* a connector gets (a business chat / group vs a
|
||||
direct 1:1); it can be set by non-businesses, and a real business may run a plain direct-chat
|
||||
address. The directory does **not** use it to classify — the type comes from the profile's
|
||||
`peerType` (bot vs human/business, above), not from `businessAddress`. (App-side only, unchanged:
|
||||
the connect-preview briefcase shows when **either** `businessAddress` or `peerType == CPTBusiness`;
|
||||
bot cube from `peerType`, else person — in the MVP that briefcase comes from `businessAddress`.
|
||||
Separate from directory classification.)
|
||||
`businessAddress` chooses the conversation type a connector gets; the directory does not use it to
|
||||
classify.
|
||||
- **Description lives on the contact `Profile`** (new `description` field, parallel to
|
||||
`GroupProfile.description`). In group-member profiles it is **redacted per the group's policy —
|
||||
the same treatment `shortDescr` gets** (links/names stripped when the group prohibits them), not
|
||||
removed wholesale. It is carried **full** in the direct contact view, the address link preview,
|
||||
and the directory. See §G.
|
||||
- **`peerType` + `description` are visible in the app independent of the directory** (that is why
|
||||
owners will set them). `peerType` drives the type icon in the pre-connect alert
|
||||
(`ConnectPlan.kt:698-713`) and a marker in the chat list / chat banner. The compact surfaces (the
|
||||
alert, the shared-link card) are too small for the large `description`, so it appears via a
|
||||
**"Read more"** affordance in the **chat banner** (`ChatView.kt` `ChatBannerView`) and the
|
||||
**contact info page** (`ChatInfoView.kt:778`) that opens the full text in a sheet (iOS) / alert
|
||||
(Kotlin). These are NOT the welcome/auto-reply message (`AddressSettings.autoReply`, transient
|
||||
on-connect). Full details in §H.
|
||||
`GroupProfile.description`). In group-member profiles it is redacted per the group's policy, as
|
||||
`shortDescr` is; it is carried full in the direct contact view, the address link preview, and the
|
||||
directory. See §G.
|
||||
- **`peerType` + `description` are visible in the app independent of the directory.** Full details in
|
||||
§H.
|
||||
|
||||
Deliverables: (a) an API + CLI to prepare and share the signed contact card; (b) the
|
||||
`Profile.description` field; (c) directory handling that verifies and stores the address; (d) admin
|
||||
approval, web listing, and search. (A support-bot entry point for headless businesses is out of
|
||||
scope for now — §B.4.)
|
||||
approval, web listing, and search.
|
||||
|
||||
## 2. End-to-end flow
|
||||
|
||||
```
|
||||
Operator's client Directory bot
|
||||
----------------- -------------
|
||||
(already a connected contact of the directory)
|
||||
/share address @'SimpleX Directory'
|
||||
-> get own /a address (short link,
|
||||
businessAddress flag, root key)
|
||||
-> build MCChat { chatLink =
|
||||
MCLContact {connLink, profile, business},
|
||||
ownerSig = sign(rootPrivKey,
|
||||
chatBinding <> connLink) } ── card ──▶ DEChatLinkReceived (MCLContact, ownerSig)
|
||||
-> APIConnectPlan (PLAN only, no connect)
|
||||
fetches link data (opaque) + verifies sig
|
||||
=> CPContactAddress (CAPOk {contactSLinkData_})
|
||||
-> if OVVerified:
|
||||
APIPrepareContact, then addContactReg (bot if CPTBot,
|
||||
else business), status pending
|
||||
notify admins with profile (admin verifies)
|
||||
admins: /approve ... -> status active -> listingsUpdated
|
||||
-> web listing.json + bot search include it
|
||||
-> own /a short address + root key
|
||||
-> MCChat { chatLink = MCLContact {connLink, profile, business},
|
||||
ownerSig = sign(rootPrivKey,
|
||||
chatBinding <> connLink) } ── card ──▶ DEChatLinkReceived (MCLContact, ownerSig)
|
||||
-> APIConnectPlan (plan only): resolves link,
|
||||
verifies ownerSig => CAPOk {csld, ownerVerification}
|
||||
-> if OVVerified:
|
||||
require connLink == ct.profile.contactLink
|
||||
peerType: CPTBot => bot, else business
|
||||
addContactReg (existing ct.contactId),
|
||||
status pending approval
|
||||
updateKnownContactFromLink ct (listed profile)
|
||||
notify admins (admin verifies a business)
|
||||
admins: /approve @<contactId>:<name> <n> -> status active -> listingsUpdated
|
||||
-> web listing.json + bot search include it
|
||||
|
||||
periodic loop, per registered contact:
|
||||
updateKnownContactFromLink ct (resolve link + updateContactFromLinkData)
|
||||
change => suspend / re-approval; address removed => suspend; address replaced => remove
|
||||
DEContactUpdated (message profile change) => same transitions
|
||||
```
|
||||
|
||||
No conversation is opened. The prepared address contact holds the profile without a connection; the directory's only network action is the opaque link-data fetch performed by `APIConnectPlan` at registration and on each refresh.
|
||||
No conversation is opened. The directory's only network action for a registration is the opaque
|
||||
link-data fetch performed by `APIConnectPlan` at registration and by `updateKnownContactFromLink` on
|
||||
each refresh.
|
||||
|
||||
## 3. What already exists (reuse map)
|
||||
|
||||
All grounded in the current tree:
|
||||
|
||||
- **Chat-link card type** — `MCLContact {connLink :: ShortLinkContact, profile :: Profile, business :: Bool}`
|
||||
already exists (`src/Simplex/Chat/Protocol.hs:731`). `MCChat {text, chatLink, ownerSig}` and
|
||||
(`src/Simplex/Chat/Protocol.hs:730`). `MCChat {text, chatLink, ownerSig}` and
|
||||
`LinkOwnerSig {ownerId, chatBinding, ownerSig}` at `Protocol.hs:726,736`.
|
||||
- **Owner-signature verification for contact addresses is already wired.** `connectPlan`'s
|
||||
`CTShortContact CCTContact` path fetches `FixedLinkData {rootKey}` + `UserContactData {owners}`
|
||||
and computes `ov = verifyLinkOwner rootKey owners l' sig_`, surfaced as
|
||||
`CTShortContact CCTContact` path fetches `FixedLinkData {rootKey}` + `UserContactData {owners}` and
|
||||
computes `ov = verifyLinkOwner rootKey owners l' sig_`, surfaced as
|
||||
`CPContactAddress (CAPOk {contactSLinkData_, ownerVerification})`
|
||||
(`src/Simplex/Chat/Library/Commands.hs:4356-4389`, `verifyLinkOwner` def `4634`; `Controller.hs:1104,1116-1124`).
|
||||
For plain/business addresses `owners == []`, so `ownerId = Nothing` and verification uses the
|
||||
link **root key** (`verifyLinkOwner` fallback).
|
||||
- **The directory already receives any `MCChat` card as `DEChatLinkReceived`** — `Directory/Events.hs:108`
|
||||
turns `(MCChat {chatLink, ownerSig}, Nothing)` into `DEChatLinkReceived`. Today `deChatLinkReceived`
|
||||
only matches `MCLGroup` and otherwise replies "Only channels can be added to directory via link."
|
||||
(`Directory/Service.hs:965-979`). We add an `MCLContact` case.
|
||||
(`src/Simplex/Chat/Library/Commands.hs:4360-4393`, `verifyLinkOwner` def `4634`;
|
||||
`Controller.hs:1116`). For plain/business addresses `owners == []`, so `ownerId = Nothing` and
|
||||
verification uses the link **root key**.
|
||||
- **The signature is bound to the sending conversation.** `mkLinkOwnerSig` signs
|
||||
`chatBinding <> connLink` (`Commands.hs:4619`); for a direct conversation `shareChatBinding` sets the
|
||||
binding to that connection's ratchet association-data hash (`Commands.hs:4626`). On receipt the core
|
||||
recomputes the receiving connection's hash and drops the signature when it differs
|
||||
(`Subscriber.hs:1855-1861`), so a forwarded or replayed card arrives without a signature.
|
||||
- **The directory already receives any `MCChat` card as `DEChatLinkReceived`** —
|
||||
`Directory/Events.hs:108` turns `(MCChat {chatLink, ownerSig}, Nothing)` into `DEChatLinkReceived`.
|
||||
Today `deChatLinkReceived` matches `MCLGroup` only and otherwise replies "Only channels can be added
|
||||
to directory via link." (`Directory/Service.hs:964-979`). An `MCLContact` case is added.
|
||||
- **A connected contact carries its published address.** `Profile.contactLink` (`Types.hs:699`) is
|
||||
stored in `contact_profiles.contact_link`; `getContactWithoutConnViaShortAddress`
|
||||
(`Store/Profiles.hs:608`) already matches `cp.contact_link = ?` (filtered to unconnected contacts).
|
||||
- **The core emits contact profile updates.** `CEvtContactUpdated {fromContact, toContact}`
|
||||
(`Controller.hs:940`), currently unmapped in `crDirectoryEvent_` (`Events.hs:80-115`).
|
||||
- **Card-sharing UI + API + signing** — `/share chat #g @to` → `SharePublicGroup`
|
||||
(`Commands.hs:2492`, parser `Commands.hs:5670`) → `APIShareChatMsgContent`
|
||||
(`Commands.hs:1185`) which builds the `MCChat` and signs with `mkLinkOwnerSig` +
|
||||
`shareChatBinding` (binds the card to the recipient connection, anti-replay).
|
||||
- **Address key + business flag storage** — `link_priv_sig_key` (the address root private key,
|
||||
Ed25519) is stored in `user_contact_links` by `createUserContactLink`
|
||||
(`src/Simplex/Chat/Store/Profiles.hs:429-439`); `businessAddress` lives in `AddressSettings`
|
||||
(`Profiles.hs:497-502`) and is published as `ContactShortLinkData.business`
|
||||
(`Commands.hs:4648`, `Protocol.hs:1553`). Note: `getUserAddress`/`UserContactLink`
|
||||
do **not** currently read `link_priv_sig_key` back (`Profiles.hs:479-524`).
|
||||
(`Commands.hs:2492`) → `APIShareChatMsgContent` (`Commands.hs:1185`), which builds the `MCChat` and
|
||||
signs with `mkLinkOwnerSig` + `shareChatBinding`.
|
||||
- **Address root key storage** — `link_priv_sig_key` (the address root private key, Ed25519) is stored
|
||||
in `user_contact_links` by `createUserContactLink` (`Store/Profiles.hs:429-439`); `businessAddress`
|
||||
lives in `AddressSettings` (`Profiles.hs:497-502`) and is published as `ContactShortLinkData.business`
|
||||
(`Commands.hs:4648`).
|
||||
- **Directory store / listing / web infra** — `sx_directory_group_regs` table
|
||||
(`Directory/Store/{SQLite,Postgres}/Migrations.hs`), `GroupReg`/`GroupRegStatus`
|
||||
(`Directory/Store.hs:116-226`), `getAllListedGroups_` (`Store.hs:354-363`), `generateListing`
|
||||
(`Directory/Listing.hs:148-170`), `DirectoryEntry`/`DirectoryEntryType = DETGroup`
|
||||
(`Listing.hs:55-86`), website renderer `website/src/js/directory.jsc`.
|
||||
(`Directory/Store.hs:116-194`), `getAllListedGroups_` (`Store.hs:354`), `generateListing`
|
||||
(`Directory/Listing.hs:148`), `DirectoryEntry`/`DirectoryEntryType = DETGroup` (`Listing.hs:55-86`),
|
||||
`verifiedGroupDomain` (`Store.hs:228`), website renderer `website/src/js/directory.jsc`.
|
||||
|
||||
## 4. Work items
|
||||
|
||||
### A. Protocol / types
|
||||
|
||||
- `MCLContact` exists; no new protocol message for the card itself.
|
||||
- **`ChatPeerType`** (`Types.hs:711`, implemented — now `CPTHuman | CPTBot | CPTBusiness | CPTUnknown Text`) with `CPTBusiness` and
|
||||
`CPTUnknown Text` (forward-compat, like `GTUnknown`). Update the `TextEncoding`/JSON instances
|
||||
(`Types.hs:724-731`): encode `CPTBusiness` as `"business"` and `CPTUnknown t` back to `t`
|
||||
(round-trips the original tag); make `textDecode` **lenient** — an unrecognized tag becomes
|
||||
`CPTUnknown t` instead of `Nothing`, so this version never fails to parse a profile with a future
|
||||
tag. **Verified constraint:** the *current* decoder is strict (`_ -> Nothing`) and `Profile`
|
||||
is `deriveJSON`-parsed, so an already-deployed app fails the whole profile on an unknown `peerType`;
|
||||
therefore `CPTBusiness` must not be published on profiles until the lenient version is broadly
|
||||
adopted. **MVP:** the directory types a **bot** from `peerType == CPTBot` and a **business** from
|
||||
`peerType ∈ {CPTHuman, CPTBusiness}` (unset ≙ `CPTHuman`; stored as `CPTBusiness`), and **rejects
|
||||
`CPTUnknown`**. Businesses are then admin-verified — the admin is the gate, as for channels.
|
||||
- **New optional `description :: Maybe Text` on `Profile`** (`Types.hs:693`), parallel to
|
||||
`GroupProfile.description` (`Types.hs:872`). Additive/nullable — only businesses/bots set it.
|
||||
It rides into the address link data automatically (`ContactShortLinkData` embeds the whole
|
||||
`Profile`, `Protocol.hs:1553`), so the directory reads it from the fetched link data. It is
|
||||
redacted per group policy in group-member profiles, on **both send and receive** (see §G). No
|
||||
version bump is needed — `Profile` is `deriveJSON`-parsed and aeson ignores unknown keys, so old
|
||||
apps just drop `description` (same as when `peerType`/`badge`/`contactDomain` were added).
|
||||
- **Setting `peerType`/`description` (for tests + eventual UI).** Both are plain `Profile` fields, so
|
||||
they ride through the existing profile-update path (`APIUpdateProfile` / the `/p` command); tests
|
||||
drive them via `/_profile`. A small dedicated setter for the multi-line `description` is worth
|
||||
adding for CLI ergonomics. The app-UI toggle to set `peerType = CPTBusiness` is deferred (per the
|
||||
wire-compat caveat above).
|
||||
- **Core connect-plan changes for the directory refresh (§C.3).** Rename `PRMAllGroups → PRMAll`
|
||||
(`Controller.hs:677`; uses at `Commands.hs:4419`, `Service.hs:572/834/972`) and resolve known
|
||||
contacts under it. `updateContactFromLinkData` (`Internal.hs:1523`) returns `(Contact, Bool)` — the
|
||||
change flag — adapting its existing call at `Commands.hs:4374`. `CAPKnown` (`Controller.hs:1121`)
|
||||
gains `updated :: Bool` and `ownerVerification :: Maybe OwnerVerification`, so a by-link re-plan of a
|
||||
prepared contact surfaces a change flag and ownership, as `GLPKnown` does for groups. New
|
||||
`resolveKnownContact` mirrors `resolveKnownGroup` (`Commands.hs:4471`).
|
||||
- **`ChatPeerType`** (`Types.hs:711`, implemented) with `CPTBusiness` and `CPTUnknown Text`
|
||||
(forward-compat), and a **lenient** decoder (`Types.hs:724-731`). Classification is by
|
||||
`peerType == CPTBot` (bot) versus otherwise (business); `CPTUnknown` is rejected. Because the
|
||||
current decoder is strict, `CPTBusiness` is not published on profiles until the lenient version is
|
||||
broadly adopted (a business profile stays `CPTHuman`, and the directory still types it a business).
|
||||
- **`description :: Maybe Text` on `Profile`** — already merged (`Types.hs`, #7256). It rides into the
|
||||
address link data (`ContactShortLinkData` embeds the `Profile`, `Protocol.hs:1553`). Send-side
|
||||
redaction in group-member profiles is done (`redactedMemberProfile`, `Internal.hs:1261`); the
|
||||
receive-side redaction (§G.2) is **not** implemented (verified: no `redactedMemberProfile`/
|
||||
`removeSimplexLink` in `Store/Groups.hs`).
|
||||
- **Setting `peerType`/`description`.** Both are plain `Profile` fields carried by the existing
|
||||
profile-update path (`APIUpdateProfile` / `/p`); tests drive them via `/_profile`. A dedicated
|
||||
setter for the multi-line `description` is worth adding for CLI ergonomics.
|
||||
- **Refresh function for the directory (§C.3).** Add
|
||||
`updateKnownContactFromLink :: User -> Contact -> CM (Contact, Bool)`: resolve the contact's
|
||||
published address (`Contact.profile.contactLink`) via `getShortLinkConnReq'` (`Internal.hs:1557`),
|
||||
decode the `ContactShortLinkData`, and apply `updateContactFromLinkData`; the `Bool` reports whether
|
||||
the listed profile or the verification changed. `updateContactFromLinkData` (`Internal.hs:1523`)
|
||||
changes to return `(Contact, Bool)`, adapting its sole existing caller (`Commands.hs:4374`). The
|
||||
directory calls the new function directly via `runReaderT … cc` (as `sendChatCmd` does,
|
||||
`Core.hs:103`) — no `connectPlan`, `PRMAll`, `resolveKnownContact`, or new `ChatCommand`.
|
||||
|
||||
### B. Client: prepare + share the contact-address card
|
||||
|
||||
**Status: B.1–B.3 are merged** — `APIShareMyAddress`/`ShareMyAddress` with handlers and parsers
|
||||
(`Controller.hs:401,567`; `Commands.hs:1200,2505,5472,5670`). B.5 (app UI) is the outstanding piece.
|
||||
|
||||
1. **Signing key — from the agent, not the chat DB.** Sign the card with the address short-link key
|
||||
via `getConnLinkPrivKey (aConnId addressConn)` (already in the agent, used at `Subscriber.hs:1597`;
|
||||
`getUserAddressConnection` gives the connection). This is the authoritative key — the private half
|
||||
of the short link's root key the directory verifies against — and it exists whenever the short link
|
||||
does, **including right after an upgrade** (`setConnShortLink` provisions it). Do **not** read the
|
||||
chat-DB `link_priv_sig_key` for signing: it is written only at `createUserContactLink` and never on
|
||||
upgrade. *(Separate cleanup, off the signing path: persist `link_priv_sig_key` on upgrade too —
|
||||
does, including right after an upgrade (`setConnShortLink` provisions it). Do not read the chat-DB
|
||||
`link_priv_sig_key` for signing: it is written only at `createUserContactLink` and never on upgrade.
|
||||
*(Separate cleanup, off the signing path: persist `link_priv_sig_key` on upgrade too —
|
||||
`setMyAddressData`/`setUserContactLinkShortLink` — reading it back via `getConnLinkPrivKey` so the
|
||||
column stops being stale.)*
|
||||
2. **Card-builder API — `APIShareMyAddress {toSendRef :: SendRef}`** (Controller) + handler in
|
||||
`Commands.hs`, mirroring the group-share case (`APIShareChatMsgContent`, `Commands.hs:1185`):
|
||||
`Commands.hs`, mirroring `APIShareChatMsgContent` (`Commands.hs:1185`):
|
||||
- `getUserAddress` → `connLinkContact` (short link) + profile + `businessAddress`.
|
||||
- `getUserAddressConnection` → conn; `getConnLinkPrivKey (aConnId conn)` → `rootPrivKey`
|
||||
(`Nothing` ⇒ not upgraded → error; the UI pre-empts this via §B.5).
|
||||
@@ -179,155 +179,204 @@ All grounded in the current tree:
|
||||
ownerSig = C.sign' rootPrivKey (cb <> smpEncode connShortLink)}` (contact variant of
|
||||
`mkLinkOwnerSig`, `ownerId = Nothing` so the directory verifies against the link root key).
|
||||
- return `CRChatMsgContent user (MCChat {text, chatLink = MCLContact {connLink, profile, business}, ownerSig})`.
|
||||
`SendRef` covers direct **and** group/channel targets.
|
||||
`SendRef` covers direct and group/channel targets.
|
||||
3. **CLI command — `ShareMyAddress {toChatName}`**, parser `/share address @to` / `/share address #to`
|
||||
(`Commands.hs:5670` neighborhood), handler mirroring `SharePublicGroup` (`Commands.hs:2492`):
|
||||
resolve `toChatName` → `SendRef` → `APIShareMyAddress` → `APISendMessages`. Shares to contacts and
|
||||
groups/channels alike.
|
||||
resolve `toChatName` → `SendRef` → `APIShareMyAddress` → `APISendMessages`.
|
||||
4. **Support-bot entry point — OUT OF SCOPE (deferred).** A headless business running
|
||||
`apps/simplex-support-bot` (TypeScript, no app UI) will eventually need a way to trigger the
|
||||
share — a bot admin/config command that calls `APIShareMyAddress` against the directory contact
|
||||
once connected. Deferred; the core `APIShareMyAddress`/`/share address` path built here is exactly
|
||||
what it will call.
|
||||
`apps/simplex-support-bot` will eventually need a bot command that calls `APIShareMyAddress` against
|
||||
the directory contact once connected. The core path built here is what it will call.
|
||||
5. **App UI — "Share via chat" (Phase 1; mirrors the channel share).** The receiving/rendering half
|
||||
already exists from the channel work (`MsgChatLink.Contact`, `CIChatLinkHeader`, the compose
|
||||
preview, and the `SharedContent → ShareListView → ComposeView` picker). New pieces: the entry
|
||||
point, a `SharedContent.AddressLink` case, the `apiShareMyAddress` call, and the upgrade branch.
|
||||
- **Entry point:** a **"Share via chat"** button (reuse the channel string) in the user's own
|
||||
address screen (`UserAddressView.kt`), beside the existing OS-share "Share" button. Address
|
||||
creation lands on this same screen (`createAddress` sets `userAddress`, `UserAddressView.kt:73-84`
|
||||
— verified), so the button is visible immediately after creating an address.
|
||||
- **Flow:** tap → if `userAddress.shouldBeUpgraded` (old full address) show an **upgrade alert**
|
||||
("To share your address in a chat it will be upgraded to a short link. All your contacts stay
|
||||
connected."), buttons **[Upgrade & share]** / **[Cancel]** — on confirm: spinner →
|
||||
`apiAddMyAddressShortLink`, **then** continue (two separate API calls, cleaner errors); no
|
||||
"share old" option. Then set `SharedContent.AddressLink` → `ShareListView` (contacts +
|
||||
groups/channels, with the simplex-link prohibition filtering) → pick destination → `ComposeView`
|
||||
`LaunchedEffect` calls `apiShareMyAddress` → sets the existing `ChatLinkPreview` → optional
|
||||
message text (same UX as the channel share) → **Send** → the recipient sees the existing
|
||||
`CIChatLinkHeader` card and taps to connect.
|
||||
- **Entry point:** a "Share via chat" button in the user's own address screen (`UserAddressView.kt`),
|
||||
beside the OS-share "Share" button. Address creation lands on this same screen
|
||||
(`createAddress` sets `userAddress`, `UserAddressView.kt:73-84`), so the button is visible
|
||||
immediately after creating an address.
|
||||
- **Flow:** tap → if `userAddress.shouldBeUpgraded` show an upgrade alert ("To share your address in
|
||||
a chat it will be upgraded to a short link. All your contacts stay connected."), buttons
|
||||
**[Upgrade & share]** / **[Cancel]** — on confirm: spinner → `apiAddMyAddressShortLink`, then
|
||||
continue. Then set `SharedContent.AddressLink` → `ShareListView` (contacts + groups/channels, with
|
||||
the simplex-link prohibition filtering) → pick destination → `ComposeView` `LaunchedEffect` calls
|
||||
`apiShareMyAddress` → sets the existing `ChatLinkPreview` → optional message text → **Send**.
|
||||
- iOS mirrors this via the existing channel-share flow (`f49d98511`); Kotlin per
|
||||
`plans/2026-04-17-kotlin-share-channel-link.md`.
|
||||
|
||||
### C. Directory: verify + store (no connect)
|
||||
|
||||
1. **`deChatLinkReceived` — add the `MCLContact` case** (`Directory/Service.hs:965`).
|
||||
- `deChatLinkReceived ct (MCLContact {connLink, business}) (Just ownerSig)`:
|
||||
- `APIConnectPlan userId (contact link) PRMAll (Just ownerSig)` — plan only (rename `PRMAllGroups` → `PRMAll`, extend to contact links). Returns `CPContactAddress (CAPOk {contactSLinkData_ = Just csld, ownerVerification})`. `verifyLinkOwner rootKey owners l' sig_` runs on this path (`Commands.hs:4379`, def `4634`); a plain/business address has `owners == []` and card `ownerId = Nothing`, so verification uses the link root key.
|
||||
1. **`deChatLinkReceived` — add the `MCLContact` case** (`Directory/Service.hs:964`).
|
||||
- `deChatLinkReceived ct (MCLContact {connLink, business}) (Just ownerSig)` — `business` is retained
|
||||
(unused for classification, kept for possible later use):
|
||||
- `APIConnectPlan userId (contact link) PRMAllGroups (Just ownerSig)` — plan only. Because the
|
||||
operator connected to the directory (its own address is not the connection's key), the plan
|
||||
resolves the link and returns `CPContactAddress (CAPOk {contactSLinkData_ = Just csld,
|
||||
ownerVerification})` (`contactRequestPlan:4565`). If the directory had instead connected to the
|
||||
operator via that address, the plan returns `CAPKnown ct'`/`CAPContactViaAddress ct'`; treat
|
||||
those the same — verified and registered against `ct'`. `verifyLinkOwner rootKey [] connLink
|
||||
(Just ownerSig)` runs on this path (`Commands.hs:4379`); verification uses the link root key.
|
||||
- `OVVerified`:
|
||||
- resolved `peerType` from `csld.profile`: `CPTBot` → bot; `CPTHuman`/`CPTBusiness` (unset ≙ `CPTHuman`) → business, stored `CPTBusiness`; `CPTUnknown` → reject ("unsupported account type").
|
||||
- `APIPrepareContact userId ccLink verifiedDomain csld` → the prepared entity: `Contact` (`business = False`, `createPreparedContact` → `SCTDirect`) or business `GroupInfo` (`business = True`, `createPreparedGroup` → `SCTGroup`) (`Commands.hs:2129`) — `business` is a link-data flag, orthogonal to `peerType`.
|
||||
- `addContactRegStore` referencing that entity (`contact_id` or `group_id`), resolved `peerType`, status `GRSProposed`.
|
||||
- require the short link within `csld.profile.contactLink` to equal `connLink` — the operator
|
||||
has published this address in the profile the link advertises. `Profile.contactLink` is a
|
||||
`ConnLinkContact` (`ConnectionLink 'CMContact`, full or short), so extract its short link and
|
||||
compare. If it is absent, or a full (non-short) link, reply asking the operator to publish the
|
||||
short address in its profile, and stop.
|
||||
- resolved `peerType` from `csld.profile`: `CPTBot` → bot; `CPTUnknown` → reject ("unsupported
|
||||
account type"); otherwise business.
|
||||
- `addContactRegStore cc ct peerType (GRSPendingApproval 1)` referencing `ct.contactId`.
|
||||
- `updateKnownContactFromLink user ct` to set the listed profile from the link (§C.3).
|
||||
- notify admins with the profile and the approve command.
|
||||
- `OVFailed reason` → "ownership verification failed".
|
||||
- `CAPKnown`/`GLPKnown` (already prepared) → re-registration path (§C.3), matched via `getContactRegByEntity`.
|
||||
- Keep the existing `MCLGroup` and fall-through cases unchanged.
|
||||
2. **New store table `sx_directory_contact_regs`** — named migration in `Directory/Store/SQLite/Migrations.hs` and `Directory/Store/Postgres/Migrations.hs`. The prepared entity is a contact (direct/bot) or a business group (`business = True`), so the row references one or the other.
|
||||
- An already-registered address (found via `getContactRegByContactId ct.contactId`) → re-approval
|
||||
(§C.3).
|
||||
- Keep the existing `MCLGroup` and fall-through cases; a non-card link still replies with a request
|
||||
to send a card.
|
||||
|
||||
Both the address check and `peerType` read the **link** profile `csld.profile`, which is also what
|
||||
updates the stored `Contact.profile` (§C.3), not the message-side `ct.profile`.
|
||||
2. **New store table `sx_directory_contact_regs`** — named migration in
|
||||
`Directory/Store/SQLite/Migrations.hs` and `Directory/Store/Postgres/Migrations.hs`. The
|
||||
registration is one-to-one with the existing contact.
|
||||
|
||||
```sql
|
||||
CREATE TABLE sx_directory_contact_regs(
|
||||
contact_reg_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contact_id INTEGER REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES groups(group_id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
user_contact_reg_id INTEGER NOT NULL,
|
||||
submitter_contact_id INTEGER NOT NULL REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
contact_id INTEGER REFERENCES contacts(contact_id) ON DELETE CASCADE,
|
||||
peer_type TEXT NOT NULL,
|
||||
contact_reg_status TEXT NOT NULL,
|
||||
contact_promoted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
CHECK ((contact_id IS NULL) <> (group_id IS NULL))
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_sx_directory_contact_regs_contact_id ON sx_directory_contact_regs(contact_id);
|
||||
CREATE UNIQUE INDEX idx_sx_directory_contact_regs_group_id ON sx_directory_contact_regs(group_id);
|
||||
CREATE UNIQUE INDEX idx_sx_directory_contact_regs_submitter_user_reg_id ON sx_directory_contact_regs(submitter_contact_id, user_contact_reg_id);
|
||||
```
|
||||
|
||||
Column roles (Postgres mirrors with `BIGSERIAL` + `TIMESTAMPTZ`):
|
||||
- `contact_reg_id` — global PK; the admin/superuser id.
|
||||
- `contact_id` / `group_id` — the prepared entity, exactly one set (`CHECK`): `contact_id` for a direct/bot address (`createPreparedContact`), `group_id` for a business-chat address (`createPreparedGroup`, `business = True`, `Commands.hs:2129`). Both `UNIQUE`; SQLite and Postgres treat NULLs as distinct, so the many-null side is unconstrained. Profile/`contactLink`/`contactDomain`/verification are read from the joined contact or group.
|
||||
- `user_contact_reg_id` — the user-facing id, from the shared per-submitter sequence (below).
|
||||
- `submitter_contact_id` — the submitter (forwarded the card); FK `contacts`, `ON DELETE CASCADE`; this is `dbContactId`, checked by `isOwner`.
|
||||
- `peer_type` — resolved listing type (`bot`/`business`); not recoverable from the profile (`peerType = human` for a business, §A).
|
||||
- `contact_reg_status` reuses the `GroupRegStatus` encoding (Q3); `contact_promoted` as `group_promoted`.
|
||||
- `contact_reg_id` — the registration's own autoincrement key; the global admin-facing id, as
|
||||
`group_id` is for groups.
|
||||
- `contact_id` — the registered contact; nullable and unique, so at most one address per contact
|
||||
now, and addresses without a contact stay possible later. Its `Contact.profile` is the listed
|
||||
profile, refreshed from the link (§C.3).
|
||||
- `peer_type` — resolved listing type (`bot`/`business`); not recoverable from the profile alone
|
||||
(`peerType` is `human` for a business, §A).
|
||||
- `contact_reg_status` reuses the `GroupRegStatus` encoding (Q3); `contact_promoted` as
|
||||
`group_promoted`.
|
||||
|
||||
**Shared per-submitter sequence.** `user_contact_reg_id` and `user_group_reg_id` draw from one per-submitter series, so `/list` numbers channels + bots + businesses uniquely. Allocated as `1 + MAX over both tables for the submitter` (COALESCE each sub-max to 0); race-free — inserts run only in the sequential event loop (`Service.hs:175-181`). `addGroupRegStore` (`Store.hs:252`) gains the second sub-select against `sx_directory_contact_regs`.
|
||||
|
||||
Types + functions in `Directory/Store.hs`; the joined entity is a contact or a business group, so queries return `(DirectoryContactEntity, ContactReg)`:
|
||||
No submitter column (the sender is the owner, §5) and no per-operator numbering (each contact
|
||||
registers one address). Types + functions in `Directory/Store.hs`, mirroring the `GroupReg` set;
|
||||
queries join `contacts`/`contact_profiles` and return `(Contact, ContactReg)`:
|
||||
|
||||
```haskell
|
||||
type ContactRegId = Int64
|
||||
type UserContactRegId = Int64
|
||||
|
||||
data DirectoryContactEntity = DCEContact Contact | DCEGroup GroupInfo
|
||||
|
||||
data ContactReg = ContactReg
|
||||
{ contactRegId :: ContactRegId,
|
||||
dbContactId :: ContactId,
|
||||
userContactRegId :: UserContactRegId,
|
||||
contactId :: Maybe ContactId,
|
||||
groupId :: Maybe GroupId,
|
||||
peerType :: ChatPeerType,
|
||||
contactRegStatus :: GroupRegStatus,
|
||||
promoted :: Bool,
|
||||
createdAt :: UTCTime
|
||||
}
|
||||
|
||||
addContactRegStore :: ChatController -> Contact -> DirectoryContactEntity -> ChatPeerType -> GroupRegStatus -> IO (Either String ContactReg)
|
||||
getContactAndReg :: ChatController -> User -> ContactRegId -> IO (Either String (DirectoryContactEntity, ContactReg))
|
||||
getUserContactReg :: ChatController -> User -> ContactId -> UserContactRegId -> IO (Either String (DirectoryContactEntity, ContactReg))
|
||||
getUserContactRegs :: ChatController -> User -> ContactId -> IO (Either String [(DirectoryContactEntity, ContactReg)])
|
||||
getContactRegByEntity :: ChatController -> DirectoryContactEntity -> IO (Either String (Maybe ContactReg))
|
||||
setContactRegStatus :: ChatController -> ContactRegId -> GroupRegStatus -> IO (Either String (GroupRegStatus, ContactReg))
|
||||
setContactPromoted :: ChatController -> ContactRegId -> Bool -> IO (Either String (DirectoryStatus, Bool))
|
||||
deleteContactReg :: ChatController -> ContactRegId -> IO (Either String ())
|
||||
getAllListedContacts :: ChatController -> User -> IO (Either String [(DirectoryContactEntity, ContactReg)])
|
||||
addContactRegStore :: ChatController -> Contact -> ChatPeerType -> GroupRegStatus -> IO (Either String ContactReg)
|
||||
getContactReg :: ChatController -> User -> ContactId -> IO (Either String (Contact, ContactReg))
|
||||
getContactRegByContactId :: ChatController -> ContactId -> IO (Either String (Maybe ContactReg))
|
||||
setContactRegStatus :: ChatController -> ContactRegId -> GroupRegStatus -> IO (Either String (GroupRegStatus, ContactReg))
|
||||
setContactPromoted :: ChatController -> ContactRegId -> Bool -> IO (Either String (DirectoryStatus, Bool))
|
||||
deleteContactReg :: ChatController -> ContactRegId -> IO (Either String ())
|
||||
getAllListedContacts :: ChatController -> User -> IO (Either String [(Contact, ContactReg)])
|
||||
```
|
||||
|
||||
`getContactRegByEntity` resolves re-registration — `getContactWithoutConnViaShortAddress` for a contact, `getGroupViaShortLinkToConnect` for a business group. `deleteContactReg` also deletes the prepared entity. `contact_reg_status` reuses `GroupRegStatus` including `GRSPendingApproval GroupApprovalId`, so the approval-version check is unchanged. Open decision: contacts as DB-only vs mirrored `CR*` append-only log records (`Store.hs:475`); the live read path uses the DB (`getAllListedGroups_`).
|
||||
3. **Registration lifecycle mirrors channels.** `proposed → pending approval → active`, plus `suspended/removed`. On submission, notify admins with the profile and an approve command.
|
||||
`getContactReg` and admin commands look up by `contact_id`. `contact_reg_status` reuses
|
||||
`GroupRegStatus`; the reachable subset for contacts is `GRSPendingApproval`, `GRSActive`,
|
||||
`GRSSuspended`, `GRSRemoved` (the join states `GRSProposed`/`GRSPendingUpdate` and the role state
|
||||
`GRSSuspendedBadRoles` never arise). Registrations are stored in the database only — the
|
||||
append-only log (`Store.hs:476-505`, written for groups only when `--directory-file` is given,
|
||||
Options.hs:139) gets no contact records.
|
||||
3. **Refresh and re-approval.**
|
||||
|
||||
Refresh requires core changes (§A). The `CCTContact` plan path (`Commands.hs:4362`) does **not** refresh a known entity today: `refreshContact` runs `updateContactFromLinkData` only for a by-name plan (`planDomain = Just`, `4372`), and there is no `resolveKnownContact`. Add, under `resolveMode == PRMAll`: `CAPKnown ct -> resolveKnownContact ct` (new, mirrors `resolveKnownGroup`, `4471`), and — for a business group found via the contact link (`getGroupToConnect`, `4402`) — `GLPKnown g -> resolveKnownGroup g` (existing). The periodic loop (`deGroupLinkCheck` analog, `Service.hs:828`) runs `APIConnectPlan … PRMAll` on each registered address link and refreshes the prepared contact or business group.
|
||||
- **Refresh** is scheduled by `linkCheckThread_` (`Service.hs:200`), which every `linkCheckInterval`
|
||||
enqueues one event per registered entity. Add a `DEContactLinkCheck ct` event alongside
|
||||
`DEGroupLinkCheck` and enqueue it over the contact regs; its handler (a `deGroupLinkCheck` analog,
|
||||
`Service.hs:828`) calls `runReaderT (updateKnownContactFromLink user ct) cc` (§A). That resolves
|
||||
the published address and applies `updateContactFromLinkData`, which overwrites `Contact.profile`
|
||||
with the link-advertised profile and reconciles `contactDomain`/`contactDomainVerified`. The
|
||||
`Bool` result drives re-approval.
|
||||
- There is one `Contact.profile`. Both a message update (`updateContactProfile`) and the link
|
||||
refresh write it, and either change requires re-approval, so a divergent profile is hidden until
|
||||
re-approved. No separate link-profile snapshot is kept; the listing reads `Contact.profile`.
|
||||
- **Message-side changes.** Map `CEvtContactUpdated` to a new `DEContactUpdated {fromContact,
|
||||
toContact}` (`Events.hs:80`). Its handler compares the visible fields of a registered contact
|
||||
(`displayName`, `fullName`, `shortDescr`, `description`, `image`, `contactLink`, `peerType`).
|
||||
- **Transitions**, each communicated to the operator:
|
||||
- a change to a visible field (name, description, image) → `GRSPendingApproval`, hidden until
|
||||
re-approved, as `reapprove` (`Service.hs:858`) does for channels;
|
||||
- the address removed from the profile → `GRSSuspended`, with a request to add it back;
|
||||
- the address replaced by a different one → `GRSRemoved` permanently, with a request to
|
||||
re-register.
|
||||
- Re-submission of an already-registered address (`getContactRegByContactId`) re-verifies ownership
|
||||
from the card and routes through the same transitions (`deReregistration` analog).
|
||||
4. **Admin & user commands — a directory-local target sum type.** Replace the group-id field of the
|
||||
shared commands with:
|
||||
|
||||
Re-approval on change: `updateContactFromLinkData` (`Internal.hs:1523`) gains a change `Bool` (returns `(Contact, Bool)`, mirroring `updateGroupFromLinkData`), and `CAPKnown` carries it plus `ownerVerification` (§A) — the current constructor carries only `Contact`. A change transitions the registration to pending approval (hidden until re-approved), as `reapprove` (`Service.hs:858`) for channels. The channel `checkValidOwner` owner-list re-check has no contact analog.
|
||||
```haskell
|
||||
data DirectoryRef
|
||||
= DRGroup UserGroupRegId (Maybe GroupName)
|
||||
| DRAddress (Maybe (ContactId, ContactName))
|
||||
```
|
||||
|
||||
Re-submission of an already-prepared address routes through the same plan (`CAPKnown`/`GLPKnown`, matched via `getContactRegByEntity`) and re-verifies ownership from the plan's `ownerVerification` (`deReregistration` analog).
|
||||
4. **Admin & user commands — shared commands take a `ChatRef`; group-only commands unchanged** (Q4, from the full `Service.hs` read).
|
||||
- `@`, `address`, or `addr` parses to `DRAddress Nothing` — the operator's own address.
|
||||
- `@ID:name` parses to `DRAddress (Just (ContactId, ContactName))`, matching the group `ID:name`
|
||||
form; the admin reference is `contact_id`, as `group_id` is for groups.
|
||||
- A non-admin command carrying `Just` is an error; an admin command carrying `Nothing` is an error.
|
||||
|
||||
The directory id resolves **by caller role**, not a fixed type: user-run commands use the per-user local id via `getUserGroupReg` (`deUserCommand:1158` — `if isAdmin then withGroupAndReg else withUserGroupReg`), admin/superuser commands the global id via `getGroupAndReg`, and listings print `if isAdmin then groupId else userGroupRegId` (`:1504`). This split is preserved verbatim for contacts, against the contact table.
|
||||
Shared commands gain contact support, dispatching on the `DirectoryRef` constructor: `DCApprove`,
|
||||
`DCReject`, `DCSuspend`, `DCResume`, `DCDelete`, `DCPromote`, `DCSendToOwner`, the admin `DCListLast`
|
||||
/ `DCListPending`, `DCExecuteCommand`, `DCHelp`, and search. Contact approval notifies the owner and
|
||||
lists the entry; it sends no join link and runs no captcha.
|
||||
|
||||
**Shared commands switch their id field to `ChatRef`** (`Directory/Events.hs`, `DirectoryCmd` GADT):
|
||||
- `DCApproveGroup {groupId,..}` → `DCApprove {chatRef :: ChatRef, displayName :: Text, approvalId :: GroupApprovalId, promote :: Maybe Bool}`
|
||||
- `DCRejectGroup` → `DCReject ChatRef Text`; `DCSuspendGroup` → `DCSuspend ChatRef Text`; `DCResumeGroup` → `DCResume ChatRef Text`
|
||||
- `DCDeleteGroup` → `DCDelete ChatRef Text`; `DCConfirmDuplicateGroup` → `DCConfirmDuplicate ChatRef Text`
|
||||
- `DCSendToGroupOwner` → `DCSendToOwner ChatRef Text Text`; `DCPromoteGroup` → `DCPromote ChatRef Text Bool`
|
||||
- `DCListUserGroups` keeps no id; its handler lists group + contact regs together (merged last/pending too).
|
||||
Group-membership commands stay group-only: `DCMemberRole`, `DCGroupFilter`,
|
||||
`DCShowUpgradeGroupLink`, `DCInviteOwnerToGroup`, and the `DCSubmitGroup`/`DCConfirmDuplicateGroup`
|
||||
pair (contacts begin via the card).
|
||||
|
||||
**Group-only commands are not modified** — they keep the bare group-id parser (`gc`/`gc_`): `DCMemberRole`, `DCGroupFilter`, `DCShowUpgradeGroupLink`, `DCInviteOwnerToGroup`. A `@` id there is not a valid decimal, so it falls through to `DCCommandError` — parser-level rejection, no handler type check.
|
||||
|
||||
**Parser** (`directoryCmdP`): a ref parser `('@' $> CTDirect) <|> ('#' $> CTGroup) <|> pure CTGroup` then `A.decimal`, building `ChatRef {chatType, chatId, chatScope = Nothing}`; used only by the shared commands. Bare = `CTGroup`, so `/approve 5:Name 1`, `/delete 5:Name` are unchanged.
|
||||
|
||||
**Handlers** (`de{User,Admin,SuperUser}Command`): each shared command branches on `chatType chatRef` — `CTGroup` = existing group logic with `chatId chatRef` (per-role `getUserGroupReg`/`getGroupAndReg`); `CTDirect` = mirrored contact logic (`getUserContactReg`/`getContactReg`); any other `chatType` = error. The `if isAdmin` local/global choice is shared, unchanged.
|
||||
|
||||
**Emitted command strings** carry the prefix — `/approve @<contactRegId>:<name> <n>` for contacts (bare for groups; `sendToApprove:822`), and `/list`/pending shows `@<id>` for contact rows (`sendGroupsInfo:1504`).
|
||||
5. **Listing identity + verified SimpleX names.** The listing identity is the prepared entity (`contact_id` or `group_id`), 1:1 with the link. Name↔link verification is inherited from the core: `updateContactFromLinkData` (contact) / `updateGroupFromLinkData` (business group) reconciles the domain claim and sets the verified flag on each refresh (`Internal.hs:1524`); no directory-side resolve-and-compare. The verified name is read from the joined entity (when verified) into `DirectoryEntry.simplexName` and bot/web search. A link that stops resolving to the claimed name clears the verified flag through the same path.
|
||||
`/list` shows the operator's group registrations by their numbers followed by the single address
|
||||
line, referenced as `address`. Admin approval and similar messages carry the pre-filled
|
||||
`@<contactId>:<name>`.
|
||||
5. **Listing identity + verified SimpleX names.** The listing identity is `contact_id`, one-to-one with
|
||||
the address. Name↔link verification is inherited from the core: `updateContactFromLinkData` sets
|
||||
`contactDomainVerified` on each refresh (`Internal.hs:1529`). A new
|
||||
`verifiedContactDomain :: Contact -> Maybe SimplexDomain` (counterpart of `verifiedGroupDomain`,
|
||||
`Store.hs:228`) reads it into `DirectoryEntry.simplexName` and bot/web search.
|
||||
|
||||
### D. Listing + web
|
||||
|
||||
1. **`DirectoryEntryType`** (`Listing.hs:55`): add `DETContact {peerType :: ChatPeerType}`. The
|
||||
`taggedObjectJSON`/`dropPrefix "DET"` derivation already emits `{"type":"contact", ...}` for a new
|
||||
constructor for free (single→multi constructor is transparent); `peerType` serializes as
|
||||
`"business"`/`"bot"`/etc.
|
||||
2. **`contactDirectoryEntry`** builder (analogue of `groupDirectoryEntry`, `Listing.hs:100`), from `(DirectoryContactEntity, ContactReg)`: `DirectoryEntry {entryType = DETContact peerType, displayName, simplexName, groupLink = PublicLink Nothing (Just connShortLink), shortDescr, welcomeMessage, imageFile, activeAt, createdAt}`. Profile fields (`displayName`, `shortDescr`, `description` → `welcomeMessage`, `image`), the link, and the domain → `simplexName` are read from the joined entity — a `Contact`'s `Profile` (`DCEContact`) or a business group's `GroupProfile` (`DCEGroup`); `peerType` from the `ContactReg`. `PublicLink` already models contact links (`Listing.hs:63-68`).
|
||||
3. **`generateListing`** (`Listing.hs:148`): merge group entries + contact entries into the single
|
||||
`DirectoryListing`. Feed the contact rows from `getAllListedContacts` (status active); build
|
||||
`DirectoryEntry`s from both sources and serialize together. `listingsUpdated` triggers stay as-is,
|
||||
plus fire on contact-reg status changes.
|
||||
`taggedObjectJSON`/`dropPrefix "DET"` derivation emits `{"type":"contact", …}` for the new
|
||||
constructor; `peerType` serializes as `"business"`/`"bot"`.
|
||||
2. **`contactDirectoryEntry`** builder (analogue of `groupDirectoryEntry`, `Listing.hs:100`), from
|
||||
`(Contact, ContactReg)`: `DirectoryEntry {entryType = DETContact peerType, displayName, simplexName,
|
||||
groupLink = PublicLink Nothing (Just connShortLink), shortDescr, welcomeMessage, imageFile, activeAt,
|
||||
createdAt}`. The profile fields (`displayName`, `shortDescr`, `description` → `welcomeMessage`,
|
||||
`image`), the link, and the verified domain → `simplexName` are read from `Contact.profile` (the
|
||||
link-advertised profile, §C.3); `peerType` from the `ContactReg`. `PublicLink` already models contact
|
||||
links (`Listing.hs:63-68`). Like `groupDirectoryEntry`, it returns
|
||||
`Maybe (DirectoryEntry, Maybe (FilePath, ImageFileData))`, the image filename hashed from the contact
|
||||
link (`imgFileData`, `Listing.hs:133`), so `generateListing` writes the image.
|
||||
3. **Export pipeline.** `generateListing` (`Listing.hs:148`,
|
||||
`FilePath -> [(GroupInfo, GroupReg, Maybe GroupLink)] -> IO ()`) builds entries via
|
||||
`groupDirectoryEntry`, writes each image into `listingImageFolder`, and saves `listing.json` +
|
||||
`promoted.json` (filtered by `promoted`) into a timestamped directory swapped in atomically via a
|
||||
symlink. Extend its signature to also take `[(Contact, ContactReg)]`, build and write those via
|
||||
`contactDirectoryEntry` alongside the group rows, and merge into the one `DirectoryListing {entries}`
|
||||
array; `promoted.json` includes promoted contacts (`contact_promoted`). The caller
|
||||
`updateGroupListingFiles` (`Service.hs:1557`) fetches `getAllListedContacts` beside
|
||||
`getAllListedGroups` and passes both; `listingsUpdated` fires on contact-reg status/promotion
|
||||
changes as it does for groups.
|
||||
4. **Website `directory.jsc`**: branch `displayEntries` on `entryType.type` and, for contacts, on
|
||||
`entryType.peerType`:
|
||||
- business vs bot label/avatar from `peerType` (`business`/`bot`); non-group avatar fallback
|
||||
instead of `/img/group.svg`;
|
||||
- "Connect"/"Chat" affordance instead of the "N members/subscribers" line (`entryMemberCount`
|
||||
- business vs bot label/avatar from `peerType`; non-group avatar fallback instead of
|
||||
`/img/group.svg`;
|
||||
- a "Connect"/"Chat" affordance instead of the "N members/subscribers" line (`entryMemberCount`
|
||||
already returns 0 for non-group — `directory.jsc:183-193`);
|
||||
- join URI already works via `connShortLink` (`directory.jsc:331-348`).
|
||||
Search/filter already reads generic fields (`displayName`, `shortDescr`, `welcomeMessage`,
|
||||
@@ -335,116 +384,97 @@ All grounded in the current tree:
|
||||
|
||||
### E. Bot search
|
||||
|
||||
Include active contact regs in the bot's search results (`DCSearchGroup` path,
|
||||
`Service.hs:1115`, backed by `searchListedGroups` in `Store.hs`) as **one unified result set** (not a
|
||||
separate contact search); match on display name and SimpleX name.
|
||||
Include active contact regs in the bot's search results (`DCSearchGroup` path, `Service.hs:1115`,
|
||||
backed by `searchListedGroups`) as one unified result set; match on display name and SimpleX name.
|
||||
|
||||
### F. Tests
|
||||
|
||||
- **Client** (`tests/ChatTests/`): `/share address` produces an `MCChat`/`MCLContact` card with a
|
||||
valid `ownerSig` (`ownerId = Nothing`); parser test for `/share address`.
|
||||
- **Directory** (`tests/Bots/DirectoryTests.hs`, mirroring `testRegisterChannelViaCard`
|
||||
`:2050` and `testDirectoryChannelName` `:2129`): register a business and a bot via card
|
||||
(verified → pending → admin approve → listed), reject on bad/absent signature, search finds it,
|
||||
and the generated `listing.json` contains a `"type":"contact"` entry with the right `peerType`
|
||||
(`business`/`bot`). Wire under the names/SMP test harness as needed.
|
||||
- **Profile description** (§G): a member's `description` is **redacted per the group's policy** in
|
||||
the profile others receive in a group (send side) and when stored from an incoming member profile
|
||||
(receive side) — links/names stripped when the group prohibits them, clean prose passing through;
|
||||
a direct contact / address preview keeps it full.
|
||||
- **Client** (`tests/ChatTests/`): `/share address` produces an `MCChat`/`MCLContact` card with a valid
|
||||
`ownerSig` (`ownerId = Nothing`); parser test for `/share address`.
|
||||
- **Directory** (`tests/Bots/DirectoryTests.hs`, mirroring `testRegisterChannelViaCard` `:2050`):
|
||||
register a business and a bot via card (verified → pending → admin approve → listed), reject on
|
||||
bad/absent signature, reject when the address is not in the sender's profile, search finds it, a
|
||||
profile change de-lists per §C.3, and the generated `listing.json` contains a `"type":"contact"`
|
||||
entry with the right `peerType`.
|
||||
- **Profile description** (§G): a member's `description` is redacted per the group's policy on both
|
||||
send and receive; a direct contact / address preview keeps it full.
|
||||
|
||||
### G. `Profile.description` field + member-profile redaction (resolved)
|
||||
|
||||
`description :: Maybe Text` is added to `Profile` (§A). In group-member profiles it is **redacted
|
||||
per the group's policy — the same treatment `shortDescr` gets today** (not removed wholesale):
|
||||
links and SimpleX names are stripped when the group prohibits them.
|
||||
`description :: Maybe Text` is added to `Profile` (§A). In group-member profiles it is redacted per the
|
||||
group's policy — the same treatment `shortDescr` gets — not removed wholesale: links and SimpleX names
|
||||
are stripped when the group prohibits them. Implemented in `redactedMemberProfile`
|
||||
(`Internal.hs:1259-1273`).
|
||||
|
||||
1. **Send side** — in `redactedMemberProfile` (`Internal.hs:1259`, which already redacts
|
||||
`shortDescr`/`contactLink`/name-proof under the group's `SGFSimplexLinks`/`SGFDirectMessages`),
|
||||
also redact `description` — with a **new inline-strip helper** (per G.3), not `shortDescr`'s
|
||||
drop-whole `removeSimplexLink`. Adding `description` to `Profile` forces this output record to be
|
||||
rebuilt here anyway. (Used on every member-profile-out path — `Internal.hs:1247,1255`,
|
||||
`Subscriber.hs:803,3220`, `Commands.hs:4230`.)
|
||||
2. **Receive side** — apply the same redaction when ingesting a member profile from the network, so
|
||||
a peer can't inject a link/name-laden description. Chokepoints: `updateMemberProfile`
|
||||
(`Store/Groups.hs:3388`) and member creation (`Store/Groups.hs:2510`, `1395`); prefer a single
|
||||
helper mirroring the send-side redaction.
|
||||
3. **Redaction granularity (RESOLVED).** **Inline-strip links and names** — drop the
|
||||
`Uri`/`HyperLink`/`SimplexLink`/`SimplexName` (the `isLink` set, `Markdown.hs:184`) and `Mention`
|
||||
spans via `parseMaybeMarkdownList`, re-concat the remaining `FormattedText`, keep the prose (empty
|
||||
result ⇒ `Nothing`). **Exception:** if `hasObfuscatedSimplexLink` matches (a link that can't be
|
||||
cleanly isolated as a token), drop the **whole** description.
|
||||
4. **Kept full where wanted** — the address link data (`ContactShortLinkData` embeds the full,
|
||||
unredacted profile), the direct contact profile view, and the directory listing all carry the
|
||||
full `description`. Group redaction applies only to member-profile *delivery into a group*, a
|
||||
separate code path. For the **directory** page, abuse is gated by **admin review** (Q7), not an
|
||||
automatic filter.
|
||||
5. **UI/UX** — add a multi-line "Description" field to the profile/address editor (app UI, follow-on
|
||||
with §B.5). Because the field can carry into groups (redacted), an edit-time hint that links and
|
||||
names won't show where a group prohibits them is worthwhile, mirroring `shortDescr`.
|
||||
1. **Send side** — in `redactedMemberProfile` (which already redacts `shortDescr`/`contactLink`/name
|
||||
proof under the group's `SGFSimplexLinks`/`SGFDirectMessages`), redact `description` with an inline
|
||||
strip helper (per G.3). Adding `description` to `Profile` forces this output record to be rebuilt
|
||||
here anyway. (Used on every member-profile-out path — `Internal.hs:1247,1255`, `Subscriber.hs:803,3220`,
|
||||
`Commands.hs:4230`.)
|
||||
2. **Receive side** — apply the same redaction when ingesting a member profile. Chokepoints:
|
||||
`updateMemberProfile` (`Store/Groups.hs:3388`) and member creation (`Store/Groups.hs:2510,1395`).
|
||||
3. **Redaction granularity.** Inline-strip links and names — drop the
|
||||
`Uri`/`HyperLink`/`SimplexLink`/`SimplexName`/`Mention` spans via `parseMaybeMarkdownList`, re-concat
|
||||
the remaining `FormattedText`, keep the prose (empty result ⇒ `Nothing`). Exception: if
|
||||
`hasObfuscatedSimplexLink` matches, drop the whole description.
|
||||
4. **Kept full where wanted** — the address link data, the direct contact profile view, and the
|
||||
directory listing all carry the full `description`. Directory abuse is gated by admin review (Q7).
|
||||
5. **UI/UX** — add a multi-line "Description" field to the profile/address editor (app UI, follow-on with
|
||||
§B.5), with an edit-time hint that links and names won't show where a group prohibits them.
|
||||
|
||||
### H. App visibility of `peerType` + `description` (why owners will set them)
|
||||
|
||||
These are persistent profile identity shown to everyone who reaches the address — independent of the
|
||||
directory. That is the reason to fill them in; the directory is a bonus channel. Existing surfaces
|
||||
(multiplatform paths; iOS/Android mirror them):
|
||||
directory.
|
||||
|
||||
**`peerType` — type icon / badge** (small, already-present surfaces):
|
||||
**`peerType` — type icon / badge**:
|
||||
- Pre-connect "Open chat?" alert (`newchat/ConnectPlan.kt:698-713`) — type icon + verification;
|
||||
briefcase when **either** the address `business` flag or `peerType == CPTBusiness`, bot cube from
|
||||
`peerType`, else person (see §1). The alert holds no description (too small — `AlertManager.kt:289`).
|
||||
briefcase when either the address `business` flag or `peerType == CPTBusiness`, bot cube from
|
||||
`peerType`, else person.
|
||||
- Chat list (`chatlist/ChatPreviewView.kt:188`, `isBot`) and the chat banner
|
||||
(`chat/ChatView.kt:2234` `ChatBannerView`, which already has per-type captions — bot / business /
|
||||
contact) — extend to a business marker from `peerType`.
|
||||
(`chat/ChatView.kt:2234` `ChatBannerView`) — extend to a business marker from `peerType`.
|
||||
|
||||
**`description` — shown via a "Read more" affordance, NOT inline** (the alert and the in-chat link
|
||||
card `CIChatLinkHeader.kt` are too small — they carry only the short teaser). Rendered in **two
|
||||
surfaces: the chat banner (`ChatBannerView`) and the contact info page (`ChatInfoView`, `:778`)**:
|
||||
- Teaser text: if `shortDescr` is present → show `shortDescr`, then a clickable **"Read more"**; if
|
||||
`shortDescr` is absent → show the first line of `description` truncated to 100 chars with ellipsis
|
||||
(up to the first line break), then **"Read more"**. "Read more" appears only when a `description`
|
||||
exists to reveal.
|
||||
- **"Read more" is a client-only `Format` span (Phase 1, implemented).** The `Modal {modalName}`
|
||||
variant lives only in the app's mirrored `Format` enum (Kotlin/Swift); it is **not** in Haskell
|
||||
`Markdown.hs`. The teaser is built app-side from the profile fields, and each client resolves the
|
||||
label and modal content from the current chat by `modalName`, rendering the tap (iOS sheet / Android
|
||||
modal). No Haskell core change.
|
||||
- This is NOT the welcome/auto-reply message (`AddressSettings.autoReply`, a transient on-connect
|
||||
message), and NOT shown in the pre-connect alert or the shared-link card.
|
||||
**`description` — shown via a "Read more" affordance, NOT inline** (the alert and the in-chat link card
|
||||
`CIChatLinkHeader.kt` carry only the short teaser). Rendered in the chat banner (`ChatBannerView`) and
|
||||
the contact info page (`ChatInfoView`, `:778`):
|
||||
- Teaser: `shortDescr` if present, then a clickable "Read more"; otherwise the first line of
|
||||
`description` truncated to 100 chars, then "Read more".
|
||||
- "Read more" is a client-only `Format` span (implemented). The `Modal {modalName}` variant lives only
|
||||
in the app's mirrored `Format` enum (Kotlin/Swift), not in Haskell `Markdown.hs`. Each client resolves
|
||||
the label and modal content by `modalName`, rendering the tap (iOS sheet / Android modal). No Haskell
|
||||
change.
|
||||
- This is NOT the welcome/auto-reply message (`AddressSettings.autoReply`), and NOT shown in the
|
||||
pre-connect alert or the shared-link card.
|
||||
|
||||
**Profile editor** (`usersettings/UserProfileView.kt`) — add the multi-line description field and a
|
||||
way to set the account type (`peerType`). Note: the editor exposes two separate "business" concepts
|
||||
— `peerType` (identity) and the `businessAddress` conversation-type setting — which must use distinct
|
||||
labels, since both otherwise read as "business."
|
||||
|
||||
Note: before connecting, the only surface with room to read the full description is the directory web
|
||||
page; in-app it is the banner/info "Read more" once the (prepared) chat is open.
|
||||
**Profile editor** (`usersettings/UserProfileView.kt`) — add the multi-line description field and a way
|
||||
to set the account type (`peerType`). The editor exposes two distinct "business" concepts — `peerType`
|
||||
(identity) and the `businessAddress` conversation-type setting — which must use distinct labels.
|
||||
|
||||
## 5. Files to touch (summary)
|
||||
|
||||
- `src/Simplex/Chat/Types.hs` — extend `ChatPeerType` (`CPTBusiness`, `CPTUnknown`, lenient decode);
|
||||
add `Profile.description`; JSON/TextEncoding derivations.
|
||||
- `src/Simplex/Chat/Controller.hs` / `Library/Commands.hs` / `Library/Internal.hs` — refresh core changes (§A/§C.3): `PRMAllGroups → PRMAll` + `resolveKnownContact`, `CAPKnown` gains `updated`/`ownerVerification`, `updateContactFromLinkData → (Contact, Bool)`.
|
||||
- App views (Phase 1, §B.5/§H) — `UserAddressView.kt` ("Share via chat" button + upgrade branch),
|
||||
`ChatInfoView.kt` + `ChatView.kt` `ChatBannerView` (description teaser + `Modal` "Read more"), the
|
||||
Kotlin/Swift `Format` mirror (`Modal` case + tap → sheet/alert) (+ iOS equivalents). The `peerType`
|
||||
badge/editor UI is deferred.
|
||||
- `src/Simplex/Chat/Library/Internal.hs` — `updateKnownContactFromLink`;
|
||||
`updateContactFromLinkData → (Contact, Bool)`; redact `description` in `redactedMemberProfile` (§G).
|
||||
- `src/Simplex/Chat/Library/Commands.hs` — adapt the `updateContactFromLinkData` caller (`:4374`);
|
||||
`APIShareMyAddress` + `ShareMyAddress` handlers + parsers; reuse `shareChatBinding`.
|
||||
- `src/Simplex/Chat/Controller.hs` — `APIShareMyAddress`, `ShareMyAddress` command constructors.
|
||||
- `src/Simplex/Chat/Library/Commands.hs` — handlers + parsers for the two new commands; reuse
|
||||
`shareChatBinding`.
|
||||
- `src/Simplex/Chat/Library/Internal.hs` — redact `description` per group policy in `redactedMemberProfile` (send side, §G).
|
||||
- `src/Simplex/Chat/Store/Groups.hs` — redact `description` when ingesting a member profile (receive side, §G).
|
||||
- `src/Simplex/Chat/Store/Groups.hs` — redact `description` when ingesting a member profile (§G).
|
||||
- `src/Simplex/Chat/Store/Profiles.hs` — persist `link_priv_sig_key` on short-link upgrade
|
||||
(`setUserContactLinkShortLink`/`setMyAddressData`); card signing uses the agent's
|
||||
`getConnLinkPrivKey`, not this column.
|
||||
(cleanup, off the signing path).
|
||||
- `apps/simplex-directory-service/src/Directory/Service.hs` — `MCLContact` case in
|
||||
`deChatLinkReceived`; contact-reg lifecycle + admin/user commands; listing trigger.
|
||||
- `apps/simplex-directory-service/src/Directory/Store.hs` — `ContactReg` model + queries.
|
||||
`deChatLinkReceived`; `DEContactUpdated` handling; contact-reg lifecycle + admin/user commands;
|
||||
listing trigger.
|
||||
- `apps/simplex-directory-service/src/Directory/Events.hs` — map `CEvtContactUpdated`; the
|
||||
`DirectoryRef` target type in `directoryCmdP` and the shared command constructors.
|
||||
- `apps/simplex-directory-service/src/Directory/Store.hs` — `ContactReg` model + queries;
|
||||
`verifiedContactDomain`.
|
||||
- `apps/simplex-directory-service/src/Directory/Store/{SQLite,Postgres}/Migrations.hs` — new table.
|
||||
- `apps/simplex-directory-service/src/Directory/Events.hs` — extend `directoryCmdP` to parse the
|
||||
`@`/`#` chat-type prefix and thread the chat type into the (shared) command constructors.
|
||||
- `apps/simplex-directory-service/src/Directory/Listing.hs` — `DETContact`, `contactDirectoryEntry`,
|
||||
merge in `generateListing`.
|
||||
- App views (Phase 1, §B.5/§H) — `UserAddressView.kt`, `ChatInfoView.kt` + `ChatView.kt`
|
||||
`ChatBannerView`, the Kotlin/Swift `Format` mirror (+ iOS equivalents). The `peerType` badge/editor UI
|
||||
is deferred.
|
||||
- `website/src/js/directory.jsc` (+ a contact/bot avatar asset) — non-group card rendering.
|
||||
- `tests/Bots/DirectoryTests.hs`, `tests/ChatTests/*` — tests.
|
||||
|
||||
@@ -452,63 +482,57 @@ page; in-app it is the banner/info "Read more" once the (prepared) chat is open.
|
||||
|
||||
Resolved:
|
||||
|
||||
- **Submission model (RESOLVED: identical to channels).** Submission is by the **link owner**,
|
||||
**signed with the address key** — exactly the channel card flow, no extra requirement. The
|
||||
`ownerSig` (only the key-holder can produce it) is the authorization. Admins then decide to list;
|
||||
any profile change sends it back to admin review; the address is re-read on the same periodic loop
|
||||
— all as for channels. The only "submitter ≠ owner" accommodation is giving the headless support
|
||||
bot a way to send (§B.4). *(Earlier we explored open submission with the verified SimpleX name as
|
||||
the authenticity signal, and an opt-in flag in the address link data; both dropped — the channel
|
||||
model already answers authorization, and name-verification proves identity, not consent to list.)*
|
||||
- **Description home (RESOLVED: `Profile.description`, redacted per group policy).** New profile
|
||||
field. In group-member profiles it is redacted the same way `shortDescr` is (links/names stripped
|
||||
under the group's policy, §G), not removed wholesale; carried full in the address link data /
|
||||
direct view / directory. Directory abuse is gated by admin review (Q7).
|
||||
- **Q1 — Verification (RESOLVED: reuse the plan).** Already verifiable via `APIConnectPlan` — the
|
||||
same `verifyLinkOwner` path channels use, no new code. The intrinsic link-data fetch (to get the
|
||||
root public key) is opaque and not a connection. No card/protocol extension.
|
||||
- **Q4 — Command surface (RESOLVED: same commands, extended with chat type).** Reuse the existing
|
||||
command constructors and syntax with a chat-type discriminator on the id token — `#` group
|
||||
(existing), `@` contact (new) — e.g. `/approve @<id>:<name> <version>`. The prefix disambiguates
|
||||
the overlapping `group_id`/`contact_reg_id` spaces, so no parallel command names are needed.
|
||||
- **Q5 — SimpleX names (RESOLVED: support now).** Verify name↔link consistency for addresses and
|
||||
populate `simplex_name`; flows through to listing + search (see §C.5).
|
||||
|
||||
- **Q2 — Entry type (RESOLVED: `ChatPeerType`, typed + admin-verified).** The listing type is a
|
||||
`ChatPeerType`: **bot** from `peerType == CPTBot`; **business** from `peerType ∈ {CPTHuman,
|
||||
CPTBusiness}` (unset ≙ `CPTHuman`; stored as `CPTBusiness`); `CPTUnknown` is rejected. Because
|
||||
`CPTBusiness` can't be published on profiles yet (wire-compat, §A), a business's profile is
|
||||
`CPTHuman` in practice; the admin verifies it. When profiles can carry `CPTBusiness`, it's read
|
||||
directly.
|
||||
- **Q3 — Reg status type (RESOLVED: reuse the group/channel type).** Use the same `GroupRegStatus`
|
||||
the channel registrations use — no separate `ContactRegStatus`. The lifecycle mirrors channels.
|
||||
- **Q6 — Updates (RESOLVED: prepared contact refreshed on the channel loop).** The periodic loop runs `APIConnectPlan … PRMAll` on each registered address link (`deGroupLinkCheck` analog, `Service.hs:828`); under `PRMAll` a new `resolveKnownContact` (mirroring `resolveKnownGroup`) invokes `updateContactFromLinkData` to refresh the prepared contact and reconcile `contactDomain`/`contactDomainVerified`; a business group refreshes via `resolveKnownGroup`. No connection (§A/§C.3).
|
||||
- **Q7 — Description screening (RESOLVED: two surfaces, two mechanisms).** *Directory page:* admin
|
||||
approval is the gate — a profile change (incl. description) triggers re-approval, hiding the
|
||||
address until re-approved, exactly like a channel profile change; no separate automatic content
|
||||
filter on the directory description. *Group member profiles:* the group's own policy redacts the
|
||||
description on delivery (links/names stripped like `shortDescr`, §G). The two are independent.
|
||||
- **Submission model (RESOLVED: owner-signed card).** Submission is by the address owner, signed with
|
||||
the address root key, bound to the sending conversation. The directory neither connects to nor
|
||||
prepares the address; it references the sender's existing contact. Admins decide to list; any profile
|
||||
change returns it to review; the address is re-read on the periodic loop.
|
||||
- **Ownership proof (RESOLVED: sufficient).** The card signs the sending connection's ratchet-ad-hash
|
||||
plus the link, under the address root key; a forwarded card loses its signature on receipt
|
||||
(`Subscriber.hs:1856`), and `verifyLinkOwner` checks it against the resolved root key. The published
|
||||
address in the profile is a listing requirement for user convenience, not the proof.
|
||||
- **Refresh (RESOLVED: direct function, no plan).** `updateKnownContactFromLink` resolves the published
|
||||
address and applies `updateContactFromLinkData`, called directly via `runReaderT … cc`. `connectPlan`
|
||||
is the wrong tool: it finds contacts only by `conn_short_link_to_connect`, never set for an operator
|
||||
that connected to the directory. The listed profile is the link-advertised one, which guards against
|
||||
a divergent advertised profile.
|
||||
- **Entry type (RESOLVED: `ChatPeerType`).** Bot when `peerType == CPTBot`; business otherwise;
|
||||
`CPTUnknown` rejected. A business profile is `CPTHuman` in practice (wire-compat, §A), and the admin
|
||||
verifies it.
|
||||
- **Reg status (RESOLVED: reuse `GroupRegStatus`).** The reachable subset for contacts is
|
||||
`GRSPendingApproval`/`GRSActive`/`GRSSuspended`/`GRSRemoved`.
|
||||
- **Identity + numbering (RESOLVED: one contact, one address).** The registration is one-to-one with the
|
||||
contact, so there is no submitter and no per-operator number. `contact_reg_id` is the admin-facing id;
|
||||
admins reference `contact_id`, as they reference `group_id` for groups.
|
||||
- **Command surface (RESOLVED: `DirectoryRef`).** A directory-local sum type distinguishing a group
|
||||
(by number) from an address (`@`/`address`, with `@ID:name` for admins). No core `ChatRef`.
|
||||
- **SimpleX names (RESOLVED: support now).** `verifiedContactDomain` reads the verified domain into the
|
||||
listing and search.
|
||||
- **Description (RESOLVED: `Profile.description`, redacted per group policy).** Implemented in
|
||||
`redactedMemberProfile` (§G). Directory abuse is gated by admin review.
|
||||
|
||||
## 7. Suggested sequencing
|
||||
|
||||
**Phase 1 — UX prerequisites (self-contained; do these first — no registration work until they
|
||||
land).**
|
||||
**Phase 1 — UX prerequisites.** Largely merged; outstanding items marked.
|
||||
|
||||
1. **`Profile.description` field** (§A) + member-profile redaction on send and receive (§G) + a test
|
||||
that a member's description is redacted per group policy.
|
||||
2. **Show the description in the app** — banner + contact-info "Read more" via the `Modal` markdown
|
||||
element (§H). This is the "see how it looks" step; iterate on the UX here.
|
||||
3. **`ChatPeerType` extension** (`CPTBusiness`, `CPTUnknown`, lenient decoder) (§A) — the type only,
|
||||
**no UI** to set or display it yet.
|
||||
4. **Share a contact link via chat** — core (`getUserAddressSignKey`, `APIShareAddress`,
|
||||
`/share address`, §B.1–3) + the app share UI mirroring the channel share (§B.5) + a client test on
|
||||
the signed `MCLContact` card.
|
||||
1. `Profile.description` field — merged. Send-side member redaction — merged (`redactedMemberProfile`,
|
||||
`Internal.hs:1261`). **Outstanding: receive-side redaction (§G.2)** + a test that a member's
|
||||
description is redacted per group policy.
|
||||
2. `ChatPeerType` extension (`CPTBusiness`, `CPTUnknown`, lenient decoder) — merged (`Types.hs:711`).
|
||||
3. Core share path (`APIShareMyAddress`, `/share address`, §B.1-3) — merged. **Outstanding: the app
|
||||
"Share via chat" UI (§B.5)** + a client test on the signed `MCLContact` card.
|
||||
4. **Outstanding (verify against the app before treating as done): in-app description display** —
|
||||
banner + contact-info "Read more" via the `Modal` element (§H).
|
||||
|
||||
**Phase 2 — directory (only after Phase 1).**
|
||||
|
||||
5. Directory store: migration (two nullable ids + `CHECK`) + `ContactReg` model/queries + shared per-submitter sequence.
|
||||
6. Core refresh changes (§A: `PRMAll`/`resolveKnownContact`, `CAPKnown.ownerVerification`, `updateContactFromLinkData` change flag), then `deChatLinkReceived` `MCLContact` case (verify → `APIPrepareContact` → `addContactRegStore`) + admin approval + directory test through to "listed".
|
||||
7. Listing merge (`DETContact` + `contactDirectoryEntry` + `generateListing`) + one unified
|
||||
group+contact search + website rendering.
|
||||
5. Core refresh function (§A: `updateKnownContactFromLink`, `updateContactFromLinkData → (Contact,
|
||||
Bool)`).
|
||||
6. Directory store: migration (`sx_directory_contact_regs`) + `ContactReg` model/queries +
|
||||
`verifiedContactDomain`.
|
||||
7. `deChatLinkReceived` `MCLContact` case (verify → `addContactRegStore` → initial refresh) +
|
||||
`DEContactUpdated` handling + the re-approval/suspend/remove transitions + admin approval + directory
|
||||
test through to "listed".
|
||||
8. Listing merge (`DETContact` + `contactDirectoryEntry` + `generateListing`) + one unified group+contact
|
||||
search + website rendering.
|
||||
|
||||
**Deferred:** peerType setting/badge UI; the support-bot entry point (§B.4).
|
||||
Reference in new issue
Block a user