summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/invite_links.py336
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a7_add_group_invite_links.py46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/hub_settings.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py54
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py16
-rw-r--r--packages/meshbay-hub/tests/test_group_purge.py9
-rw-r--r--packages/meshbay-hub/tests/test_invite_links.py332
22 files changed, 847 insertions, 9 deletions
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))))
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 9b9a189..a994acb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -35,6 +35,7 @@ from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
EmailVerification,
Group,
+ GroupInviteLink,
GroupMember,
IPLog,
Node,
@@ -1396,6 +1397,10 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
await db.execute(delete(SwarmSource).where(SwarmSource.node_id == user.id))
await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id))
+ # Links this account issued for a group it no longer owns; the ones for its
+ # own groups went with them above. A used link keeps pointing at the
+ # tombstone of whoever used it, which is the record of the join.
+ await db.execute(delete(GroupInviteLink).where(GroupInviteLink.created_by == user.id))
username = user.username
# Before the name is released: the connection log is kept for its legal
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 209dc32..b1d9626 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -25,6 +25,8 @@ from meshbay_hub.api.groups import swarm_router
from meshbay_hub.api.health import router as health_router
from meshbay_hub.api.hub import router as hub_router
from meshbay_hub.api.hub import set_config as hub_set_config
+from meshbay_hub.api.invite_links import redeem_router as invite_links_redeem_router
+from meshbay_hub.api.invite_links import router as invite_links_router
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.moderation import router as moderation_router
from meshbay_hub.api.nodes import router as nodes_router
@@ -195,6 +197,8 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(users_router)
app.include_router(nodes_router)
app.include_router(groups_router)
+ app.include_router(invite_links_router)
+ app.include_router(invite_links_redeem_router)
app.include_router(swarm_router)
app.include_router(revocation_router)
app.include_router(moderation_router)
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
index f2212de..e618510 100644
--- a/packages/meshbay-hub/src/meshbay_hub/config.py
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -130,6 +130,11 @@ class MailConfig:
# recipient, and without the exemption a typo locks the account out for the
# whole window.
email_change_cooldown: int = 172800 # 48 hours
+ # Invitation-link mails one account may have the hub send per day. The only
+ # per-account bound here, because a link reaches addresses with no account,
+ # and the per-recipient cap alone would let one account mail ten strangers
+ # each, without end.
+ invite_link_daily_cap: int = 10
@dataclass
@@ -174,7 +179,7 @@ def load_config(path: Path | None = None) -> HubConfig:
"destination_cooldown_seconds", "destination_daily_cap",
"hourly_budget", "hourly_reserved_for_recovery",
"verification_resend_cooldown", "reset_cooldown",
- "email_change_cooldown",
+ "email_change_cooldown", "invite_link_daily_cap",
):
setattr(cfg.mail, name, ml.get(name, getattr(cfg.mail, name)))
if cap := raw.get("captcha", {}):
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a7_add_group_invite_links.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a7_add_group_invite_links.py
new file mode 100644
index 0000000..75b4d49
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a7_add_group_invite_links.py
@@ -0,0 +1,46 @@
+"""add group_invite_links
+
+The hub's half of an invitation link: a ticket, stored hashed, that grants
+membership of one group to the one account whose verified address matches.
+
+Revision ID: b2c3d4e5f6a7
+Revises: a9b8c7d6e5f4
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "b2c3d4e5f6a7"
+down_revision: str | Sequence[str] | None = "a9b8c7d6e5f4"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "group_invite_links",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("group_id", sa.String(36), sa.ForeignKey("groups.id"), nullable=False),
+ sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
+ sa.Column("ticket_hash", sa.String(64), nullable=False),
+ sa.Column("email_hash", sa.String(64), nullable=False),
+ sa.Column("email_masked", sa.String(128), nullable=False),
+ sa.Column("node_invite_id", sa.String(32), nullable=False, server_default=""),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
+ server_default=sa.func.now()),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("redeemed_by", sa.String(36), sa.ForeignKey("users.id")),
+ sa.Column("redeemed_at", sa.DateTime(timezone=True)),
+ )
+ op.create_index("uq_invite_links_ticket", "group_invite_links", ["ticket_hash"],
+ unique=True)
+ op.create_index("ix_invite_links_group", "group_invite_links", ["group_id"])
+
+
+def downgrade() -> None:
+ # Outstanding links stop working; the node halves stay until they expire.
+ op.drop_index("ix_invite_links_group", table_name="group_invite_links")
+ op.drop_index("uq_invite_links_ticket", table_name="group_invite_links")
+ op.drop_table("group_invite_links")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index ac1828f..51a3d47 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -145,6 +145,40 @@ class GroupMember(Base):
user: Mapped["User"] = relationship(back_populates="group_memberships")
+class GroupInviteLink(Base):
+ """
+ The hub's half of an invitation link (docs/MESHBAY_DESIGN.md §7.3).
+
+ A link carries two secrets. The node's code decides whether someone gets the
+ group key, and the hub never sees it. This row decides whether someone may
+ *reach* the node at all — membership, which is all the hub has to give — and
+ only for the account whose verified address matches `email_hash`. The ticket
+ is stored as `sha256(ticket)`, so a copy of this table opens nothing.
+
+ No address in the clear: `email_hash` is the same blind index `users` has,
+ and `email_masked` is what the owner's list shows (`al***@ex***.com`).
+ """
+ __tablename__ = "group_invite_links"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
+ group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), nullable=False)
+ created_by: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
+ ticket_hash: Mapped[str] = mapped_column(String(64), nullable=False)
+ email_hash: Mapped[str] = mapped_column(String(64), nullable=False)
+ email_masked: Mapped[str] = mapped_column(String(128), nullable=False)
+ # The node's handle for its half, so cancelling can take back both.
+ node_invite_id: Mapped[str] = mapped_column(String(32), nullable=False, default="")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ redeemed_by: Mapped[str | None] = mapped_column(ForeignKey("users.id"))
+ redeemed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+
+ __table_args__ = (
+ Index("uq_invite_links_ticket", "ticket_hash", unique=True),
+ Index("ix_invite_links_group", "group_id"),
+ )
+
+
# ── Refresh tokens ────────────────────────────────────────────────────────────
class RefreshToken(Base):
diff --git a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
index c397f58..e01bfd2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
+++ b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
@@ -35,6 +35,7 @@ MAIL_KEYS = (
"verification_resend_cooldown",
"reset_cooldown",
"email_change_cooldown",
+ "invite_link_daily_cap",
)
# What a value may be. A cooldown of zero disables it, which is a legitimate
@@ -49,6 +50,7 @@ MAIL_BOUNDS: dict[str, tuple[int, int]] = {
"verification_resend_cooldown": (0, 86_400),
"reset_cooldown": (0, 86_400),
"email_change_cooldown": (0, 2_592_000), # 30 days
+ "invite_link_daily_cap": (1, 1_000),
}
_mail_defaults: dict[str, int] = {}
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py
index 1eb7c51..d2659e0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/mail.py
+++ b/packages/meshbay-hub/src/meshbay_hub/mail.py
@@ -43,13 +43,14 @@ class MailRefused(Exception):
# registration confirm an address at sign-up
# password_reset a code to the address already on file for that account
# invite tell a registered member they were invited to a group
+# invite_link send an invitation link to an address, account or not
# email_change confirm a new address before it replaces the old one
#
-# Only `registration` and `email_change` can reach an address this hub has no
-# prior relationship with. Both are necessary; both are why the bound that
+# Only `registration`, `email_change` and `invite_link` can reach an address this
+# hub has no prior relationship with. Both are necessary; both are why the bound that
# matters is keyed on the recipient rather than on who asked.
ALLOWED_PURPOSES = frozenset({
- "registration", "password_reset", "invite", "email_change"})
+ "registration", "password_reset", "invite", "email_change", "invite_link"})
# The two a person is actively waiting on. They may spend the whole hourly
# budget; the other two may not spend the reserved share of it, so a flood of
@@ -166,7 +167,7 @@ async def _announce_exhaustion(scope: str) -> None:
log.warning("Could not announce the mail budget: %s", e)
-async def reserve(db, purpose: str, address: str) -> None:
+async def reserve(db, purpose: str, address: str, *, sender: str = "") -> None:
"""Charge one send, or raise MailRefused. The caller owns the commit."""
from meshbay_hub import hub_settings
@@ -175,6 +176,18 @@ async def reserve(db, purpose: str, address: str) -> None:
limits = await hub_settings.mail_limits(db)
+ # An invitation link goes to an address that may belong to nobody here, at
+ # the request of anyone who owns a group — which is anyone, since
+ # registration is open. So the account asking is counted too, per day
+ # (docs/MESHBAY_DESIGN.md §3.4, AV29). Charged first: of the three, it is the one
+ # bound on the person causing the mail, and a refusal further down leaves
+ # it spent, which errs towards less mail. No sender, no link mail.
+ if purpose == "invite_link":
+ if not sender:
+ raise MailRefused("an invitation link mail names the account sending it")
+ await _take(db, f"invite_link:{sender}", timedelta(days=1),
+ limits["invite_link_daily_cap"], None)
+
# The instance ceiling first, and the recovery share is subtracted for the
# purposes that are not one. Registration is open, so a limit counted per
# account or per IP is one an attacker buys more of; this one is not.
@@ -283,7 +296,7 @@ def _send(msg: EmailMessage, *, purpose: str) -> bool:
return False
-async def send_off_loop(db, fn, *args, purpose: str, **kwargs) -> bool:
+async def send_off_loop(db, fn, *args, purpose: str, sender: str = "", **kwargs) -> bool:
"""Run one of the `send_*` functions below in a worker thread.
`smtplib` is synchronous and this one waits up to ten seconds. Called
@@ -304,7 +317,7 @@ async def send_off_loop(db, fn, *args, purpose: str, **kwargs) -> bool:
nobody received.
"""
try:
- await reserve(db, purpose, args[0])
+ await reserve(db, purpose, args[0], sender=sender)
except MailRefused as refusal:
# Never the address: this line goes to the journal.
log.warning("Mail refused (%s): %s", purpose, refusal)
@@ -421,6 +434,35 @@ def send_invite_notification(
log.info("Invite notification sent to %s", _mask_email(to))
+def send_invite_link(to: str, link: str, inviter: str, group_name: str) -> None:
+ """An invitation link, to an address that may have no account yet.
+
+ Nothing in it comes from the request but the link, and the link is checked
+ by the caller to be this hub's own invitation URL before it gets here.
+ """
+ msg = EmailMessage()
+ msg["From"] = f"noreply@{_hub_domain}"
+ msg["To"] = to
+ msg["Subject"] = f"MeshBay — {inviter} invited you to {group_name}"
+ msg.set_content(
+ f"{inviter} invited you to the group \"{group_name}\" on MeshBay.\n"
+ "\n"
+ "Open this link to create your account, or to sign in, and join the group:\n"
+ "\n"
+ f"{link}\n"
+ "\n"
+ "It works once, and only for an account registered with this address.\n"
+ "If you did not expect it, you can ignore this message.\n"
+ )
+ _send(msg, purpose="invite_link")
+ log.info("Invitation link sent to %s", _mask_email(to))
+
+
+def hub_url() -> str:
+ """This hub's own address, as every mail it sends names it."""
+ return _hub_url
+
+
def _mask_email(email: str) -> str:
local, _, domain = email.partition("@")
if len(local) <= 2:
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
index b3d71dd..8cbfb5a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
@@ -195,6 +195,7 @@ const MAIL_FIELDS = [
'hourly_reserved_for_recovery',
'destination_daily_cap',
'destination_cooldown_seconds',
+ 'invite_link_daily_cap',
'verification_resend_cooldown',
'reset_cooldown',
'email_change_cooldown',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 81a6689..17a184e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -553,6 +553,7 @@ export default {
'admin.mail_hourly_reserved_for_recovery': "Davon reserviert für Rücksetzungen und Einladungen",
'admin.mail_destination_daily_cap': "Nachrichten pro Tag an einen Empfänger",
'admin.mail_destination_cooldown_seconds': "Sekunden zwischen Nachrichten an einen Empfänger",
+ 'admin.mail_invite_link_daily_cap': 'Einladungslink-E-Mails pro Tag und Konto',
'admin.mail_verification_resend_cooldown': "Sekunden zwischen zwei Registrierungscodes",
'admin.mail_reset_cooldown': "Sekunden zwischen zwei Rücksetzungscodes",
'admin.mail_email_change_cooldown': "Sekunden, bevor ein Konto eine andere Adresse vorschlagen darf",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 4dee9f3..a58fdd6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -541,6 +541,7 @@ export default {
'admin.mail_hourly_reserved_for_recovery': "Of those, reserved for resets and invitations",
'admin.mail_destination_daily_cap': "Messages per day to one recipient",
'admin.mail_destination_cooldown_seconds': "Seconds between messages to one recipient",
+ 'admin.mail_invite_link_daily_cap': 'Invitation-link mails per day from one account',
'admin.mail_verification_resend_cooldown': "Seconds between two sign-up codes",
'admin.mail_reset_cooldown': "Seconds between two passphrase-reset codes",
'admin.mail_email_change_cooldown': "Seconds before an account may propose another address",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 567838d..09592ae 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -548,6 +548,7 @@ export default {
'admin.mail_hourly_reserved_for_recovery': "De ellos, reservados para restablecimientos e invitaciones",
'admin.mail_destination_daily_cap': "Mensajes por día a un mismo destinatario",
'admin.mail_destination_cooldown_seconds': "Segundos entre mensajes al mismo destinatario",
+ 'admin.mail_invite_link_daily_cap': 'Correos de enlace de invitación por día y por cuenta',
'admin.mail_verification_resend_cooldown': "Segundos entre dos códigos de registro",
'admin.mail_reset_cooldown': "Segundos entre dos códigos de restablecimiento",
'admin.mail_email_change_cooldown': "Segundos antes de que una cuenta pueda proponer otra dirección",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 0cc176f..b77bf2b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -551,6 +551,7 @@ export default {
'admin.mail_hourly_reserved_for_recovery': "Dont réservés aux réinitialisations et invitations",
'admin.mail_destination_daily_cap': "Messages par jour vers un même destinataire",
'admin.mail_destination_cooldown_seconds': "Secondes entre deux messages au même destinataire",
+ 'admin.mail_invite_link_daily_cap': 'E-mails de lien d’invitation par jour et par compte',
'admin.mail_verification_resend_cooldown': "Secondes entre deux codes d'inscription",
'admin.mail_reset_cooldown': "Secondes entre deux codes de réinitialisation",
'admin.mail_email_change_cooldown': "Secondes avant qu'un compte puisse proposer une autre adresse",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 03a1762..2deb4fc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -551,6 +551,7 @@ export default {
'admin.mail_hourly_reserved_for_recovery': "Di cui riservati a reimpostazioni e inviti",
'admin.mail_destination_daily_cap': "Messaggi al giorno verso uno stesso destinatario",
'admin.mail_destination_cooldown_seconds': "Secondi tra due messaggi allo stesso destinatario",
+ 'admin.mail_invite_link_daily_cap': 'E-mail con link d’invito al giorno per account',
'admin.mail_verification_resend_cooldown': "Secondi tra due codici di registrazione",
'admin.mail_reset_cooldown': "Secondi tra due codici di reimpostazione",
'admin.mail_email_change_cooldown': "Secondi prima che un account possa proporre un altro indirizzo",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index f5447bf..0b46994 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -544,6 +544,7 @@ export default {
'admin.mail_hourly_reserved_for_recovery': "うち再設定と招待のために確保する数",
'admin.mail_destination_daily_cap': "同一受信者への 1 日あたりの通数",
'admin.mail_destination_cooldown_seconds': "同一受信者への送信間隔(秒)",
+ 'admin.mail_invite_link_daily_cap': '1アカウントあたり1日の招待リンクメール数',
'admin.mail_verification_resend_cooldown': "登録コード再送の間隔(秒)",
'admin.mail_reset_cooldown': "再設定コード再送の間隔(秒)",
'admin.mail_email_change_cooldown': "別のアドレスを申請できるようになるまでの秒数",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index b051a87..310bb4d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -552,6 +552,7 @@ export default {
'admin.mail_hourly_reserved_for_recovery': "Daarvan gereserveerd voor herstel en uitnodigingen",
'admin.mail_destination_daily_cap': "Berichten per dag naar één ontvanger",
'admin.mail_destination_cooldown_seconds': "Seconden tussen berichten naar één ontvanger",
+ 'admin.mail_invite_link_daily_cap': 'E-mails met uitnodigingslink per dag per account',
'admin.mail_verification_resend_cooldown': "Seconden tussen twee registratiecodes",
'admin.mail_reset_cooldown': "Seconden tussen twee herstelcodes",
'admin.mail_email_change_cooldown': "Seconden voordat een account een ander adres mag voorstellen",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 1e29669..886f1c7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -564,6 +564,7 @@ export default {
'admin.mail_hourly_reserved_for_recovery': "Z tego zarezerwowane na resety i zaproszenia",
'admin.mail_destination_daily_cap': "Wiadomości dziennie do jednego odbiorcy",
'admin.mail_destination_cooldown_seconds': "Sekundy między wiadomościami do jednego odbiorcy",
+ 'admin.mail_invite_link_daily_cap': 'E-maile z linkiem zaproszenia dziennie na konto',
'admin.mail_verification_resend_cooldown': "Sekundy między dwoma kodami rejestracji",
'admin.mail_reset_cooldown': "Sekundy między dwoma kodami resetu",
'admin.mail_email_change_cooldown': "Sekundy, zanim konto może zaproponować inny adres",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index a0ae557..643548a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -550,6 +550,7 @@ export default {
'admin.mail_hourly_reserved_for_recovery': "Destas, reservadas para redefinições e convites",
'admin.mail_destination_daily_cap': "Mensagens por dia para um mesmo destinatário",
'admin.mail_destination_cooldown_seconds': "Segundos entre mensagens ao mesmo destinatário",
+ 'admin.mail_invite_link_daily_cap': 'E-mails de link de convite por dia por conta',
'admin.mail_verification_resend_cooldown': "Segundos entre dois códigos de cadastro",
'admin.mail_reset_cooldown': "Segundos entre dois códigos de redefinição",
'admin.mail_email_change_cooldown': "Segundos até uma conta poder propor outro endereço",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 8d3e426..17adb8b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -537,6 +537,7 @@ export default {
'admin.mail_hourly_reserved_for_recovery': "其中为重置与邀请保留",
'admin.mail_destination_daily_cap': "每日发往同一收件人的邮件数",
'admin.mail_destination_cooldown_seconds': "发往同一收件人的间隔(秒)",
+ 'admin.mail_invite_link_daily_cap': '每个账户每天的邀请链接邮件数',
'admin.mail_verification_resend_cooldown': "两次注册验证码的间隔(秒)",
'admin.mail_reset_cooldown': "两次重置验证码的间隔(秒)",
'admin.mail_email_change_cooldown': "账号可再次申请其他地址前的秒数",
diff --git a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
index 1ef7796..b0ef4d4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
+++ b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub.db.models import EmailVerification, Group, IPLog, User
+from meshbay_hub.db.models import EmailVerification, Group, GroupInviteLink, IPLog, User
log = logging.getLogger(__name__)
@@ -42,6 +42,17 @@ async def purge_stale_pending_users(db: AsyncSession,
return result.rowcount
+async def purge_invite_links(db: AsyncSession) -> int:
+ """Unused invitation links once expired; used ones after a month."""
+ from meshbay_hub.api.invite_links import KEEP_REDEEMED
+ now = datetime.now(UTC)
+ result = await db.execute(delete(GroupInviteLink).where(
+ (GroupInviteLink.redeemed_by.is_(None) & (GroupInviteLink.expires_at <= now))
+ | (GroupInviteLink.redeemed_at < now - KEEP_REDEEMED)))
+ await db.commit()
+ return result.rowcount
+
+
async def cleanup_loop(get_session):
"""Run cleanup once at startup, then every 24 hours."""
try:
@@ -71,6 +82,9 @@ async def cleanup_loop(get_session):
throttled = await login_throttle.purge_expired(db)
if throttled:
log.info("Purged %d expired sign-in counters", throttled)
+ links = await purge_invite_links(db)
+ if links:
+ log.info("Purged %d spent or expired invitation links", links)
except asyncio.CancelledError:
raise
except Exception as e:
diff --git a/packages/meshbay-hub/tests/test_group_purge.py b/packages/meshbay-hub/tests/test_group_purge.py
index e729297..9c6a664 100644
--- a/packages/meshbay-hub/tests/test_group_purge.py
+++ b/packages/meshbay-hub/tests/test_group_purge.py
@@ -25,6 +25,7 @@ from meshbay_hub.db.models import (
ContentReport,
EmailVerification,
Group,
+ GroupInviteLink,
GroupMember,
IPLog,
Notification,
@@ -34,7 +35,8 @@ from meshbay_hub.db.purge import _referencing
from sqlalchemy import delete, func, select, text
from sqlalchemy.exc import IntegrityError
-SEEDED = {"group_members", "notifications", "email_verifications", "content_reports"}
+SEEDED = {"group_members", "notifications", "email_verifications", "content_reports",
+ "group_invite_links"}
def _auth_key(password: str, username: str) -> str:
@@ -80,6 +82,11 @@ async def _group_with_everything(client, db, owner_token: str, member: str, name
expires_at=datetime.now(UTC) + timedelta(days=1)))
db.add(ContentReport(reporter_id=member_id, content_hash="ab" * 32, group_id=gid,
ip_address="192.0.2.1"))
+ owner_id = (await db.execute(select(Group.admin_id).where(Group.id == gid))).scalar_one()
+ db.add(GroupInviteLink(group_id=gid, created_by=owner_id, ticket_hash=gid[:8] * 8,
+ email_hash="1" * 64, email_masked="m***@e***.com",
+ expires_at=datetime.now(UTC) + timedelta(days=1),
+ redeemed_by=member_id, redeemed_at=datetime.now(UTC)))
await db.commit()
return gid
diff --git a/packages/meshbay-hub/tests/test_invite_links.py b/packages/meshbay-hub/tests/test_invite_links.py
new file mode 100644
index 0000000..1b60dc1
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_invite_links.py
@@ -0,0 +1,332 @@
+"""
+Invitation links, the hub's half (docs/MESHBAY_DESIGN.md §3.4, §7.3).
+
+The ticket is what lets somebody reach the node, so each test here is a way it
+could let in someone other than the one account it was meant for, tell the
+inviter something the hub should not, or make the hub mail somebody it should
+not. Every test has at least two accounts: a one-account test proves a
+one-account property (CLAUDE.md, "ask who pays").
+"""
+
+import base64
+import time
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_hub import mail as mail_mod
+from meshbay_hub.api import invite_links
+from meshbay_hub.db.models import GroupInviteLink, GroupMember
+from sqlalchemy import select
+
+NODE_INVITE_ID = "ab" * 16
+NODE_PK = "A" * 43
+CODE = "K7P2-9WQX"
+
+
+async def _account(client, username: str, email: str | None = None) -> dict:
+ auth_key = base64.b64encode(b"k" * 32).decode()
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": email or f"{username}@example.test",
+ "auth_key": auth_key})
+ assert r.status_code == 201, r.text
+ r = await client.post("/v1/users/login", json={"username": username, "auth_key": auth_key})
+ assert r.status_code == 200, r.text
+ return {"username": username,
+ "h": {"Authorization": f"Bearer {r.json()['access_token']}"}}
+
+
+async def _group(client, owner: dict, name: str = "family-photos", **extra) -> str:
+ r = await client.post("/v1/groups", json={"name": name, **extra}, headers=owner["h"])
+ assert r.status_code in (200, 201), r.text
+ return r.json()["group_id"]
+
+
+def _expires(days: float = 7) -> str:
+ return (datetime.now(UTC) + timedelta(days=days)).isoformat(timespec="seconds")
+
+
+async def _link(client, owner, gid, email="invitee@example.test", **extra):
+ body = {"email": email, "expires_at": _expires(), "node_invite_id": NODE_INVITE_ID,
+ **extra}
+ return await client.post(f"/v1/groups/{gid}/invite-links", json=body, headers=owner["h"])
+
+
+@pytest.fixture
+def sent(monkeypatch):
+ out = []
+ monkeypatch.setattr(mail_mod, "_send", lambda msg, **_: out.append(msg) or True)
+ return out
+
+
+# ── Who gets in ──────────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_the_addressed_account_joins_and_nobody_else(client, db_session):
+ owner = await _account(client, "link_owner")
+ gid = await _group(client, owner)
+ r = await _link(client, owner, gid, email="Invitee@Example.test")
+ assert r.status_code == 201, r.text
+ ticket = r.json()["ticket"]
+
+ mallory = await _account(client, "link_mallory")
+ for route in ("preview", "redeem"):
+ r = await client.post(f"/v1/invite-links/{route}", json={"ticket": ticket},
+ headers=mallory["h"])
+ assert r.status_code == 403 and r.json()["detail"] == "invite_other_account"
+ assert "invitee" not in r.text.lower(), "the refusal must not name the address"
+
+ # Registered with the address the owner typed, case aside.
+ invitee = await _account(client, "link_invitee", email="invitee@example.test")
+ r = await client.post("/v1/invite-links/preview", json={"ticket": ticket},
+ headers=invitee["h"])
+ assert r.status_code == 200, r.text
+ assert r.json()["group_name"] == "family-photos" and r.json()["inviter"] == "link_owner"
+ assert r.json()["already_member"] is False
+
+ r = await client.post("/v1/invite-links/redeem", json={"ticket": ticket},
+ headers=invitee["h"])
+ assert r.status_code == 200 and r.json()["group_id"] == gid
+ # A member now — which the members list answers to members only. (Not
+ # `/groups/mine`: a group no node has hosted yet is shown to its owner alone.)
+ r = await client.get(f"/v1/groups/{gid}/members", headers=invitee["h"])
+ assert r.status_code == 200
+ assert "link_invitee" in {m["username"] for m in r.json()["members"]}
+
+ # A second tab, or a reload, is the same person: same answer, no second row.
+ r = await client.post("/v1/invite-links/redeem", json={"ticket": ticket},
+ headers=invitee["h"])
+ assert r.status_code == 200
+ rows = (await db_session.execute(select(GroupMember).where(
+ GroupMember.group_id == gid))).scalars().all()
+ assert len(rows) == 2
+
+ # And the one who comes after, even with the right address, gets nothing:
+ # a twin account cannot exist (addresses are unique), so try the other.
+ r = await client.post("/v1/invite-links/redeem", json={"ticket": ticket},
+ headers=mallory["h"])
+ assert r.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_a_ticket_is_stored_only_as_a_hash(client, db_session):
+ owner = await _account(client, "hash_owner")
+ gid = await _group(client, owner)
+ ticket = (await _link(client, owner, gid)).json()["ticket"]
+ row = (await db_session.execute(select(GroupInviteLink))).scalar_one()
+ assert ticket not in (row.ticket_hash, row.email_masked, row.email_hash)
+ assert row.ticket_hash == invite_links.ticket_hash(ticket)
+ assert "invitee@" not in row.email_masked
+
+
+@pytest.mark.asyncio
+async def test_expired_cancelled_and_unknown_tickets_are_one_answer(client):
+ owner = await _account(client, "dead_owner")
+ invitee = await _account(client, "dead_invitee", email="invitee@example.test")
+ gid = await _group(client, owner)
+
+ cancelled = (await _link(client, owner, gid)).json()
+ r = await client.delete(f"/v1/groups/{gid}/invite-links/{cancelled['link_id']}",
+ headers=owner["h"])
+ assert r.status_code == 200
+
+ for ticket in (cancelled["ticket"], "x" * 22, "not-a-ticket", ""):
+ r = await client.post("/v1/invite-links/redeem", json={"ticket": ticket},
+ headers=invitee["h"])
+ assert (r.status_code, r.json()["detail"]) == (404, "invite_not_valid")
+
+
+@pytest.mark.asyncio
+async def test_a_suspended_group_admits_nobody_by_link(client, db_session):
+ from meshbay_hub.db.models import Group
+ owner = await _account(client, "susp_owner")
+ invitee = await _account(client, "susp_invitee", email="invitee@example.test")
+ gid = await _group(client, owner)
+ ticket = (await _link(client, owner, gid)).json()["ticket"]
+ (await db_session.get(Group, gid)).status = "suspended"
+ await db_session.commit()
+ r = await client.post("/v1/invite-links/redeem", json={"ticket": ticket},
+ headers=invitee["h"])
+ assert r.status_code == 404
+
+
+# ── Who may issue ────────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_only_the_owner_issues_lists_and_cancels(client):
+ owner = await _account(client, "own_owner")
+ other = await _account(client, "own_other")
+ gid = await _group(client, owner)
+ link = (await _link(client, owner, gid)).json()
+
+ assert (await _link(client, other, gid)).status_code == 403
+ assert (await client.get(f"/v1/groups/{gid}/invite-links",
+ headers=other["h"])).status_code == 403
+ assert (await client.delete(f"/v1/groups/{gid}/invite-links/{link['link_id']}",
+ headers=other["h"])).status_code == 403
+
+ r = await client.get(f"/v1/groups/{gid}/invite-links", headers=owner["h"])
+ [row] = r.json()["links"]
+ assert row["status"] == "pending" and row["email"] == "in***@ex***.test"
+ assert row["node_invite_id"] == NODE_INVITE_ID
+
+
+@pytest.mark.asyncio
+async def test_creating_a_link_says_nothing_about_whether_the_address_has_an_account(client):
+ """M1: the same answer for a registered address and a stranger's."""
+ owner = await _account(client, "m1_owner")
+ await _account(client, "m1_known", email="known@example.test")
+ gid = await _group(client, owner)
+ a = (await _link(client, owner, gid, email="known@example.test")).json()
+ b = (await _link(client, owner, gid, email="nobody@example.test")).json()
+ assert a.keys() == b.keys()
+ assert a["email_status"] == b["email_status"] == "not_requested"
+
+
+@pytest.mark.asyncio
+async def test_links_are_capped_per_group(client):
+ owner = await _account(client, "cap_owner")
+ gid = await _group(client, owner)
+ for _ in range(invite_links.MAX_OUTSTANDING_PER_GROUP):
+ assert (await _link(client, owner, gid)).status_code == 201
+ assert (await _link(client, owner, gid)).status_code == 429
+ other = await _group(client, owner, name="another-group")
+ assert (await _link(client, owner, other)).status_code == 201
+
+
+@pytest.mark.asyncio
+async def test_an_open_group_needs_no_link(client):
+ owner = await _account(client, "open_owner")
+ gid = await _group(client, owner, visibility="public", join_policy="open")
+ assert (await _link(client, owner, gid)).status_code == 409
+
+
+@pytest.mark.asyncio
+async def test_a_lifetime_is_clamped_and_a_past_one_refused(client, db_session):
+ owner = await _account(client, "ttl_owner")
+ gid = await _group(client, owner)
+ r = await _link(client, owner, gid, expires_at=_expires(days=-1))
+ assert r.status_code == 422
+ r = await _link(client, owner, gid, expires_at=_expires(days=400))
+ expires = datetime.fromisoformat(r.json()["expires_at"])
+ assert expires <= datetime.now(UTC) + invite_links.MAX_LIFETIME
+
+
+@pytest.mark.asyncio
+async def test_a_used_link_cannot_be_cancelled_here(client):
+ owner = await _account(client, "used_owner")
+ invitee = await _account(client, "used_invitee", email="invitee@example.test")
+ gid = await _group(client, owner)
+ link = (await _link(client, owner, gid)).json()
+ await client.post("/v1/invite-links/redeem", json={"ticket": link["ticket"]},
+ headers=invitee["h"])
+ r = await client.delete(f"/v1/groups/{gid}/invite-links/{link['link_id']}",
+ headers=owner["h"])
+ assert r.status_code == 409
+ r = await client.get(f"/v1/groups/{gid}/invite-links", headers=owner["h"])
+ assert r.json()["links"][0]["redeemed_by"] == "used_invitee"
+
+
+# ── What the hub mails ───────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_the_mail_carries_the_hubs_own_link_and_nothing_chosen(client, sent):
+ owner = await _account(client, "mail_owner")
+ gid = await _group(client, owner)
+ r = await _link(client, owner, gid, send_email=True, node_pk=NODE_PK, code=CODE)
+ assert r.json()["email_status"] == "sent"
+ [msg] = sent
+ body = msg.get_content()
+ assert msg["To"] == "invitee@example.test"
+ expected = invite_links.invite_url(gid, r.json()["ticket"], NODE_PK, CODE)
+ assert expected in body and expected.startswith(mail_mod.hub_url() + "/#/invite?")
+ assert "family-photos" in msg["Subject"] and "mail_owner" in msg["Subject"]
+
+
+@pytest.mark.asyncio
+async def test_no_box_no_mail(client, sent):
+ owner = await _account(client, "nomail_owner")
+ gid = await _group(client, owner)
+ r = await _link(client, owner, gid, node_pk=NODE_PK, code=CODE)
+ assert r.json()["email_status"] == "not_requested" and sent == []
+
+
+@pytest.mark.asyncio
+async def test_a_mail_needs_a_well_formed_code_and_key(client, sent):
+ owner = await _account(client, "shape_owner")
+ gid = await _group(client, owner)
+ for bad in ({"node_pk": NODE_PK, "code": "https://elsewhere.example/"},
+ {"node_pk": "../../x", "code": CODE}, {}):
+ r = await _link(client, owner, gid, send_email=True, **bad)
+ assert r.status_code == 422, bad
+ assert sent == []
+
+
+@pytest.mark.asyncio
+async def test_link_mail_is_capped_per_sender_and_leaves_other_mail_alone(client, sent):
+ """
+ AV10's shape: a link reaches addresses the hub has no relationship with, so
+ the account asking is counted. The eleventh link of the day is still made
+ and shown — only the mail is refused — and nobody else's mail is touched.
+ """
+ from meshbay_hub import hub_settings
+ owner = await _account(client, "cap_mailer")
+ cap = hub_settings.mail_default("invite_link_daily_cap")
+ assert cap == 10
+ groups = [await _group(client, owner, name=f"g-{i}") for i in range(2)]
+ statuses = []
+ for i in range(cap + 1):
+ r = await _link(client, owner, groups[i // 10], email=f"friend{i}@example.test",
+ send_email=True, node_pk=NODE_PK, code=CODE)
+ assert r.status_code == 201, r.text
+ statuses.append(r.json()["email_status"])
+ assert statuses == ["sent"] * cap + ["refused"]
+
+ other = await _account(client, "cap_other")
+ gid = await _group(client, other, name="theirs")
+ r = await _link(client, other, gid, email="friend0b@example.test",
+ send_email=True, node_pk=NODE_PK, code=CODE)
+ assert r.json()["email_status"] == "sent"
+
+
+def test_invite_link_is_its_own_mail_purpose_and_not_a_recovery_one():
+ assert "invite_link" in mail_mod.ALLOWED_PURPOSES
+ assert "invite_link" not in mail_mod.RECOVERY_PURPOSES
+
+
+# ── The CLI's door ───────────────────────────────────────────────────────────
+
+async def _node_token(client, owner: dict) -> dict:
+ sk = Ed25519PrivateKey.generate()
+ pk = base64.b64encode(sk.public_key().public_bytes(
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw)).decode()
+ r = await client.put("/v1/users/me/node_key", json={"pk_node_ed25519": pk},
+ headers=owner["h"])
+ assert r.status_code == 200, r.text
+ ts = int(time.time())
+ r = await client.post("/v1/nodes/auth", json={
+ "username": owner["username"], "timestamp": ts,
+ "signature": base64.b64encode(
+ sk.sign(f"meshbay:node_auth:{owner['username']}:{ts}".encode())).decode()})
+ assert r.status_code == 200, r.text
+ return {"h": {"Authorization": f"Bearer {r.json()['access_token']}"}}
+
+
+@pytest.mark.asyncio
+async def test_a_node_may_issue_for_its_operator_but_never_mail(client, sent):
+ owner = await _account(client, "cli_owner")
+ gid = await _group(client, owner)
+ node = await _node_token(client, owner)
+ r = await _link(client, node, gid)
+ assert r.status_code == 201, r.text
+ r = await _link(client, node, gid, send_email=True, node_pk=NODE_PK, code=CODE)
+ assert r.status_code == 403 and sent == []
+
+ other = await _account(client, "cli_other")
+ theirs = await _group(client, other, name="not-yours")
+ assert (await _link(client, node, theirs)).status_code == 403
+ # Redeeming is a person's act, never a node's.
+ r = await client.post("/v1/invite-links/preview", json={"ticket": "x" * 22},
+ headers=node["h"])
+ assert r.status_code == 403