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