aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/invite_links.py49
1 files changed, 23 insertions, 26 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
index f33dc6e..3c50e19 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
@@ -4,14 +4,13 @@ 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.
+which is all the hub has to give (§7.1) — and it gives it to one account: the
+first to redeem it. Both halves are therefore bearer secrets, so the link can
+travel through any messaging service; what bounds a leaked one is that it works
+once, for seven days, and that the owner can cancel it.
-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.
+The address is optional and binds nothing. When given, it is where the hub
+mails the link and a masked label in the owner's list.
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.
@@ -30,7 +29,6 @@ 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
@@ -40,9 +38,9 @@ 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)
+# The node issues a link's code for exactly this long; the ticket follows it,
+# and never outlives it.
+MAX_LIFETIME = timedelta(days=7)
# How long a spent link is kept before it is forgotten. The owner is not shown
# it — the person is in the group — but while the row is here, a reload or a
# second tab of the invitation page still answers the account that used it.
@@ -83,6 +81,8 @@ def _aware(when: datetime) -> datetime:
def _valid_email(v: str) -> str:
v = v.strip()
+ if not v:
+ return v
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)):
@@ -91,7 +91,8 @@ def _valid_email(v: str) -> str:
class CreateLinkRequest(BaseModel):
- email: str
+ # Optional: only where the hub mails the link, and a label for the owner.
+ 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:
@@ -149,6 +150,8 @@ async def create_invite_link(
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 not body.email:
+ raise HTTPException(status_code=422, detail="Mailing a link needs an address")
if payload.get("scope") == "node":
raise HTTPException(status_code=403,
detail="Invitation mail is sent from the interface only")
@@ -179,7 +182,7 @@ async def create_invite_link(
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),
+ email_masked=_mask(body.email) if body.email else None,
node_invite_id=body.node_invite_id, expires_at=expires)
db.add(row)
await db.flush()
@@ -225,7 +228,7 @@ async def list_invite_links(
now = datetime.now(UTC)
return {"links": [{
"link_id": r.id,
- "email": r.email_masked,
+ "email": r.email_masked or "",
"node_invite_id": r.node_invite_id,
"created_at": _aware(r.created_at).isoformat(),
"expires_at": _aware(r.expires_at).isoformat(),
@@ -257,14 +260,10 @@ async def delete_invite_link(
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.
+ The link this ticket names, if this account may use it: any account while
+ nobody has, and afterwards only the one that did. One uniform refusal for
+ everything else — unknown, spent by someone else, expired, a group no
+ longer active.
"""
invalid = HTTPException(status_code=404, detail="invite_not_valid")
if not _TICKET.match(ticket or ""):
@@ -280,8 +279,6 @@ async def _resolve(db: AsyncSession, ticket: str, user: User
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
@@ -312,8 +309,8 @@ async def redeem_invite_link(
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.
+ Membership for the first account that asks, 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: