From 2cb2b7f483608b070599b27d6e3347f1b140fe6b Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 20 Jul 2026 22:38:31 +0000 Subject: [PATCH] feat(invites): 1 hour default TTL plus optional per-mint ttl_secs The 15 minute TTL killed invites before a human could hand the link and PIN to an agent operator: both of the maintainer's live-test invites expired unredeemed. Default is now 1 hour, and mint accepts an optional ttl_secs (60s to 24h) so a longer-lived invite is a deliberate choice at mint time rather than a code change. The PIN remains the security gate; expiry is hygiene. Tests: default lands at 1 hour, ttl_secs is honoured, over-cap is 422. --- tests/test_routes_project_invites.py | 36 +++++++++++++++++++++++++++ tinyagentos/projects/invite_store.py | 9 ++++--- tinyagentos/routes/project_invites.py | 5 ++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/tests/test_routes_project_invites.py b/tests/test_routes_project_invites.py index 60b67d280..e0e6d0bea 100644 --- a/tests/test_routes_project_invites.py +++ b/tests/test_routes_project_invites.py @@ -585,3 +585,39 @@ async def test_guide_markdown_contains_required_instructions(client, app, monkey # Timed-check instruction mentions the interval value. assert "1800" in guide + + +@pytest.mark.asyncio +async def test_mint_default_ttl_is_one_hour(client, app): + pid = await _create_project(client) + before = time.time() + resp = await client.post( + f"/api/projects/{pid}/invites", + json={"scopes": [], "approval_mode": "auto"}, + ) + assert resp.status_code == 200, resp.text + ttl = resp.json()["expires_ts"] - before + assert 3500 < ttl <= 3610 + + +@pytest.mark.asyncio +async def test_mint_honours_ttl_secs(client, app): + pid = await _create_project(client) + before = time.time() + resp = await client.post( + f"/api/projects/{pid}/invites", + json={"scopes": [], "approval_mode": "auto", "ttl_secs": 86400}, + ) + assert resp.status_code == 200, resp.text + ttl = resp.json()["expires_ts"] - before + assert 86300 < ttl <= 86410 + + +@pytest.mark.asyncio +async def test_mint_rejects_ttl_over_cap(client, app): + pid = await _create_project(client) + resp = await client.post( + f"/api/projects/{pid}/invites", + json={"scopes": [], "approval_mode": "auto", "ttl_secs": 999999}, + ) + assert resp.status_code == 422, resp.text diff --git a/tinyagentos/projects/invite_store.py b/tinyagentos/projects/invite_store.py index e0c0240df..ff9db0701 100644 --- a/tinyagentos/projects/invite_store.py +++ b/tinyagentos/projects/invite_store.py @@ -10,7 +10,7 @@ from tinyagentos.base_store import BaseStore -_EXPIRY_SECS = 15 * 60 +_EXPIRY_SECS = 60 * 60 _MAX_ATTEMPTS = 5 _PENDING_CAP = 10 @@ -131,7 +131,8 @@ async def mint(self, *, project_id=None, scopes: list[str], approval_mode: str, display_name: str | None = None, kind: str = "agent", pin_required: bool = True, - contact_id: str | None = None) -> dict: + contact_id: str | None = None, + ttl_secs: int | None = None) -> dict: if self._db is None: raise RuntimeError("ProjectInviteStore not initialised") @@ -180,7 +181,9 @@ async def mint(self, *, project_id=None, scopes: list[str], approval_mode: str, pin = self._generate_pin() pin_hash = hashlib.sha256(pin.encode()).hexdigest() now = _now() - expires_ts = now + _EXPIRY_SECS + if ttl_secs is not None and ttl_secs <= 0: + raise ValueError("ttl_secs must be positive") + expires_ts = now + (ttl_secs if ttl_secs is not None else _EXPIRY_SECS) # 6-digit invite IDs have a non-trivial collision chance under load # (~12 % at 500 pending). Retry on UNIQUE constraint violation so a diff --git a/tinyagentos/routes/project_invites.py b/tinyagentos/routes/project_invites.py index 6c5dfda1e..23af899c6 100644 --- a/tinyagentos/routes/project_invites.py +++ b/tinyagentos/routes/project_invites.py @@ -30,6 +30,10 @@ class MintInviteIn(BaseModel): scopes: list[str] = Field(default_factory=list) approval_mode: str = Field(default="auto", pattern="^(auto|manual)$") check_interval_secs: int = Field(default=1800, ge=60) + # How long the invite stays redeemable. Omitted means the store default + # (1 hour). Capped at 24 hours: the PIN is the security gate, expiry is + # hygiene, but an indefinitely live link is not a state we want. + ttl_secs: int | None = Field(default=None, ge=60, le=86400) class MintOsInviteIn(BaseModel): @@ -649,6 +653,7 @@ async def mint_invite( approval_mode=payload.approval_mode, check_interval_secs=payload.check_interval_secs, created_by=user.user_id, + ttl_secs=payload.ttl_secs, ) except InvitePendingCapError as exc: return JSONResponse({"error": str(exc)}, status_code=429)