File size: 8,623 Bytes
31c93b1
4aaae80
31c93b1
 
 
f6ead22
31c93b1
 
481b78e
 
a190f73
3f78ea8
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f53c4c
 
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
8f53c4c
 
 
 
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f53c4c
 
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f53c4c
 
 
31c93b1
8f53c4c
 
 
 
 
31c93b1
 
 
 
 
 
 
 
 
 
 
 
8f53c4c
 
 
 
31c93b1
 
 
 
 
 
 
 
 
8f53c4c
 
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6ca3a2
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f78ea8
 
 
f6ead22
 
3f78ea8
f6ead22
31c93b1
 
d6ca3a2
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4aaae80
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4aaae80
31c93b1
4aaae80
31c93b1
 
 
4aaae80
31c93b1
4aaae80
38cba90
 
 
 
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
"""M13 — Onboarding: invite encode/decode, QR generation, create/join community flows."""

from __future__ import annotations

import base64
import contextlib
import json
from dataclasses import dataclass
from datetime import timezone as _tz
UTC = _tz.utc

UTC = UTC

from hearthnet.constants import INVITE_DEFAULT_TTL_SECONDS


@dataclass(frozen=True)
class InviteBlob:
    """Invite that travels between devices to enable joining."""

    community_id: str
    community_name: str
    inviter_node_id: str
    invitee_node_id: str  # the new member's full node ID
    issued_at: str  # RFC 3339 UTC
    expires_at: str  # RFC 3339 UTC
    signature: str  # inviter's signature
    relay_url: str = ""  # optional: relay hub to join for all-to-all mesh over NAT
    relay_token: str = ""  # optional: one-time/scoped token authorising the relay join


def encode_invite(blob: InviteBlob) -> str:
    """Compact base64url encoding. Aim: < 500 bytes."""
    d = {
        "cid": blob.community_id,
        "cn": blob.community_name,
        "inv": blob.inviter_node_id,
        "tee": blob.invitee_node_id,
        "iat": blob.issued_at,
        "exp": blob.expires_at,
        "sig": blob.signature,
    }
    if blob.relay_url:
        d["ru"] = blob.relay_url
    if blob.relay_token:
        d["rt"] = blob.relay_token
    raw = json.dumps(d, separators=(",", ":"))
    return "hn1:" + base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")


def decode_invite(text: str) -> InviteBlob:
    """Parse + verify signature. Raises OnboardingError on invalid."""
    if not text.startswith("hn1:"):
        raise OnboardingError("invite_invalid", reason="missing 'hn1:' prefix")
    try:
        payload = text[4:]
        padded = payload + "=" * (4 - len(payload) % 4 if len(payload) % 4 != 0 else 0)
        raw = base64.urlsafe_b64decode(padded).decode()
        d = json.loads(raw)
    except Exception as exc:
        raise OnboardingError("invite_invalid", reason=str(exc)) from exc

    now_str = _iso_now()
    if d.get("exp", "") < now_str:
        raise OnboardingError("invite_expired", reason=f"expired at {d.get('exp')}")

    return InviteBlob(
        community_id=d["cid"],
        community_name=d.get("cn", ""),
        inviter_node_id=d["inv"],
        invitee_node_id=d["tee"],
        issued_at=d["iat"],
        expires_at=d["exp"],
        signature=d.get("sig", ""),
        relay_url=d.get("ru", ""),
        relay_token=d.get("rt", ""),
    )


def invite_to_qr_png(blob: InviteBlob, *, box_size: int = 8) -> bytes:
    """Render invite as QR PNG. Returns empty bytes if qrcode not installed."""
    try:
        import io

        import qrcode

        qr = qrcode.QRCode(
            error_correction=qrcode.constants.ERROR_CORRECT_M,
            box_size=box_size,
            border=4,
        )
        qr.add_data(encode_invite(blob))
        qr.make(fit=True)
        img = qr.make_image(fill_color="black", back_color="white")
        buf = io.BytesIO()
        img.save(buf, format="PNG")
        return buf.getvalue()
    except ImportError:
        return b""


def make_invite(
    invitee_node_id: str,
    community_id: str,
    community_name: str,
    kp,  # KeyPair
    ttl_seconds: int = INVITE_DEFAULT_TTL_SECONDS,
    *,
    relay_url: str = "",
    relay_token: str = "",
) -> InviteBlob:
    """Create and sign an invite blob.

    Pass ``relay_url`` (and optionally ``relay_token``) to embed a relay hub so the
    redeemer can join the all-to-all mesh over NAT without any manual config.
    """
    from hearthnet.identity.keys import sign_payload

    iat = _iso_now()
    exp = _iso_after(ttl_seconds)
    payload = {
        "community_id": community_id,
        "community_name": community_name,
        "inviter_node_id": kp.node_id_full,
        "invitee_node_id": invitee_node_id,
        "issued_at": iat,
        "expires_at": exp,
    }
    if relay_url:
        payload["relay_url"] = relay_url
    if relay_token:
        payload["relay_token"] = relay_token
    signed = sign_payload(payload, kp)
    return InviteBlob(
        community_id=community_id,
        community_name=community_name,
        inviter_node_id=kp.node_id_full,
        invitee_node_id=invitee_node_id,
        issued_at=iat,
        expires_at=exp,
        signature=signed.get("signature", ""),
        relay_url=relay_url,
        relay_token=relay_token,
    )


def create_community(
    name: str,
    kp,  # KeyPair
    policy: dict | None = None,
    event_log=None,
) -> dict:
    """Create a new community. Returns community manifest dict."""
    from hearthnet.identity.manifest import build_community_manifest

    manifest = build_community_manifest(
        kp=kp,
        name=name,
        members=[kp.node_id_full],
        policy=policy or {"join_requires_invite": True, "max_members": 100},
    )
    if event_log is not None:
        with contextlib.suppress(Exception):
            event_log.append_local(
                event_type="community.created",
                author=kp.node_id_full,
                payload=manifest.as_dict(),
                kp=kp,
            )
    return manifest.as_dict()


def redeem_invite(
    blob: InviteBlob,
    kp,  # our KeyPair
    event_log=None,
) -> dict:
    """Verify invite, emit member.joined event, return community manifest stub."""
    if (
        blob.invitee_node_id not in (kp.node_id_full, kp.node_id_short) and blob.invitee_node_id
    ):  # "" means open invite
        raise OnboardingError(
            "invitee_mismatch",
            reason=(f"invite was for {blob.invitee_node_id[:20]}, we are {kp.node_id_full[:20]}"),
        )

    if event_log is not None:
        with contextlib.suppress(Exception):
            event_log.append_local(
                event_type="community.member.joined",
                author=kp.node_id_full,
                payload={
                    "community_id": blob.community_id,
                    "member_node_id": kp.node_id_full,
                    "invited_by": blob.inviter_node_id,
                },
                kp=kp,
            )

    return {
        "version": 1,
        "community_id": blob.community_id,
        "name": blob.community_name,
        "root_node_id": blob.inviter_node_id,
        "members": [blob.inviter_node_id, kp.node_id_full],
        "policy": {},
        "joined_via_invite": True,
    }


def build_onboarding_ui(config=None, kp_provider=None):
    """Build Gradio onboarding UI. Returns None if gradio not available."""
    try:
        import gradio as gr
    except ImportError:
        return None

    with gr.Blocks(title="HearthNet — Onboarding") as demo:
        gr.Markdown("# HearthNet Onboarding")
        with gr.Tab("Create Community"):
            name_input = gr.Textbox(label="Community Name", placeholder="My Neighbourhood")
            create_btn = gr.Button("Create Community")
            create_output = gr.JSON(label="Result")

            def do_create(name):
                if not name:
                    return {"error": "Community name required"}
                return {
                    "message": f"Community '{name}' ready (keypair required for full flow)",
                    "status": "demo",
                }

            create_btn.click(do_create, inputs=name_input, outputs=create_output)

        with gr.Tab("Join Community"):
            invite_input = gr.Textbox(label="Invite Code", placeholder="hn1:...")
            join_btn = gr.Button("Join")
            join_output = gr.JSON(label="Result")

            def do_join(invite_text):
                try:
                    blob = decode_invite(invite_text)
                    return {
                        "community": blob.community_name,
                        "from": blob.inviter_node_id[:20],
                        "status": "verified",
                    }
                except OnboardingError as e:
                    return {"error": str(e)}

            join_btn.click(do_join, inputs=invite_input, outputs=join_output)

    return demo


class OnboardingError(Exception):
    def __init__(self, code: str, **kwargs: str) -> None:
        super().__init__(code)
        self.code = code
        self.context = kwargs


def _iso_now() -> str:
    from datetime import datetime

    return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")


def _iso_after(seconds: int) -> str:
    from datetime import datetime, timedelta

    return (datetime.now(UTC) + timedelta(seconds=seconds)).strftime("%Y-%m-%dT%H:%M:%SZ")


# Spec-mandated name (M13 §3.1)
build_onboarding = build_onboarding_ui