Spaces:
Running on Zero
Running on Zero
| # 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. | |