Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Docs/AppAPI/guide
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,23 @@ operation itself declared with `remote=True` — and only through the control
plane, which is the one door that knows the difference. An app is not asked to
work out whether its caller is at the keyboard.

### An operation that names its own permission

`app_api.operation(name, doc, params, changes=…, permission="contacts.read")`
says which permission an **app** caller needs; without it the call is the read or
the change of the apps it always was. The name is kept in the declaration (it
travels in the catalogue, so the Internal API page shows it) but is **not**
carried by `declare_all`, which is what an app outside the node declares: a
permission is the node's to name, not a thing a package states about itself.

## What ships today

| app | operation | changes | remote | what it answers |
|---|---|---|---|---|
| `chat` | `peer` | no | no | pseudo, whether it is a contact, unread count, where the conversation lives |
| `chat` | `contact` | yes | no | add a node to the address book (not a grant, and not a name — that is the node's own, signed) |
| `chat` | `contact` | yes | no | add a node to the address books through the contacts app (not a grant, and not a name — that is the node's own, signed); refused with a named permission when chat was not allowed to write |
| `contacts` | `books` `list` `names` `search` `find` | no | no | the address books — each declares `contacts.read` |
| `contacts` | `add` `remove` | yes | no | save or take out a node — each declares `contacts.write` |
| `fleet` | `relation` | no | **yes** | both directions of the ledger: rights held over that node, rights it holds here, requests pending either way |
| `fleet` | `enrol` | yes | no | ask a node for the right to manage it |
| `fleet` | `request` | yes | no | ask a node already managed for extra rights |
Expand Down
15 changes: 14 additions & 1 deletion Docs/AppPermissions/guide
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ manifest that asks for something else is told which name it got wrong.
| `readstate.trust` | sensitive | trust anchors, witnesses, membership, signing keys held (never the keys) |
| `readstate.updates` | sensitive | releases, packages, the installed set, transfers |
| `readstate.apps` | sensitive | apps, their state, and their read-only operations |
| `contacts` | sensitive | **everything below** — the address books ([`../Apps/contacts`](../Apps/contacts)) |
| `contacts.read` | sensitive | the books, the contacts in them and the names they were given; searching them |
| `contacts.write` | sensitive | adding a node to the books, taking one out |
| `control` | dangerous | **everything below** — what an operator at the console can do |
| `control.node` | dangerous | ping, speed test, forget, retry, restart, rename |
| `control.network` | dangerous | probing, punching, listening, discovery, multi-link |
Expand All @@ -51,7 +54,8 @@ manifest that asks for something else is told which name it got wrong.
| `modding` | dangerous | standing in front of the node's own operations — before, after, or instead of them |

**A parent covers its children.** Granting `readstate` grants all nine reads;
granting `readstate.logs` grants that one. Taking a parent back takes its children
granting `readstate.logs` grants that one. Reading is not writing: `contacts.read`
does not give `contacts.write`. Taking a parent back takes its children
with it: a page that unticks `readstate` and leaves `readstate.logs` ticked has
said two contradictory things, and the narrower one is kept.

Expand Down Expand Up @@ -256,6 +260,15 @@ declared a manifest: attached now (with its own token or only the shared one),
installed with a `nmesh.json`, or remembered from before; it is where an app's
connector token is read (this machine only) and where an app is forgotten.

An operation another app declares can name its own permission
(`app_api.operation(..., permission="contacts.read")`): a call **from an app**
is then answered from that name (`app_perms.app_call_need`) instead of the
blanket `readstate.apps` / `control.apps`. A name that is not on the tree is
ignored, so naming one cannot be a way to need nothing. Chat uses it to reach the
address books, and when a person has not answered yet it shows the question the
way a phone does: *Allow*, *Read only*, *Not now* (the last is remembered in that
browser only).

*Apps → Internal API*: every endpoint, what it does, the permission an app needs,
and the MCP tool it becomes.

Expand Down
57 changes: 50 additions & 7 deletions Docs/Apps/chat
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ NMesh's reference messaging app, built on the data connector. A real end-to-end
messenger: **profiles** (name, bio, avatar), 1:1 **conversations** and
**groups**, **replies**, **editing**, **deletion**, **emoji reactions**,
**delivered/read receipts**, a **typing indicator**, **file and media sharing**
(chunked + verified, images previewed), **finding people by name** (the node's
(chunked + verified, images previewed), **voice messages** (Opus), **finding
people by name** (the node's
signed pseudos, whole or partial), plus the **real-time stream** of timestamped
frames —
the primitive a voice/video call would use. Everything stays **E2E encrypted**;
Expand Down Expand Up @@ -144,10 +145,29 @@ sender; a peer's `EDIT`/`DELETE` only ever alters its own messages. A profile
(bio/avatar) is **learned** from the peer; the **name is not in it** — that
comes from the node's signed claim, and the node_id stays the real identity.

## Voice messages

The microphone button (shown when the text box is empty and the browser can
record Opus) records with `MediaRecorder` as **Opus** in WebM or Ogg
(`audio/webm;codecs=opus`, `audio/ogg;codecs=opus`, 32 kbit/s, five minutes at
most) and sends it as an ordinary file transfer — chunked, verified, rate
limited like any other. There is no new wire type: a voice message is a file
whose name says so, `voice-<milliseconds>.webm|ogg`. The name is the sender's,
so the receiving node decides (`chat_web.voice_length`): the pattern must match
exactly, the length is 1 ms to 10 minutes and the file 1 byte to 8 MiB, the size
must be a plain integer — anything else is shown as the plain file it is, and a
non-Opus file with a borrowed name is the browser's to refuse. The page fetches
the bytes through its authenticated `GET /api/chat/file` (so it follows the node
being driven), plays them from a blob URL (at most 24 held) and shows a play
button, a seek bar and the length. A browser that cannot record Opus simply has
no microphone button, and still plays what it receives.

## The screen

Three panes on one grid: the **conversation list**, the **thread**, and the node
card of whoever you are talking to. Which of them is on screen is a single
card of whoever you are talking to. The list has two tabs: **Chats** and
**Contacts** (the mirror above, one tap to start a conversation, a link to the
Contacts page). Which of them is on screen is a single
attribute on the shell — `data-view="list" | "conv" | "peer"` — so at any width
there is exactly one answer to "what am I looking at", and the panes cannot
contradict each other.
Expand All @@ -158,6 +178,12 @@ conversation. Below 860 the screen shows one pane at a time and the list is the
navigation — no drawer and no hamburger, the same stance the console takes with
its tab bar.

A touch screen (`pointer:coarse`) gets larger targets, decided by the pointer
and not by a user-agent string; the composer swaps its microphone for the send
button as soon as there is text, and shows only the recorder (cancel, time,
send) while recording, so a tap cannot send half a sentence typed beside it.
Every sentence is read from the catalogue — `Docs/I18n/guide`.

Everything is prefixed `ch-`. The design system (`src/webassets/ui.py`) owns the
generic names, and this page used to redefine `.msg` — quietly changing a shared
component for itself alone. The prefix is what stops that.
Expand Down Expand Up @@ -223,9 +249,23 @@ real data.
writes it through the console (`POST /api/pseudo`), the node signs a claim for
it, and chat mirrors it back with the rest. Chat's own profile is the **bio
and avatar**, which it does announce to contacts (`PROFILE`).
- **Contacts**: `add_contact` / `remove_contact` (a node id — no name is passed,
because what a node is called is its own to say). Adding a contact announces
my profile to them.
- **Contacts**: chat has **no address book of its own**. The people it shows are
the [contacts app's](contacts), read and written through the permission a
person gave chat (`contacts.read` / `contacts.write`, `Docs/AppPermissions/guide`).
`ChatBridge.contacts_access()` is one of `write`, `read`, `denied` or
`unavailable`, and the page is built for each: *denied* shows a permission card
(*Allow*, *Read only*, *Not now*) and the contacts chat already had stay on
screen as they were — a node that has not answered loses nothing; the first
time chat may **write**, those contacts move into the default book (once,
`migrated`); afterwards the list chat renders from is a **mirror** of the books
(`ChatState.replace_contacts`), carrying the name its owner gave each node
(`label`), refreshed on every poll. `add_contact(id)` saves the node in the
books (named from the directory) and announces my profile to them; refused it
raises `PermissionError(permission)`, which the console returns as `403
{"error":"permission","permission":…}` so the page asks the question instead of
failing. Starting a chat does **not** save the person — a conversation and an
address book are two decisions — and any conversation shows **Add to
contacts** while the node is in no book.
- **Groups**: `create_group(name, members)` mints a `group_id`, adds me to the
roster and sends a `GROUP_INVITE` to each member. `send_group_text` **fans**
the message out to every member (members never relay → no loop, no
Expand All @@ -237,15 +277,18 @@ real data.
unsigned pseudo in a peer-to-peer profile is a peer telling us what to call
it, which is exactly how impersonation works. That field is gone, and so are
the old `DIR_QUERY` / `DIR_REPLY` messages it fed.
- **Search by name** — `search_pseudo(query, wide=…)`, which is the node's own
- **Search by name** — `search_pseudo(query, wide=…)`: saved contacts first
(found by the name their owner gave them, `match = -1`), then the node's own
ranked search (exact, then prefix, then a word inside the name, then anywhere;
case- and accent-insensitive), merged with `find_by_pseudo` over the contacts
already on this screen. `wide=True` also asks the network for the exact name,
which finds somebody this node has never met. See
[`Docs/Pseudos/guide`](../Pseudos/guide).

All of this is drivable from the console's `/chat` sub-page (identity + copying
the id, contacts, groups, search). See `Docs/WebConsole/guide`.
the id, contacts, groups, search). See `Docs/WebConsole/guide`. `/chat#c/<node id>`
opens (or starts) the conversation with that node; the Contacts page and the
node card link to it.

## Audio calls (`src/apps/call.py`)

Expand Down
174 changes: 174 additions & 0 deletions Docs/Apps/contacts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# The contacts application

`src/apps/contacts.py`, `contacts_state.py`, `contacts_web.py`,
`contacts_link.py` · page `src/webassets/contacts.py` (console `/contacts`)

The address books of a node. A **contact** is a name a person chose and the
nodes it answers to; a **book** is a named list of contacts. A node keeps as
many books as it likes (family, work, the ones other people shared), and **every
other app asks for them** — chat keeps no list of its own beyond a copy it draws
the screen from.

It is a built-in app like chat: it runs on the data connector under its own app
id, keeps its books in its own encrypted drawer (never in the clear on disk), and
is on by default. Turn it off from *Apps* and `/contacts` and `/api/contacts/*`
stop existing.

## Names are somebody's own word

What a node is called by *itself* is a signed claim the node holds
([`../Pseudos/guide`](../Pseudos/guide)). What a **contact** is called is what the
person who owns the book wrote. The two are kept apart: the page shows the node's
own name beside the one the person chose, never in place of it, and a nickname
cannot make a stranger look like somebody else (a name always travels with the
node id).

## The permission

Android's model: a person decides, per app, whether it may touch the contacts.

| Permission | Level | Allows |
|---|---|---|
| `contacts` | sensitive | everything below |
| `contacts.read` | sensitive | list the books and their contacts, search by name or node id, ask who a node is |
| `contacts.write` | sensitive | add a node as a contact, take one out |

Nothing is granted by asking. An app that asks for them says why on the
permissions page (chat: "it shows the people you know by the names you gave
them" / "it adds a person to your address book when you press *Add to
contacts*"). Reading does not let an app write, taking back `contacts` takes
back its children, and a client holding only the connector's shared token never
gets either (`Docs/AppPermissions/guide`).

The operations are declared with the permission they need
(`app_api.operation(..., permission="contacts.read")`), and the control plane
answers an **app** caller from that — so the check is made where the call is
made, not left to the app being asked:

| Operation | Needs | Changes | Answers |
|---|---|---|---|
| `books` | `contacts.read` | no | id, name, colour, count, default/writable/shared flags |
| `list` | `contacts.read` | no | the contacts of one book (or all), without their notes |
| `names` | `contacts.read` | no | every node in the books and the name its owner gave it |
| `search` | `contacts.read` | no | contacts matching a name, a label, or the start of a node id |
| `find` | `contacts.read` | no | the contacts that answer to a node |
| `add` | `contacts.write` | yes | save a node (named from the directory when no name is given) |
| `remove` | `contacts.write` | yes | take a node out of one book or all of them |

None of them is `remote`: a console managing this node reaches the books only
with its `apps` grant, and `/api/contacts/` is on the same list as
`/api/chat/` (`_NEEDS_APPS`). The note a person wrote beside a contact never
leaves through the API.

### How an app uses it

`src/apps/contacts_link.py`:

```python
link = ContactsLink(host, perms, my_app_id_hex)
link.access() # "write" | "read" | "denied" | "unavailable"
link.call("find", node=hex) # Unavailable | Denied(permission) | AppAPIError
```

The host and the permission book are looked up on every call, so a contacts app
stopped a second ago is not reachable through a reference the link kept. A refused
app gets a **refusal** (`Denied` naming the permission), never a thinner answer —
so it can ask the person instead of showing an empty list that looks true.

## Finding somebody on a network of thousands

Search is two questions asked in order: the books first (instant, nothing leaves
the node), then the directory (`wide`), which is the node's own ranked name
search and asks the network for an exact name. Saved contacts come first in
chat's search (`match = -1`), found by the name their owner gave them as well as
the one the node signs. Ranking is `pseudo.fold` + `rank_folded` — the same
rules as the directory, so a name found here and one found there sort alike.

## The data

`ContactsState` is the data and its rules, no sockets. Every collection is hard
bounded:

| Limit | Value |
|---|---|
| books | 32 |
| contacts per book (tombstones included) | 1000 |
| nodes per contact | 8 |
| name / label / note | 64 / 32 / 512 characters |
| members of a shared book | 32 |
| invitations waiting for a human | 16 in all, 2 per sender, 14 days |

A book is one value of the node's drawer (256 KiB at most); a book that no
longer fits is refused with `full` rather than truncated. Deleted contacts leave
a tombstone for 90 days so the deletion reaches nodes that were offline; they
are the first thing to go when room is needed.

## Sharing a book with another node

A book is shared by **invitation**, and an invitation is not trusted: a node that
offers a book costs the other a line in a queue until a human accepts. A contact
called "Bank" planted in somebody's address book is a social-engineering attack,
so the only way into a book is a person saying yes. Roles are `read` and `write`.

**The owner numbers.** The node that made the book gives changes a sequence
number; a member that may write sends its edits to the owner, which orders them
and passes them on. Each entry carries a version `[counter, writer]`, ordered by
counter and then by writer id (last write wins), so every replica settles on the
same entry without a clock anyone has to believe. If the owner is gone for good
the copies stay readable and, where writable, editable — they stop following each
other, nothing more. The owner can also let a member go (`revoke` detaches its
copy, which becomes the member's own book).

Wire format inside each E2E `DATA` payload: one type byte, then a JSON object.

| Type | Sent | Meaning |
|---|---|---|
| `OFFER` (1) | owner → node | "here is a book, if you want it" |
| `ACCEPT` / `DECLINE` (2, 3) | node → owner | the human's answer |
| `LEAVE` (4) | member → owner | "I no longer follow this book" |
| `PULL` (5) | member → owner | "what changed since this sequence?" |
| `PUSH` (6) | either way | contacts, newest wins (20 per page) |
| `ACK` (7) | owner → member | "I have these of yours" |
| `REVOKE` (8) | owner → member | "this book is no longer shared with you" |

Everything is validated before it is looked at: a message over 40 000 bytes, one
that is not what its type says, a push to a book that was not accepted, an edit
from a reader, an entry whose version jumps past the bound — all dropped, with
the sender reported (`AppGuard`: invitations 4/60 s, answers 20/10 s, pulls and
pushes 30 and 60/10 s). A pull for a book this node does not own is answered
with nothing (not even "no such book"), and `REVOKE` is only ever sent to a node
that really is not a member. A round runs every 300 s (pulls, the pushes that
failed, invitations not yet answered, at most 12 times).

## The page (`/contacts`)

Three panes on one grid — the **books**, the **contacts** of the open book, one
**contact** — and a single attribute, `data-view="books|list|detail"`, says which
is on screen. Wide enough (over 1000 px) shows all three; narrower shows one at a
time with a back button. The page never asks which device it is on.

- books: colour, "private / shared by you / shared with you", pending
**invitations** with Accept / Decline;
- contacts: search within the book, favourites first, add by **name from the
network** or by pasting a node id;
- editor: name, up to eight nodes (each with a label), note, favourite, move to
another book, **Message** (opens `/chat#c/<node id>`);
- share dialog: invite by name or id, role, who follows, take back;
- import / export as **vCard** (`X-NMESH-NODE` carries the node ids; any other
address book reads the rest). An import is capped at 600 000 characters of text and 1000 entries (the request carrying it at 2 MiB).

Every sentence is read from the catalogue (`t()`), and a refusal from the node
travels as a stable code (`why`: `no_book`, `read_only`, `no_contact`, `empty`,
`full`, `bad_node`, `not_owner`, `self`, `gone`) that the page turns into its own
words.

## Console routes

| Method | Path | |
|---|---|---|
| GET | `/api/contacts/state` | `?book=…` → books, invitations, the book's contacts, names (sectioned like chat's feed) |
| GET | `/api/contacts/people` | `?q=…&wide=0|1` → saved contacts first, then the directory |
| GET | `/api/contacts/export` | `?book=…` → a `.vcf` download |
| POST | `/api/contacts/<op>` | `save` `delete` `move` `book` `share` `unshare` `answer` `people` `import` |

All behind the console session; the page and its assets are public, like chat's.
Loading
Loading