From 998f9c69308ee88fac36cfb77dfb6d07c6fa926a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 23 Sep 2026 17:46:48 +0200 Subject: feat(hub): invitation-link tickets bound to a verified address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit group_invite_links holds sha256(ticket) and the invitee's address blind index; redeeming grants membership to that account only. Owner-only create/list/cancel (a node token may create, never mail), 20 outstanding per group, optional mail written by the hub itself and capped at 10 per sender per day (mail.invite_link_daily_cap). MESHBAY_DESIGN.md §3.4 now carries the whole link design. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/api/invite_links.py | 336 +++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/api/invite_links.py (limited to 'packages/meshbay-hub/src/meshbay_hub/api/invite_links.py') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py new file mode 100644 index 0000000..d44576b --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py @@ -0,0 +1,336 @@ +""" +Invitation links — the hub's half (docs/MESHBAY_DESIGN.md §3.4, §7.3). + +A link carries two secrets with two jobs. The node's code decides who gets the +group key, and it is shown to the hub only when the inviter asks the hub to mail +the link. The ticket here decides who may *reach* the node — membership, +which is all the hub has to give (§7.1) — and it gives it to one account only: +the one whose verified address the inviter named. A ticket that leaks, through a +messaging service that previews links or a forwarded mail, is therefore useless +without that mailbox. + +Stated per the design's convention: that binding holds against third parties and +not against this hub, which verifies the addresses it compares. An active hub +could already be anybody. + +No route here answers without an account, and nothing tells the inviter whether +an address has an account (M1): creating a link looks the same either way. +""" + +import hashlib +import re +import secrets +from datetime import UTC, datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, field_validator +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from meshbay_hub import mail +from meshbay_hub.api.deps import _decode_token, get_current_user, require_user_scope +from meshbay_hub.api.middleware import limiter +from meshbay_hub.auth import hash_email_blind +from meshbay_hub.db.engine import get_db +from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User + +router = APIRouter(prefix="/v1/groups", tags=["invite-links"]) +redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"]) + +# The node holds at most as many unredeemed link codes per group; one more +# ticket here than codes there would be a link that cannot work. +MAX_OUTSTANDING_PER_GROUP = 20 +# A node's invitation lifetime is the operator's setting (7 days by default); +# the ticket follows it, up to this. +MAX_LIFETIME = timedelta(days=30) +# How long a redeemed link stays in the owner's list, saying who used it. +KEEP_REDEEMED = timedelta(days=30) + +_TICKET = re.compile(r"^[A-Za-z0-9_-]{22}$") # secrets.token_urlsafe(16) +_NODE_INVITE_ID = re.compile(r"^[0-9a-f]{32}$") # roster.create_link_invite +_CODE = re.compile(r"^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$") # roster.generate_code +_B64URL_KEY = re.compile(r"^[A-Za-z0-9_-]{43}$") # 32 bytes, unpadded + + +def ticket_hash(ticket: str) -> str: + return hashlib.sha256(ticket.encode()).hexdigest() + + +def invite_url(group_id: str, ticket: str, node_pk: str, code: str) -> str: + """ + The one shape an invitation link has (docs/MESHBAY_DESIGN.md §3.4). + + Everything after `#`, so none of it is sent to this hub in a request — it + reaches the page and the page alone, which parses this same shape. + """ + return (f"{mail.hub_url()}/#/invite?v=1&g={group_id}&t={ticket}" + f"&n={node_pk}&c={code}") + + +def _mask(email: str) -> str: + """`al***@ex***.com` — enough for the owner to recognise, not to reuse.""" + local, _, domain = email.strip().lower().partition("@") + name, _, tld = domain.rpartition(".") + return f"{local[:2]}***@{name[:2]}***.{tld}" + + +def _aware(when: datetime) -> datetime: + # SQLite hands a timezone-aware column back naive. + return when if when.tzinfo else when.replace(tzinfo=UTC) + + +def _valid_email(v: str) -> str: + v = v.strip() + local, sep, domain = v.partition("@") + if (not sep or not local or not domain or "." not in domain.strip(".") + or len(v) > 254 or any(c.isspace() or ord(c) < 32 for c in v)): + raise ValueError("invalid email address") + return v + + +class CreateLinkRequest(BaseModel): + email: str + expires_at: str + node_invite_id: str + # Only when the hub is to mail the link, since only then must it write it: + # the node key in its URL-safe form, and the node's code — which the hub + # therefore reads. Unchecked in the interface, neither is sent. + send_email: bool = False + node_pk: str = "" + code: str = "" + + @field_validator("email") + @classmethod + def email_valid(cls, v: str) -> str: + return _valid_email(v) + + +class TicketRequest(BaseModel): + ticket: str + + +async def _owned_group(db: AsyncSession, group_id: str, user: User) -> Group: + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + if group.admin_id != user.id: + raise HTTPException(status_code=403, + detail="Only the group owner can manage its invitation links") + return group + + +@router.post("/{group_id}/invite-links", status_code=201) +@limiter.limit("20/hour") +async def create_invite_link( + group_id: str, + body: CreateLinkRequest, + request: Request, + payload: dict = Depends(_decode_token), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Mint the ticket for a link whose node half already exists. + + `get_current_user`, not `require_user_scope`: `meshbay-node member invite + --link` asks with the node's token, as the CLI invitation already does, and + the owner check is the guard — a node can only act for its own operator's + groups. The CLI mails nothing (`USERGUIDE.md` §7), so a node token asking + for mail is refused rather than quietly obeyed. + """ + group = await _owned_group(db, group_id, current_user) + if group.status != "active": + raise HTTPException(status_code=409, detail="This group is not active") + if group.join_policy == "open": + raise HTTPException(status_code=409, + detail="An open group admits anyone; it needs no invitation") + if not _NODE_INVITE_ID.match(body.node_invite_id): + raise HTTPException(status_code=422, detail="Not a node invitation id") + if body.send_email: + if payload.get("scope") == "node": + raise HTTPException(status_code=403, + detail="Invitation mail is sent from the interface only") + if not (_CODE.match(body.code) and _B64URL_KEY.match(body.node_pk)): + raise HTTPException(status_code=422, + detail="Mailing a link needs its code and node key") + + now = datetime.now(UTC) + try: + expires = _aware(datetime.fromisoformat(body.expires_at)) + except ValueError: + raise HTTPException(status_code=422, detail="Not a date") from None + if expires <= now: + raise HTTPException(status_code=422, detail="That invitation has already expired") + expires = min(expires, now + MAX_LIFETIME) + + await _purge(db, group_id, now) + outstanding = (await db.execute( + select(func.count()).select_from(GroupInviteLink).where( + GroupInviteLink.group_id == group_id, + GroupInviteLink.redeemed_by.is_(None), + GroupInviteLink.expires_at > now))).scalar() or 0 + if outstanding >= MAX_OUTSTANDING_PER_GROUP: + raise HTTPException( + status_code=429, + detail=f"This group already has {outstanding} unused invitation links") + + ticket = secrets.token_urlsafe(16) + row = GroupInviteLink( + group_id=group_id, created_by=current_user.id, ticket_hash=ticket_hash(ticket), + email_hash=hash_email_blind(body.email), email_masked=_mask(body.email), + node_invite_id=body.node_invite_id, expires_at=expires) + db.add(row) + await db.flush() + + email_status = "not_requested" + if body.send_email: + try: + sent = await mail.send_off_loop( + db, mail.send_invite_link, body.email, + invite_url(group_id, ticket, body.node_pk, body.code.upper()), + current_user.username, group.name, + purpose="invite_link", sender=current_user.id) + email_status = "sent" if sent else "refused" + except Exception: + email_status = "send_failed" + + await db.commit() + return {"link_id": row.id, "ticket": ticket, "expires_at": expires.isoformat(), + "email_status": email_status} + + +@router.get("/{group_id}/invite-links") +async def list_invite_links( + group_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """The owner's view: who each link was for, masked, and whether it was used.""" + await _owned_group(db, group_id, current_user) + rows = (await db.execute( + select(GroupInviteLink, User.username) + .outerjoin(User, User.id == GroupInviteLink.redeemed_by) + .where(GroupInviteLink.group_id == group_id) + .order_by(GroupInviteLink.created_at.desc()) + .limit(200))).all() + now = datetime.now(UTC) + return {"links": [{ + "link_id": r.id, + "email": r.email_masked, + "node_invite_id": r.node_invite_id, + "created_at": _aware(r.created_at).isoformat(), + "expires_at": _aware(r.expires_at).isoformat(), + "status": ("redeemed" if r.redeemed_by + else "expired" if _aware(r.expires_at) <= now else "pending"), + "redeemed_by": name, + } for r, name in rows]} + + +@router.delete("/{group_id}/invite-links/{link_id}") +async def delete_invite_link( + group_id: str, + link_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Take the ticket back. The node's code is the client's half to cancel, + first, with a signed `invite_cancel` — this only closes the door here.""" + await _owned_group(db, group_id, current_user) + row = await db.get(GroupInviteLink, link_id) + if not row or row.group_id != group_id: + raise HTTPException(status_code=404, detail="No such invitation link") + if row.redeemed_by: + raise HTTPException(status_code=409, + detail="This link has been used; remove the member instead") + await db.delete(row) + await db.commit() + return {"status": "deleted", "link_id": link_id} + + +async def _resolve(db: AsyncSession, ticket: str, user: User + ) -> tuple[GroupInviteLink, Group]: + """ + The link this ticket names, if this account may use it. + + One uniform refusal for everything that says nothing about the account — + unknown, spent by someone else, expired, a group no longer active — and one + distinct answer, `invite_other_account`, for the case the person can act + on: signed in as somebody other than the address it was sent to. That + answer names no address, and it is given only to someone holding the + ticket, who already knows a link exists. + """ + invalid = HTTPException(status_code=404, detail="invite_not_valid") + if not _TICKET.match(ticket or ""): + raise invalid + row = (await db.execute(select(GroupInviteLink).where( + GroupInviteLink.ticket_hash == ticket_hash(ticket)))).scalar_one_or_none() + if row is None: + raise invalid + group = await db.get(Group, row.group_id) + if group is None or group.status != "active": + raise invalid + if row.redeemed_by and row.redeemed_by != user.id: + raise invalid + if not row.redeemed_by and _aware(row.expires_at) <= datetime.now(UTC): + raise invalid + if not user.email_hash or user.email_hash != row.email_hash: + raise HTTPException(status_code=403, detail="invite_other_account") + return row, group + + +@redeem_router.post("/preview") +@limiter.limit("30/minute") +async def preview_invite_link( + body: TicketRequest, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """What the confirmation screen shows before anyone joins anything.""" + row, group = await _resolve(db, body.ticket, current_user) + inviter = await db.get(User, row.created_by) + member = await db.get(GroupMember, (group.id, current_user.id)) + return {"group_id": group.id, "group_name": group.name, + "inviter": inviter.username if inviter else "", + "expires_at": _aware(row.expires_at).isoformat(), + "already_member": member is not None} + + +@redeem_router.post("/redeem") +@limiter.limit("30/minute") +async def redeem_invite_link( + body: TicketRequest, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """ + Membership for the addressed account, once — and the same answer again for + that account, because a second tab or a reload is the same person. + """ + row, group = await _resolve(db, body.ticket, current_user) + if not row.redeemed_by: + claimed = await db.execute( + update(GroupInviteLink) + .where(GroupInviteLink.id == row.id, GroupInviteLink.redeemed_by.is_(None)) + .values(redeemed_by=current_user.id, redeemed_at=datetime.now(UTC))) + if claimed.rowcount == 0: + raise HTTPException(status_code=404, detail="invite_not_valid") + if not await db.get(GroupMember, (group.id, current_user.id)): + db.add(GroupMember(group_id=group.id, user_id=current_user.id)) + from meshbay_hub.api.notifications import create_notification + await create_notification( + db, row.created_by, "invite_link_redeemed", + f"{current_user.username} joined {group.name} through your invitation link", + link=f"#/group/{group.id}", group_id=group.id) + await db.commit() + return {"group_id": group.id, "group_name": group.name} + + +async def _purge(db: AsyncSession, group_id: str, now: datetime) -> None: + """Forget this group's dead links: unused ones once expired, used ones after + `KEEP_REDEEMED`. Done on the way in, so the table cannot outgrow its use.""" + await db.execute(delete(GroupInviteLink).where( + GroupInviteLink.group_id == group_id, + ((GroupInviteLink.redeemed_by.is_(None) & (GroupInviteLink.expires_at <= now)) + | (GroupInviteLink.redeemed_at < now - KEEP_REDEEMED)))) -- cgit v1.2.3