From 8ebf38583053db3217ebb892bdd175d69dd83d6c Mon Sep 17 00:00:00 2001 From: owen Date: Sun, 3 May 2026 23:02:16 -0400 Subject: [PATCH 1/2] feat: linked-account Redis cache, user JWT, /link UX - linked_account_context: Turso lookup + Redis cache; attach on interactions. - raidhub_user_jwt: POST /authorize/user with RAIDHUB_CLIENT_SECRET for scoped API. - link command + manifest copy for Account linking; config/env/README updates. - Slash commands pass RaidHubClient with user JWT where applicable. Co-authored-by: Cursor --- .env.example | 18 ++ README.md | 10 ++ pyproject.toml | 2 + src/app_factory.py | 22 ++- src/commands/link.py | 44 +++++ src/commands/player_search.py | 14 +- src/commands/player_search_helpers.py | 5 +- src/commands/subscribe.py | 4 +- src/commands/subscription.py | 4 +- src/commands/subscription_helpers.py | 4 +- src/commands/subscription_routes.py | 12 +- src/commands/unsubscribe.py | 8 +- src/config.py | 30 ++++ src/linked_account_context.py | 230 ++++++++++++++++++++++++++ src/manifest/builders.py | 12 ++ src/raidhub_client.py | 15 +- src/raidhub_user_jwt.py | 120 ++++++++++++++ tests/test_manifest_commands.py | 9 + tests/test_raidhub_client_envelope.py | 2 +- tests/test_subscription_helpers.py | 6 +- 20 files changed, 547 insertions(+), 24 deletions(-) create mode 100644 src/commands/link.py create mode 100644 src/linked_account_context.py create mode 100644 src/raidhub_user_jwt.py diff --git a/.env.example b/.env.example index 6fab4f2..35871db 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,26 @@ DISCORD_SYNC_DRY_RUN=false RAIDHUB_API_BASE_URL=http://localhost:8000 # Same string as a "key" entry in RaidHub-API/api-keys.json (required when API runs with PROD=true). RAIDHUB_API_KEY= +# Same value as RaidHub-API CLIENT_SECRET — used to mint user JWTs for linked Discord users (POST /authorize/user). +# RAIDHUB_CLIENT_SECRET= +# Public site URL for /link and /register buttons. +# RAIDHUB_WEBSITE_BASE_URL=https://raidhub.io +# Redis TTL (seconds) for cached user JWTs (default 3600). +# RAIDHUB_USER_JWT_CACHE_TTL_SECONDS=3600 # Same value as RaidHub-API JWT_SECRET if you use signed Discord calls. RAIDHUB_JWT_SECRET= +# Same libsql URL as RaidHub-Website (NextAuth). Used to resolve Discord user → Bungie + Destiny ids on the proxy. +# RAIDHUB_ACCOUNT_TURSO_URL= +# Redis cache for that lookup (same vars as RaidHub-Services). Optional: if unset, every hit goes to Turso. +# REDIS_URL= +# REDIS_HOST=redis +# REDIS_PORT=6379 +# REDIS_PASSWORD= +# REDIS_DB=0 +# TTL (seconds) for linked-account keys in Redis. +# RAIDHUB_ACCOUNT_LOOKUP_CACHE_TTL_SECONDS=90 +# Bump to invalidate all cached Discord→account rows (key prefix includes this). +# RAIDHUB_DISCORD_LINKED_ACCOUNT_CACHE_NS=1 # Leave empty unless you intend to send events to your own Sentry project. SENTRY_DSN= SENTRY_ENVIRONMENT=development diff --git a/README.md b/README.md index d9762bf..9f8e6f6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,16 @@ Backend for the RaidHub Discord application: it receives [Discord interactions]( When the RaidHub API runs with production-style auth, configure the API key in `.env` to match what the API expects (see comments in `.env.example`). +## Linked-account cache (Redis) + +If `RAIDHUB_ACCOUNT_TURSO_URL` is set, Discord user → Bungie / Destiny profile resolution is cached in **Redis** using the same connection style as RaidHub-Services (`REDIS_HOST` / `REDIS_PORT` / optional `REDIS_PASSWORD`, or `REDIS_URL`). TTL defaults to 90s (`RAIDHUB_ACCOUNT_LOOKUP_CACHE_TTL_SECONDS`). + +**Bust cache:** increment `RAIDHUB_DISCORD_LINKED_ACCOUNT_CACHE_NS` (e.g. `1` → `2`), or delete keys under `raidhub:discord:linked::*` (prefer `SCAN` in production). If Redis is not configured, lookups always hit Turso. + +**User JWT (scoped API):** with `RAIDHUB_CLIENT_SECRET` set (same as RaidHub-API `CLIENT_SECRET`), linked users get a short-lived cached **user JWT** via `POST /authorize/user`; outbound calls send it as `x-raidhub-user-authorization: Bearer …` alongside `Authorization: Discord …` where used (e.g. `/search`). + +**Slash `/link` and `/register`:** ephemeral prompt with a button to `RAIDHUB_WEBSITE_BASE_URL/account` (default `https://raidhub.io`). + ## Slash command sync After changing command definitions, push them to Discord with the `sync-discord-commands` console script from this package. diff --git a/pyproject.toml b/pyproject.toml index ab0891a..c2ae986 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,14 @@ requires-python = ">=3.14" dependencies = [ "discord-interactions", "fastapi", + "libsql-client>=0.3.1", "uvicorn", "pynacl", "httpx", "python-dotenv", "PyJWT", "prometheus-client", + "redis>=5", "sentry-sdk[fastapi]", ] diff --git a/src/app_factory.py b/src/app_factory.py index e85470c..f4b0811 100644 --- a/src/app_factory.py +++ b/src/app_factory.py @@ -19,6 +19,11 @@ ) from .config import Settings, get_settings from .discord_auth import verify_discord_signature_with_reason +from .linked_account_context import ( + attach_raidhub_linked_account_context, + close_linked_account_redis, + init_linked_account_redis, +) from .log import ingress from .pagination import try_handle_pager_component from .prom_metrics import metrics_response, observe_interaction @@ -63,7 +68,11 @@ def _validate_startup_settings() -> None: @asynccontextmanager async def lifespan(_: FastAPI): _validate_startup_settings() - yield + await init_linked_account_redis(settings) + try: + yield + finally: + await close_linked_account_redis() app = FastAPI(title="raidhub-discord", lifespan=lifespan) @@ -171,8 +180,19 @@ async def discord_interactions( name = interaction.get("data", {}).get("name") ingress.info("DISCORD_COMMAND_RECEIVED", {"command_name": name}) + if name in ("link", "register"): + from .commands.link import link_interaction_response + + observe_interaction( + handler="link_or_register_immediate", + status="ok", + started_monotonic=t0, + ) + return JSONResponse(link_interaction_response(settings)) + handler = command_handlers.get(name) if handler: + await attach_raidhub_linked_account_context(interaction, settings) background_tasks.add_task(handler, interaction, raidhub, settings) handler_label = str(name).replace("-", "_") observe_interaction( diff --git a/src/commands/link.py b/src/commands/link.py new file mode 100644 index 0000000..d5ebb20 --- /dev/null +++ b/src/commands/link.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Any + +from discord_interactions import InteractionResponseType + +from ..config import Settings + + +def link_interaction_response(settings: Settings) -> dict[str, Any]: + """Ephemeral message + link button to RaidHub account (and optional docs).""" + base = settings.raidhub_website_base_url.rstrip("/") + account_url = f"{base}/account" + desc = ( + "1. Open **RaidHub account** below (Bungie sign-in if asked).\n" + "2. Under **Linked accounts**, connect **Discord** and approve **linked roles** scopes.\n" + "3. Return here — slash commands can then use your RaidHub identity (when the bot is configured with `RAIDHUB_CLIENT_SECRET`)." + ) + return { + "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + "data": { + "flags": 64, + "embeds": [ + { + "title": "Link RaidHub ↔ Discord", + "description": desc[:4096], + "color": 0x5865_F2, + } + ], + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Open RaidHub account", + "url": account_url[:512], + } + ], + } + ], + }, + } diff --git a/src/commands/player_search.py b/src/commands/player_search.py index c6affef..3c77314 100644 --- a/src/commands/player_search.py +++ b/src/commands/player_search.py @@ -6,6 +6,7 @@ from ..pagination import store_paged_session from ..prom_metrics import observe_deferred_completion from ..raidhub_client import RaidHubClient +from ..raidhub_user_jwt import resolve_user_bearer_token from .player_search_helpers import ( PLAYER_SEARCH_PAGE_SIZE, player_search_render_from_state, @@ -40,12 +41,21 @@ async def run_player_search_deferred( query_params: dict[str, Any] = {"query": query} page_size = PLAYER_SEARCH_PAGE_SIZE + user_bearer = await resolve_user_bearer_token(interaction, settings) session_id = store_paged_session( - {"query_params": query_params, "page_size": page_size} + { + "query_params": query_params, + "page_size": page_size, + "user_bearer": user_bearer, + } ) payload = await player_search_render_from_state( raidhub, - {"query_params": query_params, "page_size": page_size}, + { + "query_params": query_params, + "page_size": page_size, + "user_bearer": user_bearer, + }, session_id, "0", ) diff --git a/src/commands/player_search_helpers.py b/src/commands/player_search_helpers.py index f81853f..2835ac0 100644 --- a/src/commands/player_search_helpers.py +++ b/src/commands/player_search_helpers.py @@ -74,7 +74,10 @@ async def player_search_render_from_state( params: dict[str, Any] = {"query": query, "count": page_size, "offset": offset} - env = await raidhub.request_envelope("GET", "/player/search", params=params) + user_bearer = state.get("user_bearer") + ub = user_bearer if isinstance(user_bearer, str) and user_bearer.strip() else None + + env = await raidhub.request_envelope("GET", "/player/search", params=params, user_bearer=ub) if not env.get("success"): code = str(env.get("code", "Error")) return { diff --git a/src/commands/subscribe.py b/src/commands/subscribe.py index dc976de..f238e23 100644 --- a/src/commands/subscribe.py +++ b/src/commands/subscribe.py @@ -32,7 +32,7 @@ player_target_from_subscribe_leaf, subscription_envelope_error_message, ) -from .subscription_routes import SUB_ROUTE_PUT +from .subscription_routes import SUB_ROUTE_PUT, SUBSCRIPTION_WEBHOOKS_PATH from .shared import ( USER_FACING_GENERIC, application_id, @@ -188,7 +188,7 @@ async def run_subscribe_deferred( ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT) env = await raidhub.request_envelope( "PUT", - "/subscriptions/discord/webhooks", + SUBSCRIPTION_WEBHOOKS_PATH, json=body, discord_context=ctx, ) diff --git a/src/commands/subscription.py b/src/commands/subscription.py index bed4406..2f2af0a 100644 --- a/src/commands/subscription.py +++ b/src/commands/subscription.py @@ -13,7 +13,7 @@ format_subscription_status_embed, subscription_envelope_error_message, ) -from .subscription_routes import SUB_ROUTE_STATUS +from .subscription_routes import SUB_ROUTE_STATUS, SUBSCRIPTION_WEBHOOKS_PATH from .shared import ( USER_FACING_GENERIC, application_id, @@ -66,7 +66,7 @@ async def run_subscription_deferred( ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_STATUS) env = await raidhub.request_envelope( "GET", - "/subscriptions/discord/webhooks", + SUBSCRIPTION_WEBHOOKS_PATH, discord_context=ctx, ) diff --git a/src/commands/subscription_helpers.py b/src/commands/subscription_helpers.py index c73c146..7ea3bd1 100644 --- a/src/commands/subscription_helpers.py +++ b/src/commands/subscription_helpers.py @@ -6,7 +6,7 @@ from ..raidhub_client import RaidHubClient, discord_invocation_context from .subscribe_resolution import format_player_display_name -from .subscription_routes import SUB_ROUTE_STATUS +from .subscription_routes import SUB_ROUTE_STATUS, SUBSCRIPTION_WEBHOOKS_PATH from .shared import ( base_embed, discord_message_for_failed_envelope, @@ -217,7 +217,7 @@ async def fetch_subscription_status_envelope( ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_STATUS) return await raidhub.request_envelope( "GET", - "/subscriptions/discord/webhooks", + SUBSCRIPTION_WEBHOOKS_PATH, discord_context=ctx, ) diff --git a/src/commands/subscription_routes.py b/src/commands/subscription_routes.py index 990363e..8b3a902 100644 --- a/src/commands/subscription_routes.py +++ b/src/commands/subscription_routes.py @@ -1,3 +1,9 @@ -SUB_ROUTE_PUT = "PUT subscriptions/discord/webhooks" -SUB_ROUTE_DELETE = "DELETE subscriptions/discord/webhooks" -SUB_ROUTE_STATUS = "GET subscriptions/discord/webhooks" +"""RaidHub API routes for Discord subscription webhooks (under ``/internal``).""" + +# HTTP path (leading slash), passed to ``RaidHubClient.request(..., path=...)``. +SUBSCRIPTION_WEBHOOKS_PATH = "/internal/subscriptions/discord/webhooks" + +# route_id for Discord invocation JWT (METHOD + path without leading slash). +SUB_ROUTE_PUT = "PUT internal/subscriptions/discord/webhooks" +SUB_ROUTE_DELETE = "DELETE internal/subscriptions/discord/webhooks" +SUB_ROUTE_STATUS = "GET internal/subscriptions/discord/webhooks" diff --git a/src/commands/unsubscribe.py b/src/commands/unsubscribe.py index cf6f18b..b2c0a6c 100644 --- a/src/commands/unsubscribe.py +++ b/src/commands/unsubscribe.py @@ -33,7 +33,7 @@ subscription_active_player_ids, subscription_envelope_error_message, ) -from .subscription_routes import SUB_ROUTE_DELETE, SUB_ROUTE_PUT +from .subscription_routes import SUB_ROUTE_DELETE, SUB_ROUTE_PUT, SUBSCRIPTION_WEBHOOKS_PATH from .shared import ( USER_FACING_GENERIC, application_id, @@ -99,7 +99,7 @@ async def run_unsubscribe_deferred( ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_DELETE) env = await raidhub.request_envelope( "DELETE", - "/subscriptions/discord/webhooks", + SUBSCRIPTION_WEBHOOKS_PATH, discord_context=ctx, ) if not env.get("success"): @@ -235,7 +235,7 @@ async def run_unsubscribe_player_deferred( ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT) env = await raidhub.request_envelope( "PUT", - "/subscriptions/discord/webhooks", + SUBSCRIPTION_WEBHOOKS_PATH, json=body, discord_context=ctx, ) @@ -369,7 +369,7 @@ async def run_unsubscribe_clan_deferred( ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT) env = await raidhub.request_envelope( "PUT", - "/subscriptions/discord/webhooks", + SUBSCRIPTION_WEBHOOKS_PATH, json=body, discord_context=ctx, ) diff --git a/src/config.py b/src/config.py index 26e2ff1..5fa9db7 100644 --- a/src/config.py +++ b/src/config.py @@ -18,7 +18,18 @@ class Settings: discord_sync_dry_run: bool raidhub_api_base_url: str raidhub_api_key: str + raidhub_client_secret: str + raidhub_website_base_url: str + raidhub_user_jwt_cache_ttl_seconds: int raidhub_jwt_secret: str + raidhub_account_turso_url: str + raidhub_account_lookup_cache_ttl_seconds: int + raidhub_discord_linked_account_cache_ns: str + redis_url: str + redis_host: str + redis_port: int + redis_password: str + redis_db: int sentry_dsn: str sentry_environment: str sentry_release: str @@ -39,7 +50,26 @@ def get_settings() -> Settings: "RAIDHUB_API_BASE_URL", "http://localhost:8000" ).strip(), raidhub_api_key=os.getenv("RAIDHUB_API_KEY", "").strip(), + raidhub_client_secret=os.getenv("RAIDHUB_CLIENT_SECRET", "").strip(), + raidhub_website_base_url=os.getenv( + "RAIDHUB_WEBSITE_BASE_URL", "https://raidhub.io" + ).strip(), + raidhub_user_jwt_cache_ttl_seconds=int( + os.getenv("RAIDHUB_USER_JWT_CACHE_TTL_SECONDS", "3600").strip() or "3600" + ), raidhub_jwt_secret=os.getenv("RAIDHUB_JWT_SECRET", "").strip(), + raidhub_account_turso_url=os.getenv("RAIDHUB_ACCOUNT_TURSO_URL", "").strip(), + raidhub_account_lookup_cache_ttl_seconds=int( + os.getenv("RAIDHUB_ACCOUNT_LOOKUP_CACHE_TTL_SECONDS", "90").strip() or "90" + ), + raidhub_discord_linked_account_cache_ns=os.getenv( + "RAIDHUB_DISCORD_LINKED_ACCOUNT_CACHE_NS", "1" + ).strip(), + redis_url=os.getenv("REDIS_URL", "").strip(), + redis_host=os.getenv("REDIS_HOST", "").strip(), + redis_port=int(os.getenv("REDIS_PORT", "6379").strip() or "6379"), + redis_password=os.getenv("REDIS_PASSWORD", "").strip(), + redis_db=int(os.getenv("REDIS_DB", "0").strip() or "0"), sentry_dsn=os.getenv("SENTRY_DSN", "").strip(), sentry_environment=os.getenv("SENTRY_ENVIRONMENT", "development").strip(), sentry_release=os.getenv("SENTRY_RELEASE", "").strip(), diff --git a/src/linked_account_context.py b/src/linked_account_context.py new file mode 100644 index 0000000..7aa6c89 --- /dev/null +++ b/src/linked_account_context.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +from dataclasses import dataclass +from typing import Any + +import redis.asyncio as aioredis +from libsql_client import create_client_sync + +from .config import Settings +from .log import ingress + +RAIDHUB_REQUEST_KEY = "_raidhubRequest" + +_redis: aioredis.Redis | None = None + + +def get_linked_account_redis() -> aioredis.Redis | None: + """Shared Redis client (linked-account cache + user JWT cache).""" + return _redis + + +def _redis_key(settings: Settings, turso_url: str, discord_user_id: str) -> str: + """Namespace + Turso fingerprint so cache bust / env changes stay isolated.""" + url_fp = hashlib.sha256(turso_url.encode("utf-8")).hexdigest()[:12] + ns = settings.raidhub_discord_linked_account_cache_ns.strip() or "1" + return f"raidhub:discord:linked:{ns}:{url_fp}:{discord_user_id}" + + +def _redis_configured(settings: Settings) -> bool: + return bool(settings.redis_url.strip()) or bool(settings.redis_host.strip()) + + +async def init_linked_account_redis(settings: Settings) -> None: + global _redis + if not _redis_configured(settings): + ingress.info("REDIS_LINKED_ACCOUNT_CACHE_DISABLED", {"reason": "no_redis_host_or_url"}) + return + try: + if settings.redis_url.strip(): + client = aioredis.from_url( + settings.redis_url.strip(), + decode_responses=True, + socket_connect_timeout=5.0, + ) + else: + client = aioredis.Redis( + host=settings.redis_host.strip(), + port=settings.redis_port, + password=settings.redis_password.strip() or None, + db=settings.redis_db, + decode_responses=True, + socket_connect_timeout=5.0, + ) + await client.ping() + _redis = client + ingress.info("REDIS_LINKED_ACCOUNT_CACHE_READY", {}) + except Exception as err: + _redis = None + ingress.warn( + "REDIS_LINKED_ACCOUNT_CACHE_INIT_FAILED", + err, + {"host": settings.redis_host, "has_url": bool(settings.redis_url.strip())}, + ) + + +async def close_linked_account_redis() -> None: + global _redis + if _redis is None: + return + try: + await _redis.aclose() + except Exception as err: + ingress.warn("REDIS_LINKED_ACCOUNT_CACHE_CLOSE_FAILED", err, None) + finally: + _redis = None + + +@dataclass(frozen=True, slots=True) +class _LinkedSnapshot: + bungie_membership_id: str | None + destiny_membership_ids: tuple[str, ...] + + +def discord_user_snowflake(interaction: dict[str, Any]) -> str: + member = interaction.get("member") or {} + user = member.get("user") if isinstance(member, dict) else None + if not user: + user = interaction.get("user") or {} + return str((user or {}).get("id") or "").strip() + + +def _lookup_linked_sync(turso_url: str, discord_user_id: str) -> _LinkedSnapshot: + client = create_client_sync(turso_url) + try: + rs = client.execute( + """ + SELECT a.bungie_membership_id AS mid, dp.destiny_membership_id AS did + FROM account AS a + LEFT JOIN destiny_profile AS dp + ON dp.bungie_membership_id = a.bungie_membership_id + WHERE a.provider = 'discord' AND a.provider_account_id = ? + """, + [discord_user_id], + ) + finally: + client.close() + + rows = list(rs.rows) + if not rows: + return _LinkedSnapshot(bungie_membership_id=None, destiny_membership_ids=()) + + mids = {str(r[0]).strip() for r in rows if r[0] is not None and str(r[0]).strip()} + bungie = next(iter(mids), None) if mids else None + + destiny: set[str] = set() + for r in rows: + if r[1] is None: + continue + s = str(r[1]).strip() + if s: + destiny.add(s) + + return _LinkedSnapshot( + bungie_membership_id=bungie, + destiny_membership_ids=tuple(sorted(destiny)), + ) + + +def _snapshot_to_json(snap: _LinkedSnapshot) -> str: + return json.dumps( + { + "bungieMembershipId": snap.bungie_membership_id, + "destinyMembershipIds": list(snap.destiny_membership_ids), + }, + separators=(",", ":"), + ) + + +def _snapshot_from_json(raw: str) -> _LinkedSnapshot | None: + try: + obj = json.loads(raw) + if not isinstance(obj, dict): + return None + mid = obj.get("bungieMembershipId") + dids = obj.get("destinyMembershipIds") + bungie = str(mid).strip() if mid is not None else None + bungie = bungie or None + if not isinstance(dids, list): + dids = [] + out = tuple(sorted({str(x).strip() for x in dids if str(x).strip()})) + return _LinkedSnapshot(bungie_membership_id=bungie, destiny_membership_ids=out) + except (json.JSONDecodeError, TypeError, ValueError): + return None + + +async def attach_raidhub_linked_account_context( + interaction: dict[str, Any], settings: Settings +) -> None: + """ + Proxy-only: resolve Discord → RaidHub user from Turso (Website NextAuth DB). + + Mutates ``interaction[RAIDHUB_REQUEST_KEY]`` with: + - ``discordUserId``: str | None + - ``bungieMembershipId``: str | None + - ``destinyMembershipIds``: list[str] + + When Redis is configured (same deployment style as RaidHub-Services: ``REDIS_HOST`` / + ``REDIS_PORT`` / optional ``REDIS_PASSWORD`` or ``REDIS_URL``), results are cached with TTL. + + **Cache bust:** bump env ``RAIDHUB_DISCORD_LINKED_ACCOUNT_CACHE_NS`` (e.g. ``1`` → ``2``), or + ``DEL`` keys matching ``raidhub:discord:linked::*`` (use ``SCAN`` in production). + """ + uid = discord_user_snowflake(interaction) + url = settings.raidhub_account_turso_url.strip() + ttl = max(5, settings.raidhub_account_lookup_cache_ttl_seconds) + + ctx: dict[str, Any] = { + "discordUserId": uid or None, + "bungieMembershipId": None, + "destinyMembershipIds": [], + } + interaction[RAIDHUB_REQUEST_KEY] = ctx + + if not url or not uid: + return + + rkey = _redis_key(settings, url, uid) + client = _redis + + if client is not None: + try: + cached = await client.get(rkey) + if cached is not None: + snap = _snapshot_from_json(cached) + if snap is not None: + ctx["bungieMembershipId"] = snap.bungie_membership_id + ctx["destinyMembershipIds"] = list(snap.destiny_membership_ids) + return + except Exception as err: + ingress.warn( + "REDIS_LINKED_ACCOUNT_CACHE_GET_FAILED", + err, + {"redis_key": rkey}, + ) + + try: + snap = await asyncio.to_thread(_lookup_linked_sync, url, uid) + except Exception as err: + ingress.warn( + "RAIDHUB_ACCOUNT_TURSO_LOOKUP_FAILED", + err, + {"discord_user_id": uid}, + ) + return + + ctx["bungieMembershipId"] = snap.bungie_membership_id + ctx["destinyMembershipIds"] = list(snap.destiny_membership_ids) + + if client is not None: + try: + await client.set(rkey, _snapshot_to_json(snap), ex=ttl) + except Exception as err: + ingress.warn( + "REDIS_LINKED_ACCOUNT_CACHE_SET_FAILED", + err, + {"redis_key": rkey}, + ) diff --git a/src/manifest/builders.py b/src/manifest/builders.py index e401fc1..b525d66 100644 --- a/src/manifest/builders.py +++ b/src/manifest/builders.py @@ -12,6 +12,18 @@ def build_commands( raid_filter_choices: list[tuple[str, int]] | None = None, ) -> list[CommandDto]: return [ + CommandDto( + name="link", + description="Open RaidHub to link your Bungie account with Discord and linked roles.", + dm_permission=True, + options=[], + ), + CommandDto( + name="register", + description="Same as /link — open RaidHub to connect Discord for linked roles.", + dm_permission=True, + options=[], + ), CommandDto( name="search", description="Search RaidHub players by Bungie name or platform name.", diff --git a/src/raidhub_client.py b/src/raidhub_client.py index da1e6e7..93c7e0f 100644 --- a/src/raidhub_client.py +++ b/src/raidhub_client.py @@ -57,7 +57,12 @@ def _sign_discord_jwt(self, discord_context: dict[str, Any]) -> str: } return jwt.encode(payload, self._jwt_secret, algorithm="HS256") - def _headers(self, discord_context: dict[str, Any] | None = None) -> dict[str, str]: + def _headers( + self, + discord_context: dict[str, Any] | None = None, + *, + user_bearer: str | None = None, + ) -> dict[str, str]: headers: dict[str, str] = {} if self._api_key: headers["x-api-key"] = self._api_key @@ -65,6 +70,8 @@ def _headers(self, discord_context: dict[str, Any] | None = None) -> dict[str, s headers["authorization"] = ( f"{DISCORD_AUTH_SCHEME} {self._sign_discord_jwt(discord_context)}" ) + if user_bearer: + headers["x-raidhub-user-authorization"] = f"Bearer {user_bearer}" return headers async def request( @@ -74,8 +81,9 @@ async def request( *, params: dict[str, Any] | None = None, discord_context: dict[str, Any] | None = None, + user_bearer: str | None = None, ) -> dict[str, Any]: - headers = self._headers(discord_context) + headers = self._headers(discord_context, user_bearer=user_bearer) async with httpx.AsyncClient(base_url=self._base_url, timeout=15) as client: response = await client.request(method, path, params=params, headers=headers) response.raise_for_status() @@ -89,9 +97,10 @@ async def request_envelope( params: dict[str, Any] | None = None, json: dict[str, Any] | list[Any] | None = None, discord_context: dict[str, Any] | None = None, + user_bearer: str | None = None, ) -> dict[str, Any]: """Normalize RaidHub JSON envelopes and HTTP status without raising on transport/HTTP errors.""" - headers = self._headers(discord_context) + headers = self._headers(discord_context, user_bearer=user_bearer) try: async with httpx.AsyncClient(base_url=self._base_url, timeout=30) as client: response = await client.request( diff --git a/src/raidhub_user_jwt.py b/src/raidhub_user_jwt.py new file mode 100644 index 0000000..9edfe65 --- /dev/null +++ b/src/raidhub_user_jwt.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any + +import httpx + +from .config import Settings +from .linked_account_context import RAIDHUB_REQUEST_KEY, get_linked_account_redis +from .log import raidhub_api + +_USER_JWT_KEY_PREFIX = "raidhub:discord:user_jwt" + + +def _destiny_ids_fingerprint(destiny_membership_ids: list[str]) -> str: + normalized = sorted({str(x).strip() for x in destiny_membership_ids if str(x).strip()}) + raw = json.dumps(normalized, separators=(",", ":")) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + + +def _user_jwt_cache_key(settings: Settings, bungie_membership_id: str, destiny_ids: list[str]) -> str: + ns = settings.raidhub_discord_linked_account_cache_ns.strip() or "1" + fp = _destiny_ids_fingerprint(destiny_ids) + return f"{_USER_JWT_KEY_PREFIX}:{ns}:{bungie_membership_id}:{fp}" + + +def _parse_expires_seconds(expires_iso: str) -> int | None: + s = expires_iso.strip() + if not s: + return None + if s.endswith("Z"): + s = s[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = (dt - datetime.now(tz=timezone.utc)).total_seconds() + return int(delta) if delta > 120 else None + except ValueError: + return None + + +async def resolve_user_bearer_token(interaction: dict[str, Any], settings: Settings) -> str | None: + """ + Mint (or reuse from Redis) a RaidHub user JWT via ``POST /authorize/user``, same as the Website. + + Requires ``RAIDHUB_CLIENT_SECRET`` and a linked Turso profile (``_raidhubRequest`` filled by + ``attach_raidhub_linked_account_context``). Sends JWT to the API as ``x-raidhub-user-authorization`` + so ``Authorization: Discord …`` can coexist on the same request. + """ + secret = settings.raidhub_client_secret.strip() + if not secret: + return None + + ctx = interaction.get(RAIDHUB_REQUEST_KEY) or {} + bungie = ctx.get("bungieMembershipId") + dest = ctx.get("destinyMembershipIds") or [] + if not bungie or not isinstance(dest, list) or not dest: + return None + + bungie_str = str(bungie).strip() + dest_strs = [str(x).strip() for x in dest if str(x).strip()] + if not bungie_str or not dest_strs: + return None + + cache_key = _user_jwt_cache_key(settings, bungie_str, dest_strs) + r = get_linked_account_redis() + if r is not None: + try: + cached = await r.get(cache_key) + if cached: + return str(cached).strip() or None + except Exception as err: + raidhub_api.warn("REDIS_USER_JWT_CACHE_GET_FAILED", err, {"key": cache_key}) + + base = settings.raidhub_api_base_url.rstrip("/") + body = { + "bungieMembershipId": bungie_str, + "destinyMembershipIds": dest_strs, + "clientSecret": secret, + } + try: + async with httpx.AsyncClient(base_url=base, timeout=20) as client: + res = await client.post("/authorize/user", json=body) + except httpx.RequestError as err: + raidhub_api.warn("RAIDHUB_AUTHORIZE_USER_FAILED", err, {"bungie": bungie_str}) + return None + + if not res.is_success: + raidhub_api.warn( + "RAIDHUB_AUTHORIZE_USER_HTTP", + None, + {"status": res.status_code, "bungie": bungie_str, "body": res.text[:300]}, + ) + return None + + try: + data = res.json() + except Exception: + return None + + token = str(data.get("value") or "").strip() + if not token: + return None + + ex = settings.raidhub_user_jwt_cache_ttl_seconds + if isinstance(data.get("expires"), str): + parsed = _parse_expires_seconds(data["expires"]) + if parsed is not None: + ex = min(ex, parsed) + + if r is not None: + try: + await r.set(cache_key, token, ex=max(60, int(ex))) + except Exception as err: + raidhub_api.warn("REDIS_USER_JWT_CACHE_SET_FAILED", err, {"key": cache_key}) + + return token diff --git a/tests/test_manifest_commands.py b/tests/test_manifest_commands.py index 00e8d9e..16af6b1 100644 --- a/tests/test_manifest_commands.py +++ b/tests/test_manifest_commands.py @@ -13,6 +13,8 @@ def test_build_commands_has_stable_slash_names(self) -> None: self.assertEqual( names, { + "link", + "register", "search", "subscribe", "subscriptions", @@ -31,6 +33,11 @@ def test_subscribe_dm_permission_false(self) -> None: cmds = {c.name: c for c in build_commands()} self.assertIs(cmds["subscribe"].dm_permission, False) + def test_link_and_register_allow_dm(self) -> None: + cmds = {c.name: c for c in build_commands()} + self.assertIs(cmds["link"].dm_permission, True) + self.assertIs(cmds["register"].dm_permission, True) + def test_subscribe_subcommands_expose_rule_filter_options(self) -> None: cmds = {c.name: c for c in build_commands()} subscribe = cmds["subscribe"] @@ -79,6 +86,8 @@ def test_subscription_slash_commands_require_manage_webhooks(self) -> None: msg=name, ) self.assertNotIn("default_member_permissions", by_name["search"]) + self.assertNotIn("default_member_permissions", by_name["link"]) + self.assertNotIn("default_member_permissions", by_name["register"]) if __name__ == "__main__": diff --git a/tests/test_raidhub_client_envelope.py b/tests/test_raidhub_client_envelope.py index 7834068..f43d668 100644 --- a/tests/test_raidhub_client_envelope.py +++ b/tests/test_raidhub_client_envelope.py @@ -47,7 +47,7 @@ def test_client_error_plain(self) -> None: out = normalize_envelope_response( base_url="http://api", method="GET", - path="/subscriptions/discord/webhooks", + path="/internal/subscriptions/discord/webhooks", status=404, response_text="Cannot GET", data=None, diff --git a/tests/test_subscription_helpers.py b/tests/test_subscription_helpers.py index 7da2be1..04ae101 100644 --- a/tests/test_subscription_helpers.py +++ b/tests/test_subscription_helpers.py @@ -14,9 +14,9 @@ class SubscriptionRoutesTests(unittest.TestCase): def test_route_ids_match_expected_api_shape(self) -> None: - self.assertIn("subscriptions/discord/webhooks", SUB_ROUTE_PUT) - self.assertIn("subscriptions/discord/webhooks", SUB_ROUTE_DELETE) - self.assertIn("subscriptions/discord/webhooks", SUB_ROUTE_STATUS) + self.assertIn("internal/subscriptions/discord/webhooks", SUB_ROUTE_PUT) + self.assertIn("internal/subscriptions/discord/webhooks", SUB_ROUTE_DELETE) + self.assertIn("internal/subscriptions/discord/webhooks", SUB_ROUTE_STATUS) class BuildSubscriptionJsonBodyTests(unittest.TestCase): From 77129aec9a3b0fbf6d4e08bf3306e26bbb88356b Mon Sep 17 00:00:00 2001 From: owen Date: Sun, 3 May 2026 23:08:22 -0400 Subject: [PATCH 2/2] fix(ci): mypy await on redis ping (cast to Awaitable) Co-authored-by: Cursor --- src/linked_account_context.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/linked_account_context.py b/src/linked_account_context.py index 7aa6c89..e0ce3a7 100644 --- a/src/linked_account_context.py +++ b/src/linked_account_context.py @@ -4,7 +4,7 @@ import hashlib import json from dataclasses import dataclass -from typing import Any +from typing import Any, Awaitable, cast import redis.asyncio as aioredis from libsql_client import create_client_sync @@ -54,7 +54,8 @@ async def init_linked_account_redis(settings: Settings) -> None: decode_responses=True, socket_connect_timeout=5.0, ) - await client.ping() + # redis stubs union bool | Awaitable[bool] for ping(); runtime is always awaitable for asyncio client. + await cast(Awaitable[bool], client.ping()) _redis = client ingress.info("REDIS_LINKED_ACCOUNT_CACHE_READY", {}) except Exception as err: