Repository navigation
Expand file tree
/
Copy pathguide
More file actions
218 lines (170 loc) · 10.5 KB
/
Copy pathguide
File metadata and controls
218 lines (170 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# The app API — what an app lets the rest of the product ask of it
`src/app_api.py`
## Why it exists
The console used to reach chat and fleet through routes written by hand: one
`elif` per action, in a file with no business knowing what a capability or a
conversation is. That holds while there are two apps and one caller.
It stops holding the moment a *page* wants something from an app it does not
own. The node details view wants to know whether chat has a conversation with
this node, and whether fleet manages it. Neither belongs in the console's
routing table, and neither should mean editing `webconsole.py` every time an app
learns a new trick.
So an app declares what it exposes, once, next to itself — and everything else
reaches it the same way.
## Declaring
A bridge carries an `API` tuple and one `api_<name>` method per entry:
```python
from .. import app_api
class ChatBridge:
API = (
app_api.operation(
"peer", "What chat knows about a node",
[app_api.param("node", "node")]),
app_api.operation(
"contact", "Add a node to the address book",
[app_api.param("node", "node")],
changes=True),
)
def api_peer(self, node):
...
```
`changes=True` marks an operation that alters state. It is not a permission —
the app still decides — but it lets a caller present a confirmation, and it
keeps read-only calls distinguishable at a glance.
`operator=True` says only a **person** may call it — never another app,
whatever that app holds. For an answer that is a credential: the MCP app's bearer
token is readable by whoever runs the node, and an app that could read it would
hold every permission the MCP app holds.
`remote=True` says an operator at **another node's console** may ask for this
one. It defaults to no, and that default is the interesting half: an app added
tomorrow is unreachable from a distance until its author decides, operation by
operation, what may travel. The flag is declared here and enforced by the
control plane — the layer that knows where a call came from — so an app never
has to ask who is calling
([`../Architecture/control-plane.md`](../Architecture/control-plane.md)).
### Parameter kinds
A short, closed list. A general type system was the other option and it would
have been the wrong one: what actually crosses this boundary is identities,
short strings, flags, counts and capability tokens.
| kind | accepts | bounded by |
|---|---|---|
| `node` | a NodeID as 40 hex characters (case and surrounding whitespace forgiven) | the shape itself |
| `text` | one short line | `MAX_TEXT` (200), no newlines |
| `flag` | a boolean, or the spellings a form sends (`yes`/`on`/`1`…) | — |
| `count` | a whole number | `0 … MAX_COUNT` |
| `tokens` | a list of short lowercase tokens, de-duplicated | `MAX_TOKENS` × `MAX_TOKEN` |
Giving `node` its own kind means the shape of an identity is checked **once**,
here, instead of in every operation that takes one.
## Calling
```python
api = AppAPI(app_host)
api.catalogue() # what exists right now
api.call("fleet", "relation", {"node": node_hex}) # invoke
```
From a browser, the console exposes exactly those two:
| method | path | what it does |
|---|---|---|
| GET | `/api/app-api` | the catalogue — apps running, operations, parameters |
| POST | `/api/app-call` | `{app, op, args}` → `{ok, result}` or `{ok:false, error}` |
Both require a console session, like everything else the console serves.
A page reads the catalogue to decide what to **offer**: a button that calls an
app which is not installed should not be drawn at all, rather than drawn and
then failing when pressed.
## The rules
**Reject by default, everywhere.** An operation that is not declared does not
exist, whatever the bridge happens to have as an attribute — dispatch never
looks up a name the caller supplied, only a name the app itself wrote down. An
argument that is not declared is refused rather than passed through. Every
declared argument is coerced and bounded before the app sees it.
**No new authority.** This is a way to *reach* what an app already does, not a
way around the checks it already makes. `fleet.enrol` reached from a details
page is the same `enrol` the fleet page calls: it sends a request, and a human
on the *other* machine answers. The ledger is the authority; the caller is not.
What this adds is one door instead of five, with the lock in one place.
**Nothing is held.** The surface is built per access, over whatever is running
at that instant. An app stopped a second ago is not reachable through a
reference someone kept, and a call to it is refused — the honest answer, not an
error to work around.
**An app that throws keeps its internals.** The exception type is reported, the
message is not.
**Reach is declared, not inherited.** Everything here is reachable from the
console serving the page. From a console *managing* this node, only what the
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 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 |
| `fleet` | `invite` | yes | no | have a node we manage mint an invitation to its mesh |
| `mcp` | `status` | no | no | where the MCP server listens, and how it is doing |
| `mcp` | `token` | no | no | the bearer token — **`operator`** |
| `mcp` | `rotate` | yes | no | a new bearer token — **`operator`** |
| `mcp` | `configure` | yes | no | listen on another address or port — **`operator`** |
`tests/test_app_api.py` and `tests/test_control_plane.py` pin these: renaming
one breaks a page, and *widening* one — `changes` or `remote` — is a security
change.
Only one is reachable from a console managing this node, and the reasons for
the rest are the same two the relay has always had: chat's operations are
somebody's conversations, and fleet's three actions each *act* through this
node's identity towards another — a node one operator manages must not become a
way to reach the nodes it manages. `relation` is a read of this node's own
ledger, which is exactly what somebody managing this machine needs to see.
## What it built
The node view (`webassets/nodeview.py`) is the first thing on top of this. It is
one description of a node, mounted in four places, and what it *offers* comes
from the catalogue rather than from a list in its own source:
| from | mounted in | what is dropped |
|---|---|---|
| the console | its node dialog | nothing |
| chat | a panel beside the conversation | Message — you are already there |
| fleet | its sheet | the fleet buttons — likewise |
| a window or a tab | `/node#<id>?from=…` | whatever `from` names |
Mounting rather than framing is deliberate. An `<iframe>` would have needed
`frame-ancestors 'self'`, and it would have bought nothing: this is the same
code in the same document either way. So every page this console serves keeps
`frame-ancestors 'none'`.
Where a link *leaves* the app — Message opens chat, Open in Fleet opens fleet —
the operator chooses a window or a tab (Settings → This browser), and chat has
its own choice for where node details appear. Both live in `localStorage`, like
the theme: they are preferences about one screen, not properties of the node.
## The language an app speaks
The operator's language is handed to the apps, not guessed by them:
`AppHost.language` answers it (`""` = follow the browser), and an app or bridge
defining `set_language(code)` is told at start and whenever it changes. Pages
get the strings from `/i18n.js`. See [`../I18n/guide`](../I18n/guide).
## Apps outside the node declare operations too
An app attached to the data connector declares its operations in its manifest's
`api` ([`../AppPermissions/guide`](../AppPermissions/guide)), with exactly the
fields above, brought through the same checks (`app_api.declare_all`). It answers
them over the connector (`ConnectorClient.serve`, a `CALL` the node sends and a
`RETURN` it answers), and they appear in the catalogue under the manifest's name —
reached through `apps.call` like a built-in's, after the same coercion. Only an app
connected with its own token may expose any, and never under a built-in's name.
What an *app* may call through `apps.call` is a permission of its own:
`readstate.apps` for an operation that only reads, `control.apps` for one that
changes something, and nothing at all for one declared `operator`.
## Adding an operation
1. Declare it in the bridge's `API`, with the narrowest parameter kinds that fit.
2. Decide whether it may be driven from a console managing this node
(`remote=True`), and say why in a comment if the answer is yes.
3. Write `api_<name>` on the same bridge. Return a mapping.
4. Add the row to the table above.
5. Test the refusals, not only the success: an undeclared argument, a value of
the wrong shape, the app not running — and, if it travels, that it is
refused from a remote origin when it should not be.
If an operation would need to carry arbitrary bytes, that is a decision to take
on purpose — and to write down here — not something to slip in behind a `text`.