Spaces:
Running on Zero
Running on Zero
File size: 11,421 Bytes
6f9a5fd | 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 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 | # M13 β Onboarding
**Spec version:** v1.0
**Depends on:** M01 (identity), M08 (UI), X04 (config), X02 (events, to emit `community.*` events), `qrcode`, `Pillow`
**Depended on by:** First-run flow in `node.py`; entry point from M08 settings tab
---
## 1. Responsibility
The first time a user runs HearthNet, get them from "downloaded a binary" to "joined a community with a working node" in under two minutes. Specifically:
- Generate a device keypair if not present
- Offer two paths: **create** new community or **join** existing one
- Create flow: collect community name + policy, sign genesis manifest, display invite QR
- Join flow: scan / paste an invite, redeem it, emit `community.member.joined` event
- Optional: name the device, choose a profile (defaulted from hardware probe)
Out of scope:
- Federation between communities (Phase 2)
- Multiple-community membership on one device (Phase 2)
---
## 2. File layout
```
hearthnet/ui/
βββ onboarding.py # build_onboarding(), redeem_invite(), invite_to_qr()
hearthnet/identity/
βββ manifest.py # build_community_manifest() β reused
```
A standalone `hearthnet init` CLI command in [M12](M12-cli.md) shares the same primitives.
---
## 3. Public API
### 3.1 `onboarding.py`
```python
# hearthnet/ui/onboarding.py
from dataclasses import dataclass
import qrcode
from io import BytesIO
@dataclass(frozen=True)
class InviteBlob:
"""The thing that travels between devices to enable joining."""
schema_version: int # 1
community_id: str # full
community_name: str # display
inviter_node_id: str # full
invitee_node_id: str # full of the device being invited
initial_level: str # "member" | "trusted"
bootstrap_endpoints: list[Endpoint] # how to reach the inviter
expires_at: str
signature: str # inviter's signature over canonical-JSON
# --- encoding ---
def encode_invite(blob: InviteBlob) -> str:
"""Compact representation suitable for QR / paste. Format:
'hearthnet://v1/<base64-url-nopad of canonical-JSON>'.
Aim: < 500 bytes (fits standard QR at error level M)."""
def decode_invite(text: str) -> InviteBlob:
"""Parse + verify signature. Raises OnboardingError on invalid."""
# --- QR ---
def invite_to_qr_png(blob: InviteBlob, *, box_size: int = 8) -> bytes:
"""Render the invite as a QR PNG. Used by UI for display."""
# --- create flow ---
def create_community(
name: str,
policy: CommunityPolicy,
kp: KeyPair,
state_dir: Path,
event_log: EventLog,
) -> CommunityManifest:
"""1. Build genesis community manifest (root = kp).
2. Persist manifest to <state_dir>/manifest.json.
3. Append community.created event to event_log.
4. Append community.member.invited + .joined for root device.
Returns the manifest."""
# --- join flow ---
def make_invite(
invitee_node_id_full: str,
inviter_kp: KeyPair,
community_manifest: CommunityManifest,
bootstrap_endpoints: list[Endpoint],
initial_level: str = "member",
ttl_seconds: int = 86400,
) -> InviteBlob:
"""Create + sign an invite blob. Also emit a community.member.invited event."""
def redeem_invite(
blob: InviteBlob,
our_kp: KeyPair,
transport_client: HttpClient,
event_log: EventLog,
) -> CommunityManifest:
"""1. Verify the invite signature, expiry, that invitee_node_id matches us.
2. Connect to one of bootstrap_endpoints; fetch /community/manifest.
3. Verify the community manifest's signature chain (root key in invite matches).
4. Run an initial X02 sync to populate the event log.
5. Emit a community.member.joined event (our authorship) including our node manifest.
6. Persist community manifest locally.
Returns the manifest. Raises OnboardingError on failure."""
# --- UI builders (Gradio components) ---
def build_onboarding(config: Config, kp_provider: Callable[[], KeyPair]) -> 'gr.Blocks':
"""Standalone Blocks UI for the first-run flow. Two-step wizard:
Step 1: 'Erstmal SchlΓΌssel erzeugen' (auto, with progress)
Step 2: choice β create or join
- create: form (name, policy options) β preview manifest β confirm β show QR
- join: text area / camera upload for QR β preview invite β confirm β redeem
Returns the assembled Blocks. node.py mounts this BEFORE the main UI when
config.community.community_id is None."""
class OnboardingError(Exception):
"""code in {'invite_invalid','invite_expired','invitee_mismatch','bootstrap_unreachable',
'community_manifest_invalid','sync_failed','already_member'}"""
code: str
```
---
## 4. Flows in detail
### 4.1 Create-community flow
```
User clicks "Neue Community grΓΌnden"
β
Form:
β’ Community name (free text, 1..64 chars)
β’ Allow new members to invite? (default true)
β’ Minimum signatures to revoke a member: 3 (advanced)
β
On submit:
policy = CommunityPolicy(
min_signatures_to_invite = 1,
min_signatures_to_demote = 3,
min_signatures_to_revoke = 3,
capability_token_ttl_seconds = 86400,
federation_enabled = True,
default_member_can_invite = checkbox_value,
)
manifest = create_community(name, policy, our_kp, state_dir, event_log)
config.community.community_id = manifest.community_id
X04.save(config)
β
"Du bist GrΓΌnder!" panel
β’ Show community short id
β’ Show your role: anchor
β’ Show QR code for inviting first member (preconfigured invite to ANY device)
β actually: show a "create invite" button that asks for the invitee's NodeID
β
Continue β main UI
```
### 4.2 Join-community flow
```
User clicks "Einer Community beitreten"
β
"Wie hast du die Einladung erhalten?"
β’ Paste link / text
β’ Upload QR image
β’ Use camera (mobile only)
β
decode_invite(text or scan) β InviteBlob
β’ verify signature
β’ check expiry
β’ check invitee_node_id == our_node_id_full
β
Preview:
β’ Community name
β’ Inviter display name (lookup via bootstrap_endpoints[0]/manifest)
β’ Initial level: member
β’ "Beitreten" button
β
On confirm:
redeem_invite(blob, our_kp, transport_client, event_log)
β fetch community manifest from bootstrap
β run X02 sync to fetch all history
β emit community.member.joined event
β persist community manifest
config.community.community_id = blob.community_id
X04.save(config)
β
"Willkommen!" β main UI
```
### 4.3 Inviting someone (post-onboarding, from settings tab)
```
Settings β "Mitglied einladen"
β
Two options:
(a) Generate invite for a specific NodeID
- user pastes invitee NodeID full form (they got it from their device)
- or scans a "I'm a fresh device" QR shown on their screen
(b) Use the in-person setup wizard (Phase 2: BLE pairing)
β
make_invite(...) β InviteBlob
β’ Emit community.member.invited event (so the rest of the community knows)
β’ Display QR for the invitee to scan
β’ Expires in 24h
```
### 4.4 "I'm a fresh device" QR
Before joining, a freshly-installed device displays a QR encoding only its `node_id_full` (no signature; this is a public key). The inviter scans this QR with their existing device to build an invite. Format:
```
hearthnet-id://v1/<base64-url-nopad of {"node_id_full": "...", "display_name": "Hannes' Tablet"}>
```
This is unsigned because nothing private is in it. The inviter is the source of trust.
---
## 5. Behaviour
### 5.1 First-run detection
`node.py` checks at startup:
```python
if config.community.community_id is None:
# mount onboarding UI; don't start most services yet
...
else:
# normal startup
```
After the user completes onboarding, `node.py` continues with the full startup sequence (now with a valid community).
### 5.2 Re-onboarding (changing community)
The settings tab has a "Leave community" action. After confirm, the local data for that community is moved to `<DATA>/communities/<id>.archived/` and the user is sent back to onboarding. The user keeps the same keypair unless they explicitly choose to regenerate it (which then makes a new identity).
### 5.3 What we sign and what we don't
| Artifact | Signed by | Why |
|----------|-----------|-----|
| Invite blob | Inviter | So invitee can prove this came from a member |
| Genesis community manifest | Founder (root) | Establishes the root of trust |
| `community.member.joined` event | The joining device | Asserts "I am here, with this manifest" |
| Fresh-device ID QR | Nobody | It's just a public key; sees + uses |
### 5.4 Failure modes during join
- Bootstrap endpoints unreachable β `bootstrap_unreachable` with retry option
- Invite expired β `invite_expired`, must request a new one
- Invitee mismatch β `invitee_mismatch` (someone tried to redeem someone else's invite)
- Already a member β `already_member`, no-op with a message
### 5.5 Privacy of invites
An invite contains:
- Community ID, name
- Inviter NodeID
- Invitee NodeID
- Bootstrap endpoints
It does **not** contain the event log. Anyone seeing an invite knows the community exists and who is who, but not what was said in it.
---
## 6. Errors
`OnboardingError` codes:
- `invite_invalid` β malformed or bad signature
- `invite_expired` β past TTL
- `invitee_mismatch` β invite addressed to a different NodeID
- `bootstrap_unreachable` β can't reach inviter for manifest fetch
- `community_manifest_invalid` β fetched manifest fails verification
- `sync_failed` β initial event-log sync failed
- `already_member` β we're already in this community
---
## 7. Configuration
No new config keys. Uses `config.identity.*` and `config.community.*` from [X04](../cross-cutting/X04-config.md).
---
## 8. Tests
### Unit
- `test_invite_encode_decode_roundtrip`
- `test_invite_qr_under_500_bytes`
- `test_invite_expired_rejected`
- `test_invite_addressed_to_someone_else_rejected`
- `test_create_community_emits_three_events` (created + invited self + joined self)
- `test_redeem_invite_results_in_joined_event`
### Integration
- `test_two_node_join_flow_end_to_end` β node A creates community, generates invite, node B redeems, both see each other as members
- `test_join_during_partial_partition` β bootstrap unreachable, retry succeeds
- `test_redeem_then_immediately_sync_marketplace` β historical posts visible after sync
---
## 9. Cross-references
| What | Where |
|------|-------|
| Community manifest schema | [CONTRACT Β§6.2](../CAPABILITY_CONTRACT.md) |
| `community.*` events | [CONTRACT Β§7.2](../CAPABILITY_CONTRACT.md) |
| Identity primitives | [M01](M01-identity.md) |
| UI integration | [M08 Β§8.5](M08-ui.md) |
| CLI `hearthnet init` | [M12 Β§3](M12-cli.md) |
| Event log + sync | [X02](../cross-cutting/X02-events.md) |
---
## 10. Open questions
1. **Multi-community membership** β out of scope MVP. Phase 2: same keypair, multiple `<DATA>/communities/<id>/` dirs.
2. **Recovery if device key lost** β currently impossible (key = identity). Phase 2: "social recovery" via 2-of-3 trusted members re-issuing a re-joined identity.
3. **BLE pairing** β Phase 2; faster than QR for adjacent devices.
4. **Camera capture on mobile web** β `getUserMedia` is available; needs HTTPS. Self-signed cert may trip browsers; document workaround.
|