From f12a848ebd014e85a04722446b0af5a0862ebabb Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Sat, 24 Oct 2026 22:12:26 +0900 Subject: [PATCH 01/23] feat: add Google sign-in for the Android client Mirror the Apple login flow: a one-time server nonce challenge, Google ID token verification against Google's JWKS (both issuer spellings, audience = Web OAuth client ID), and users.google_subject. GOOGLE_CLIENT_ID left empty keeps both endpoints at 503. Migration 0011 adds the column and the google_login_challenges table. --- HANDOFF.md | 13 +- backend/.env.example | 2 + backend/alembic/versions/0011_google_login.py | 27 +++ backend/app/auth_router.py | 91 ++++++++ backend/app/config.py | 4 + backend/app/container.py | 2 + backend/app/main.py | 1 + backend/app/models.py | 12 ++ backend/app/schemas.py | 13 ++ backend/app/services/google_auth.py | 119 +++++++++++ backend/deploy/production.env.example | 1 + backend/docker-compose.local.yml | 1 + backend/docker-compose.yml | 1 + backend/tests/test_google_identity.py | 201 ++++++++++++++++++ backend/tests/test_google_login.py | 182 ++++++++++++++++ 15 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0011_google_login.py create mode 100644 backend/app/services/google_auth.py create mode 100644 backend/tests/test_google_identity.py create mode 100644 backend/tests/test_google_login.py diff --git a/HANDOFF.md b/HANDOFF.md index dba9e60..4b10fac 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,6 +1,6 @@ # 콜록(Collog) 개발 HANDOFF -마지막 갱신: 2026-08-18 (Asia/Seoul) +마지막 갱신: 2026-10-24 (Asia/Seoul) 이 문서는 콜록 개발의 단일 인수인계 기준이다. 구현, 계약, 검증 결과, 미완료 항목이 바뀌면 코드와 같은 커밋에서 반드시 이 문서를 갱신한다. 비밀키와 실제 건강정보는 기록하지 않는다. @@ -132,6 +132,8 @@ STT/LLM/음향 파이프라인이 끝까지 도는 것을 확인했다. 다만 - iOS PushKit용 APNs HTTP/2 provider와 `/devices` idempotent 등록 - PushKit → CallKit → `/accept` → LiveKit iOS 계약 문서 - `/team` 모바일 웹 포털과 `/team/status.json` 비밀값 없는 연동 상태 endpoint +- Android용 Google 로그인 `/v1/auth/google/challenge`·`/v1/auth/google`. Apple 로그인과 같은 + 서버 nonce challenge 1회 소비 구조이며 `users.google_subject`로 계정을 찾는다 - 연결 대기 질문 ElevenLabs provider, MP3 cache, local/S3 만료 download URL과 iOS 로컬 폴백 - ElevenLabs voice 목록/한국어 MP3 preview CLI. provider key는 backend에만 보관 - APNs 자격증명 점검·실기기 발송 CLI와 sandbox 실기기 CallKit 수신 검증 @@ -160,6 +162,11 @@ STT/LLM/음향 파이프라인이 끝까지 도는 것을 확인했다. 다만 ### 의도적으로 미완료 +- **Google 로그인은 `GOOGLE_CLIENT_ID`를 설정해야 켜진다.** 값이 비면 두 엔드포인트가 503이다. + Google Cloud에 Web OAuth 클라이언트(이 값)와 Android OAuth 클라이언트(패키지 이름·서명 + SHA-1)를 같은 프로젝트에 만들어야 한다. 같은 사람이 iOS(Apple)와 Android(Google)로 + 가입하면 계정이 따로 생기며 병합하지 않는다. Google 계정 삭제는 OTP 계정처럼 재인증 없이 + 진행한다. Android 앱의 수신 통화(FCM)는 아직 서버에 없다. - **새 피그마와 현재 백엔드의 건강 주체 모델이 다르다.** 현재 API는 자녀 발신·부모 분석, 부모 질환 코드 배열에 고정돼 있다. 부모/자녀 양방향 통화와 양 참여자 동시 분석, 복용약·걱정·출처·본인 확인, Q/A 관찰 event와 리포트 v2는 설계만 완료됐다. @@ -296,6 +303,7 @@ APNs payload에는 `callId/callUUID/callerId/callerName/expiresAt`만 넣는다. | 파일 | 역할 | |---|---| | `services/livekit.py` | self-host LiveKit room/token/audio-track 조회/Track Egress/webhook adapter와 mock | +| `services/google_auth.py` | Google ID 토큰 JWKS 검증(iss 두 값, aud=Web client ID, nonce), 키 cache·실패 backoff | | `services/notifications.py` | APNs ES256 provider JWT와 HTTP/2 VoIP push, mock/disabled adapter | | `services/storage.py` | 로컬/S3 저장, presigned upload/download, TTS write/cache/read/delete | | `services/tts.py` | ElevenLabs 한국어 질문 합성·cache key·만료 URL과 질문별 iOS 폴백 | @@ -553,6 +561,7 @@ Compose는 ignored `backend/private/`를 `/run/secrets/collog`에 read-only moun | `HF_TOKEN` | Hugging Face 계정에서 HAI-DEF 약관 수락 후 발급 | HeAR detector artifact를 처음 받을 때만 필요 | gated model download. 앱/런타임 API key가 아니며 Git에 넣지 않음 | | `LIVEKIT_API_KEY/SECRET` | 우리가 직접 강한 난수로 생성 | 필수 | self-hosted room token, server API, Egress, webhook 서명 | | `JWT_SECRET` | 우리가 직접 강한 난수로 생성 | 필수 | 콜록 사용자 인증 JWT | +| `GOOGLE_CLIENT_ID` | Google Cloud Console의 Web OAuth 클라이언트 ID | Android Google 로그인 시 필수 | ID 토큰 `aud` 검증. 비밀값 아님 | | APNs `.p8`/Key ID/Team ID/Bundle ID | Apple Developer 발급·확인 | 실기기 백그라운드 수신 시 필수 | PushKit VoIP push | | MinIO access key/secret | 우리가 직접 생성 | Egress 녹음 시 필수 | self-hosted S3 호환 오디오 저장 | @@ -916,3 +925,5 @@ API를 바꿀 때에는 기존 camelCase 계약과 테스트를 유지하고, - 2026-08-20: 통합 직후 main이 lint 실패 상태였던 것을 고쳤다. `pipeline.py`의 로그 한 줄이 E501, `seed_narrative_history.py`가 async 안에서 `pathlib`을 써 ASYNC240이었다. 파일 읽기를 동기 함수로 분리했다. 브랜치를 머지할 때 `ruff`를 다시 돌려야 한다. +- 2026-10-24: Android 포팅을 위해 Google 로그인 엔드포인트, `google_subject` 컬럼과 + `google_login_challenges` 테이블(migration 0011), 검증기·라우트 테스트 40개를 추가했다. diff --git a/backend/.env.example b/backend/.env.example index cecc297..928fbcb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,8 @@ APPLE_CLIENT_ID=com.dohyeoplim.collog-ios APPLE_TEAM_ID= APPLE_KEY_ID= APPLE_PRIVATE_KEY_PATH= +# Android Sign in with Google: Web OAuth client ID (ID token aud). Empty disables it. +GOOGLE_CLIENT_ID= SOLAPI_API_KEY= SOLAPI_API_SECRET= SOLAPI_SENDER= diff --git a/backend/alembic/versions/0011_google_login.py b/backend/alembic/versions/0011_google_login.py new file mode 100644 index 0000000..844c12c --- /dev/null +++ b/backend/alembic/versions/0011_google_login.py @@ -0,0 +1,27 @@ +import sqlalchemy as sa +from alembic import op + +revision = "0011_google_login" +down_revision = "0010_processing_retries" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("users") as batch: + batch.add_column(sa.Column("google_subject", sa.String(255), nullable=True)) + batch.create_index("ix_users_google_subject", ["google_subject"], unique=True) + op.create_table( + "google_login_challenges", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("nonce_hash", sa.String(64), unique=True, nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index( + "ix_google_login_challenges_expires_at", "google_login_challenges", ["expires_at"] + ) + + +def downgrade() -> None: + raise RuntimeError("Restore a verified backup instead of a destructive downgrade") diff --git a/backend/app/auth_router.py b/backend/app/auth_router.py index ae61ef6..272b89d 100644 --- a/backend/app/auth_router.py +++ b/backend/app/auth_router.py @@ -14,6 +14,7 @@ AppleLoginChallenge, Device, Family, + GoogleLoginChallenge, OtpChallenge, RefreshSession, User, @@ -22,6 +23,8 @@ from app.schemas import ( AppleChallengeResponse, AppleLoginRequest, + GoogleChallengeResponse, + GoogleLoginRequest, OtpRequest, OtpVerify, RefreshRequest, @@ -31,6 +34,7 @@ from app.security import SessionDep, issue_token, otp_hash from app.services.apple_auth import AppleIdentityError, AppleServiceError from app.services.domain import family_of +from app.services.google_auth import GoogleIdentityError, GoogleServiceError from app.services.sms import SmsDeliveryError, send_otp router = APIRouter(prefix="/auth", tags=["Auth"]) @@ -162,6 +166,93 @@ async def apple_login( return await create_session(session, user, settings) +def require_google_login(settings: Settings) -> None: + if not settings.google_client_id: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, "Google 로그인을 사용할 수 없습니다" + ) + + +@router.post("/google/challenge", response_model=GoogleChallengeResponse) +async def google_challenge(request: Request, session: SessionDep) -> GoogleChallengeResponse: + settings: Settings = request.app.state.container.settings + require_google_login(settings) + now = datetime.now(UTC) + await session.execute( + delete(GoogleLoginChallenge).where(GoogleLoginChallenge.expires_at < now) + ) + nonce = secrets.token_urlsafe(32) + challenge = GoogleLoginChallenge( + nonce_hash=hashlib.sha256(nonce.encode()).hexdigest(), + expires_at=now + timedelta(seconds=settings.apple_challenge_ttl_seconds), + ) + session.add(challenge) + await session.commit() + return GoogleChallengeResponse( + challenge_id=challenge.id, nonce=nonce, expires_in=settings.apple_challenge_ttl_seconds + ) + + +@router.post("/google", response_model=TokenResponse) +async def google_login( + payload: GoogleLoginRequest, request: Request, session: SessionDep +) -> TokenResponse: + settings: Settings = request.app.state.container.settings + require_google_login(settings) + challenge = await session.get(GoogleLoginChallenge, payload.challenge_id) + if challenge is None or challenge.consumed_at is not None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Google 로그인을 다시 시작해주세요") + if challenge.expires_at.replace(tzinfo=UTC) <= datetime.now(UTC): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Google 로그인 요청이 만료되었습니다") + try: + identity = await request.app.state.container.google_identity.verify(payload.id_token) + except GoogleIdentityError as exc: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, "Google 로그인 정보를 확인할 수 없습니다" + ) from exc + except GoogleServiceError as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, "Google 로그인 확인을 잠시 후 다시 시도해주세요" + ) from exc + nonce_hash = hashlib.sha256(identity.nonce.encode()).hexdigest() + if not hmac.compare_digest(challenge.nonce_hash, nonce_hash): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Google 로그인 요청이 일치하지 않습니다") + consumed = await session.scalar( + update(GoogleLoginChallenge) + .where( + GoogleLoginChallenge.id == payload.challenge_id, + GoogleLoginChallenge.consumed_at.is_(None), + GoogleLoginChallenge.expires_at > datetime.now(UTC), + ) + .values(consumed_at=datetime.now(UTC)) + .execution_options(synchronize_session=False) + .returning(GoogleLoginChallenge.id) + ) + if consumed is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "이미 사용한 Google 로그인 요청입니다") + user = await session.scalar(select(User).where(User.google_subject == identity.subject)) + if user is None: + try: + async with session.begin_nested(): + user = User( + google_subject=identity.subject, + phone=None, + role=payload.role, + name=(payload.name or "").strip() or "사용자", + ) + session.add(user) + await session.flush() + except IntegrityError: + user = await session.scalar( + select(User).where(User.google_subject == identity.subject) + ) + if user is None: + raise HTTPException( + status.HTTP_409_CONFLICT, "Google 로그인을 다시 시도해주세요" + ) from None + return await create_session(session, user, settings) + + @router.post("/otp/request", status_code=202) async def request_otp( payload: OtpRequest, request: Request, session: SessionDep diff --git a/backend/app/config.py b/backend/app/config.py index e983519..50e18b5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -37,6 +37,10 @@ class Settings(BaseSettings): apple_team_id: str = "" apple_key_id: str = "" apple_private_key_path: Path | None = None + # Android Sign in with Google. ID 토큰의 aud가 되는 Web OAuth 클라이언트 ID이며 + # 비워두면 Google 로그인 엔드포인트는 503을 반환한다. nonce 유효 시간은 + # apple_challenge_ttl_seconds를 같이 쓴다. + google_client_id: str = "" schema_auto_reset: bool = False diff --git a/backend/app/container.py b/backend/app/container.py index dfda3d6..d0d7fc4 100644 --- a/backend/app/container.py +++ b/backend/app/container.py @@ -7,6 +7,7 @@ from app.services.calls import CallLifecycle from app.services.deepgram import create_stt_gateway from app.services.gemini import create_extraction_gateway +from app.services.google_auth import GoogleIdentityVerifier from app.services.livekit import create_livekit_gateway from app.services.notifications import create_report_push_gateway, create_voip_push_gateway from app.services.pipeline import ProcessingPipeline @@ -21,6 +22,7 @@ class AppContainer: def __init__(self, settings: Settings) -> None: self.settings = settings self.apple_identity = AppleIdentityVerifier(settings) + self.google_identity = GoogleIdentityVerifier(settings) self.database = Database(settings.database_url) self.storage = create_storage(settings) self.question_tts = create_question_tts_gateway(settings, self.storage) diff --git a/backend/app/main.py b/backend/app/main.py index 82a7f9e..0637f69 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -68,6 +68,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await container.voip_push.close() await container.report_push.close() await container.apple_identity.close() + await container.google_identity.close() await container.database.close() app = FastAPI( diff --git a/backend/app/models.py b/backend/app/models.py index 8dbea97..d616efe 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -85,6 +85,9 @@ class User(Base): apple_subject: Mapped[str | None] = mapped_column( String(255), unique=True, index=True, nullable=True ) + google_subject: Mapped[str | None] = mapped_column( + String(255), unique=True, index=True, nullable=True + ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) @@ -111,6 +114,15 @@ class AppleLoginChallenge(Base): consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) +class GoogleLoginChallenge(Base): + __tablename__ = "google_login_challenges" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + nonce_hash: Mapped[str] = mapped_column(String(64), unique=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + class Device(Base): __tablename__ = "devices" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 8a44ca8..372db82 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -70,6 +70,19 @@ class AppleChallengeResponse(ApiModel): expires_in: int +class GoogleLoginRequest(ApiModel): + id_token: str = Field(min_length=1, max_length=16_384) + challenge_id: str = Field(min_length=36, max_length=36) + role: UserRole = UserRole.CHILD + name: str | None = Field(default=None, max_length=80) + + +class GoogleChallengeResponse(ApiModel): + challenge_id: str + nonce: str + expires_in: int + + class UserView(ApiModel): id: str role: str diff --git a/backend/app/services/google_auth.py b/backend/app/services/google_auth.py new file mode 100644 index 0000000..d4c8b05 --- /dev/null +++ b/backend/app/services/google_auth.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio +import time +from collections.abc import Callable +from dataclasses import dataclass + +import httpx +import jwt + +from app.config import Settings + +GOOGLE_KEYS_URL = "https://www.googleapis.com/oauth2/v3/certs" +GOOGLE_ISSUERS = ("accounts.google.com", "https://accounts.google.com") + + +class GoogleIdentityError(Exception): + pass + + +class GoogleServiceError(Exception): + pass + + +@dataclass(frozen=True) +class GoogleIdentity: + subject: str + nonce: str + + +class GoogleIdentityVerifier: + """Android Credential Manager가 발급받은 Google ID 토큰을 검증한다. + + AppleIdentityVerifier와 같은 키 캐시·실패 backoff 규칙을 따른다. + """ + + def __init__( + self, + settings: Settings, + *, + client: httpx.AsyncClient | None = None, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._audience = settings.google_client_id + self._client = client or httpx.AsyncClient(timeout=10) + self._owns_client = client is None + self._clock = clock + self._keys: dict[str, jwt.PyJWK] = {} + self._expires_at = 0.0 + self._retry_at = 0.0 + self._lock = asyncio.Lock() + + async def close(self) -> None: + if self._owns_client: + await self._client.aclose() + + async def _key(self, kid: str) -> jwt.PyJWK: + async with self._lock: + now = self._clock() + if now < self._expires_at and kid in self._keys: + return self._keys[kid] + if now < self._retry_at: + if now >= self._expires_at: + raise GoogleServiceError("Google signing keys are temporarily unavailable") + raise GoogleIdentityError("Unknown Google signing key") + self._retry_at = now + 60 + try: + response = await self._client.get(GOOGLE_KEYS_URL) + response.raise_for_status() + document = response.json() + if not isinstance(document, dict) or not isinstance(document.get("keys"), list): + raise ValueError("Invalid key document") + keys: dict[str, jwt.PyJWK] = {} + for item in document["keys"]: + if not isinstance(item, dict): + raise ValueError("Invalid signing key") + if item.get("kty") != "RSA" or item.get("alg") != "RS256": + continue + key_id = item.get("kid") + if not isinstance(key_id, str) or not key_id or item.get("use") != "sig": + continue + keys[key_id] = jwt.PyJWK.from_dict(item, algorithm="RS256") + if not keys: + raise ValueError("No signing keys") + except (httpx.HTTPError, ValueError, jwt.PyJWTError) as exc: + raise GoogleServiceError("Could not obtain Google signing keys") from exc + self._keys = keys + self._expires_at = now + 3600 + if kid not in keys: + raise GoogleIdentityError("Unknown Google signing key") + return keys[kid] + + async def verify(self, id_token: str) -> GoogleIdentity: + if not id_token or len(id_token) > 16384 or not self._audience: + raise GoogleIdentityError("Invalid Google ID token") + try: + header = jwt.get_unverified_header(id_token) + kid = header.get("kid") + if header.get("alg") != "RS256" or not isinstance(kid, str) or not kid: + raise GoogleIdentityError("Invalid Google signing algorithm or key") + key = await self._key(kid) + claims = jwt.decode( + id_token, + key.key, + algorithms=["RS256"], + audience=self._audience, + issuer=GOOGLE_ISSUERS, + options={"require": ["exp", "iat", "sub", "nonce", "iss", "aud"]}, + ) + subject = claims["sub"] + nonce = claims["nonce"] + if not isinstance(subject, str) or not subject or len(subject) > 255: + raise GoogleIdentityError("Invalid Google subject") + if not isinstance(nonce, str) or not nonce or len(nonce) > 255: + raise GoogleIdentityError("Invalid Google nonce") + return GoogleIdentity(subject=subject, nonce=nonce) + # 깊게 중첩된 헤더 JSON은 PyJWT 안에서 RecursionError가 된다(PYSEC-2026-4142). + except (jwt.PyJWTError, TypeError, ValueError, RecursionError) as exc: + raise GoogleIdentityError("Invalid Google ID token") from exc diff --git a/backend/deploy/production.env.example b/backend/deploy/production.env.example index eac04aa..2535fb7 100644 --- a/backend/deploy/production.env.example +++ b/backend/deploy/production.env.example @@ -23,6 +23,7 @@ APPLE_CLIENT_ID=com.dohyeoplim.collog-ios APPLE_TEAM_ID=3X4434LYHD APPLE_KEY_ID= APPLE_KEY_FILE= +GOOGLE_CLIENT_ID= DEEPGRAM_API_KEY= GEMINI_API_KEY= GEMINI_DATA_PROCESSING_APPROVED=false diff --git a/backend/docker-compose.local.yml b/backend/docker-compose.local.yml index ee44e66..8dcbbb4 100644 --- a/backend/docker-compose.local.yml +++ b/backend/docker-compose.local.yml @@ -195,6 +195,7 @@ services: APPLE_TEAM_ID: ${APPLE_TEAM_ID:-} APPLE_KEY_ID: ${APPLE_KEY_ID:-} APPLE_PRIVATE_KEY_PATH: /run/secrets/apple-signin.p8 + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} APNS_VOIP_ENABLED: "true" APNS_ENVIRONMENT: ${APNS_ENVIRONMENT:-sandbox} APNS_TEAM_ID: ${APNS_TEAM_ID:?Set APNS_TEAM_ID} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index c97f5bc..e6651e6 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -30,6 +30,7 @@ x-backend-environment: &backend-environment APPLE_TEAM_ID: ${APPLE_TEAM_ID:-} APPLE_KEY_ID: ${APPLE_KEY_ID:-} APPLE_PRIVATE_KEY_PATH: /run/secrets/apple-signin.p8 + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} APNS_VOIP_ENABLED: "true" APNS_ENVIRONMENT: ${APNS_ENVIRONMENT:?Set APNS_ENVIRONMENT} APNS_TEAM_ID: ${APNS_TEAM_ID:?Set APNS_TEAM_ID} diff --git a/backend/tests/test_google_identity.py b/backend/tests/test_google_identity.py new file mode 100644 index 0000000..72a0422 --- /dev/null +++ b/backend/tests/test_google_identity.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import base64 +import json +import time + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from app.config import Settings +from app.services.google_auth import ( + GOOGLE_KEYS_URL, + GoogleIdentityError, + GoogleIdentityVerifier, + GoogleServiceError, +) + +CLIENT_ID = "test-web-client.apps.googleusercontent.com" + + +def settings() -> Settings: + return Settings(_env_file=None, google_client_id=CLIENT_ID) + + +@pytest.fixture(scope="module") +def signing_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture +def claims(): + return { + "iss": "https://accounts.google.com", + "aud": CLIENT_ID, + "sub": "google-user-123", + "nonce": "server-challenge-nonce", + "iat": int(time.time()), + "exp": int(time.time()) + 300, + } + + +def encode(signing_key, claims, kid="google-key"): + return jwt.encode(claims, signing_key, algorithm="RS256", headers={"kid": kid}) + + +def key_document(signing_key): + key = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key())) + return {"keys": [{**key, "kid": "google-key", "alg": "RS256", "use": "sig"}]} + + +def keys_client(signing_key) -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: httpx.Response(200, json=key_document(signing_key))) + ) + + +async def test_verified_subject_and_cached_keys(signing_key, claims): + requests = [] + + def respond(request): + requests.append(request) + assert str(request.url) == GOOGLE_KEYS_URL + return httpx.Response(200, json=key_document(signing_key)) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: + verifier = GoogleIdentityVerifier(settings(), client=client) + token = encode(signing_key, claims) + for _ in range(2): + identity = await verifier.verify(token) + assert identity.subject == claims["sub"] + assert identity.nonce == claims["nonce"] + for number in range(10): + with pytest.raises(GoogleIdentityError): + await verifier.verify(encode(signing_key, claims, kid=f"unknown-{number}")) + assert len(requests) == 1 + + +@pytest.mark.parametrize("issuer", ["accounts.google.com", "https://accounts.google.com"]) +async def test_accepts_both_google_issuers(signing_key, claims, issuer): + claims["iss"] = issuer + async with keys_client(signing_key) as client: + verifier = GoogleIdentityVerifier(settings(), client=client) + assert (await verifier.verify(encode(signing_key, claims))).subject == claims["sub"] + + +@pytest.mark.parametrize( + ("claim", "value"), + [ + ("iss", "https://attacker.example"), + ("iss", "accounts.google.com.attacker.example"), + ("iss", "google.com"), + ("aud", "another-app.apps.googleusercontent.com"), + ("exp", 1), + ("iat", 9999999999), + ("iat", []), + ("exp", {}), + ("sub", ""), + ("sub", 123), + ("nonce", ""), + ("nonce", 123), + ], +) +async def test_rejects_invalid_claims(signing_key, claims, claim, value): + claims[claim] = value + async with keys_client(signing_key) as client: + verifier = GoogleIdentityVerifier(settings(), client=client) + with pytest.raises(GoogleIdentityError): + await verifier.verify(encode(signing_key, claims)) + + +@pytest.mark.parametrize("claim", ["sub", "nonce", "iat", "exp", "iss", "aud"]) +async def test_requires_claims(signing_key, claims, claim): + del claims[claim] + async with keys_client(signing_key) as client: + verifier = GoogleIdentityVerifier(settings(), client=client) + with pytest.raises(GoogleIdentityError): + await verifier.verify(encode(signing_key, claims)) + + +async def test_rejects_wrong_signature(signing_key, claims): + attacker_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + async with keys_client(signing_key) as client: + verifier = GoogleIdentityVerifier(settings(), client=client) + with pytest.raises(GoogleIdentityError): + await verifier.verify(encode(attacker_key, claims)) + + +async def test_unconfigured_client_id_rejects_before_network(signing_key, claims): + def unexpected_request(_): + pytest.fail("Unconfigured verifier must not fetch keys") + + async with httpx.AsyncClient(transport=httpx.MockTransport(unexpected_request)) as client: + verifier = GoogleIdentityVerifier(Settings(_env_file=None), client=client) + with pytest.raises(GoogleIdentityError): + await verifier.verify(encode(signing_key, claims)) + + +async def test_deeply_nested_header_is_identity_error(): + def unexpected_request(_): + pytest.fail("Malformed header must not fetch keys") + + header = base64.urlsafe_b64encode(b"[" * 12000).rstrip(b"=").decode() + token = f"{header}.e30.sig" + assert len(token) <= 16384 + async with httpx.AsyncClient(transport=httpx.MockTransport(unexpected_request)) as client: + verifier = GoogleIdentityVerifier(settings(), client=client) + with pytest.raises(GoogleIdentityError): + await verifier.verify(token) + + +@pytest.mark.parametrize("algorithm", ["HS256", "none"]) +async def test_rejects_algorithm_before_network(claims, algorithm): + def unexpected_request(_): + pytest.fail("Invalid algorithm must not fetch keys") + + token = jwt.encode( + claims, + "attacker-secret-with-at-least-32-bytes" if algorithm == "HS256" else None, + algorithm=algorithm, + headers={"kid": "google-key"}, + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(unexpected_request)) as client: + verifier = GoogleIdentityVerifier(settings(), client=client) + with pytest.raises(GoogleIdentityError): + await verifier.verify(token) + + +async def test_provider_failure_has_backoff_and_recovers(signing_key, claims): + now = [0.0] + requests = [] + + def respond(request): + requests.append(request) + if len(requests) == 1: + return httpx.Response(503) + return httpx.Response(200, json=key_document(signing_key)) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: + verifier = GoogleIdentityVerifier(settings(), client=client, clock=lambda: now[0]) + token = encode(signing_key, claims) + for _ in range(2): + with pytest.raises(GoogleServiceError): + await verifier.verify(token) + assert len(requests) == 1 + now[0] = 61 + assert (await verifier.verify(token)).subject == claims["sub"] + now[0] += 3601 + assert (await verifier.verify(token)).subject == claims["sub"] + assert len(requests) == 3 + + +@pytest.mark.parametrize("document", [{}, {"keys": []}, {"keys": [None]}, {"keys": "invalid"}]) +async def test_malformed_key_response_is_service_error(signing_key, claims, document): + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: httpx.Response(200, json=document)) + ) as client: + verifier = GoogleIdentityVerifier(settings(), client=client) + with pytest.raises(GoogleServiceError): + await verifier.verify(encode(signing_key, claims)) diff --git a/backend/tests/test_google_login.py b/backend/tests/test_google_login.py new file mode 100644 index 0000000..7514313 --- /dev/null +++ b/backend/tests/test_google_login.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.models import GoogleLoginChallenge, User +from app.services.google_auth import GoogleIdentityVerifier +from tests.conftest import auth, create_user + +CLIENT_ID = "test-web-client.apps.googleusercontent.com" + + +@pytest.fixture +def sign_google(client: TestClient) -> Iterator[Callable[[str, str], str]]: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + jwk = jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key(), as_dict=True) + jwk.update(kid="test-google", alg="RS256", use="sig") + transport = httpx.MockTransport(lambda request: httpx.Response(200, json={"keys": [jwk]})) + http = httpx.AsyncClient(transport=transport) + container = client.app.state.container + container.settings.google_client_id = CLIENT_ID + client.portal.call(container.google_identity.close) + container.google_identity = GoogleIdentityVerifier(container.settings, client=http) + + def sign(nonce: str, subject: str = "google-user") -> str: + now = datetime.now(UTC) + return jwt.encode( + { + "sub": subject, + "nonce": nonce, + "iss": "https://accounts.google.com", + "aud": CLIENT_ID, + "iat": now, + "exp": now + timedelta(minutes=5), + }, + key, + algorithm="RS256", + headers={"kid": "test-google"}, + ) + + yield sign + client.portal.call(http.aclose) + + +def google_payload( + client: TestClient, sign: Callable[[str, str], str], subject: str = "google-user" +) -> dict: + response = client.post("/v1/auth/google/challenge") + assert response.status_code == 200, response.text + challenge = response.json() + return { + "challengeId": challenge["challengeId"], + "idToken": sign(challenge["nonce"], subject), + "role": "CHILD", + "name": "Google 사용자", + } + + +def test_google_login_without_phone_and_session_refresh(client: TestClient, sign_google) -> None: + response = client.post("/v1/auth/google", json=google_payload(client, sign_google)) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["user"]["phone"] is None + assert payload["user"]["appleUserId"] is None + assert payload["user"]["name"] == "Google 사용자" + assert payload["user"]["familyId"] is not None + renewed = client.post("/v1/auth/refresh", json={"refreshToken": payload["refreshToken"]}) + assert renewed.status_code == 200 + assert renewed.json()["user"]["id"] == payload["user"]["id"] + + +def test_google_relogin_preserves_name_role_and_family(client: TestClient, sign_google) -> None: + first = client.post("/v1/auth/google", json=google_payload(client, sign_google)).json() + payload = google_payload(client, sign_google) + payload["role"] = "PARENT" + payload["name"] = None + second = client.post("/v1/auth/google", json=payload) + assert second.status_code == 200 + assert second.json()["user"] == first["user"] + + +def test_google_parent_can_accept_existing_family_invitation( + client: TestClient, sign_google +) -> None: + token, child = create_user(client, "01012345678", "CHILD", "자녀") + invitation = client.post( + f"/v1/families/{child['familyId']}/invitations", + headers=auth(token), + json={"name": "부모", "relation": "MOTHER"}, + ) + payload = google_payload(client, sign_google) + payload["role"] = "PARENT" + parent = client.post("/v1/auth/google", json=payload).json() + assert parent["user"]["familyId"] is None + accepted = client.post( + "/v1/invitations/accept", + headers=auth(parent["accessToken"]), + json={"code": invitation.json()["code"]}, + ) + assert accepted.status_code == 200 + assert accepted.json()["familyId"] == child["familyId"] + + +def test_google_challenge_can_only_be_consumed_once(client: TestClient, sign_google) -> None: + payload = google_payload(client, sign_google) + with ThreadPoolExecutor(max_workers=2) as executor: + responses = list( + executor.map(lambda _: client.post("/v1/auth/google", json=payload), range(2)) + ) + assert sorted(response.status_code for response in responses) == [200, 401] + + +def test_wrong_nonce_does_not_consume_challenge(client: TestClient, sign_google) -> None: + valid = google_payload(client, sign_google) + invalid = {**valid, "idToken": sign_google("wrong-nonce", "google-user")} + assert client.post("/v1/auth/google", json=invalid).status_code == 401 + assert client.post("/v1/auth/google", json=valid).status_code == 200 + + +def test_expired_challenge_is_rejected(client: TestClient, sign_google) -> None: + payload = google_payload(client, sign_google) + + async def expire() -> None: + async with client.app.state.container.database.sessions() as session: + challenge = await session.get(GoogleLoginChallenge, payload["challengeId"]) + challenge.expires_at = datetime.now(UTC) - timedelta(seconds=1) + await session.commit() + + client.portal.call(expire) + assert client.post("/v1/auth/google", json=payload).status_code == 401 + + +def test_apple_challenge_is_not_accepted_for_google(client: TestClient, sign_google) -> None: + apple = client.post("/v1/auth/apple/challenge").json() + payload = { + "challengeId": apple["challengeId"], + "idToken": sign_google(apple["nonce"]), + "role": "CHILD", + } + assert client.post("/v1/auth/google", json=payload).status_code == 401 + + +def test_sms_account_is_not_merged_with_google_account(client: TestClient, sign_google) -> None: + _, sms = create_user(client, "01012345678", "CHILD", "Google 사용자") + google = client.post("/v1/auth/google", json=google_payload(client, sign_google)).json() + assert google["user"]["id"] != sms["id"] + + async def subjects() -> set[tuple[str | None, str | None]]: + async with client.app.state.container.database.sessions() as session: + return set((await session.execute(select(User.phone, User.google_subject))).all()) + + assert client.portal.call(subjects) == {("01012345678", None), (None, "google-user")} + + +def test_unconfigured_google_login_is_unavailable(client: TestClient) -> None: + assert client.post("/v1/auth/google/challenge").status_code == 503 + payload = {"challengeId": "0" * 36, "idToken": "token", "role": "CHILD"} + assert client.post("/v1/auth/google", json=payload).status_code == 503 + _, sms = create_user(client, "01012345678", "CHILD", "사용자") + assert sms["phone"] == "01012345678" + + +def test_google_account_can_be_deleted_without_apple_reauthentication( + client: TestClient, sign_google +) -> None: + login = client.post("/v1/auth/google", json=google_payload(client, sign_google)).json() + response = client.delete("/v1/account", headers=auth(login["accessToken"])) + assert response.status_code == 204, response.text + + async def remaining() -> list[str | None]: + async with client.app.state.container.database.sessions() as session: + return list(await session.scalars(select(User.google_subject))) + + assert client.portal.call(remaining) == [] From 77e599d5b7ae2697de95d04494d67e9479146d89 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Sat, 24 Oct 2026 22:54:06 +0900 Subject: [PATCH 02/23] feat: send Android call and report pushes through FCM Devices registered with platform=ANDROID now receive the incoming-call and report-ready signals as FCM HTTP v1 data messages. The access token is minted from a Firebase service account (FCM_SERVICE_ACCOUNT_PATH) and reused; unset keeps Android delivery disabled. iOS delivery is unchanged. --- HANDOFF.md | 10 +- backend/.env.example | 2 + backend/app/config.py | 3 + backend/app/container.py | 6 +- backend/app/main.py | 1 + backend/app/routes/calls.py | 5 +- backend/app/services/fcm.py | 223 ++++++++++++++++ backend/app/services/report_notifications.py | 19 +- backend/deploy/production.env.example | 1 + backend/docker-compose.local.yml | 1 + backend/docker-compose.yml | 1 + backend/tests/test_fcm.py | 259 +++++++++++++++++++ 12 files changed, 524 insertions(+), 7 deletions(-) create mode 100644 backend/app/services/fcm.py create mode 100644 backend/tests/test_fcm.py diff --git a/HANDOFF.md b/HANDOFF.md index 4b10fac..b6baa93 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -166,7 +166,11 @@ STT/LLM/음향 파이프라인이 끝까지 도는 것을 확인했다. 다만 Google Cloud에 Web OAuth 클라이언트(이 값)와 Android OAuth 클라이언트(패키지 이름·서명 SHA-1)를 같은 프로젝트에 만들어야 한다. 같은 사람이 iOS(Apple)와 Android(Google)로 가입하면 계정이 따로 생기며 병합하지 않는다. Google 계정 삭제는 OTP 계정처럼 재인증 없이 - 진행한다. Android 앱의 수신 통화(FCM)는 아직 서버에 없다. + 진행한다. +- **Android 통화·리포트 알림은 `FCM_SERVICE_ACCOUNT_PATH`를 설정해야 나간다.** Firebase 서비스 + 계정 키 JSON을 컨테이너에 읽기 전용으로 넣고 그 경로를 준다. 값이 비면 Android 기기가 받을 통화는 + APNs와 같은 규칙으로 502가 된다. Android 기기는 `platform=ANDROID`로 FCM 토큰 하나를 + `token`·`voipToken`·`pushToken`에 같이 등록한다. - **새 피그마와 현재 백엔드의 건강 주체 모델이 다르다.** 현재 API는 자녀 발신·부모 분석, 부모 질환 코드 배열에 고정돼 있다. 부모/자녀 양방향 통화와 양 참여자 동시 분석, 복용약·걱정·출처·본인 확인, Q/A 관찰 event와 리포트 v2는 설계만 완료됐다. @@ -303,6 +307,7 @@ APNs payload에는 `callId/callUUID/callerId/callerName/expiresAt`만 넣는다. | 파일 | 역할 | |---|---| | `services/livekit.py` | self-host LiveKit room/token/audio-track 조회/Track Egress/webhook adapter와 mock | +| `services/fcm.py` | Android FCM HTTP v1 발송. 서비스 계정 JWT로 OAuth 토큰 발급·재사용, 수신 통화·리포트 데이터 메시지, 만료 토큰 판정 | | `services/google_auth.py` | Google ID 토큰 JWKS 검증(iss 두 값, aud=Web client ID, nonce), 키 cache·실패 backoff | | `services/notifications.py` | APNs ES256 provider JWT와 HTTP/2 VoIP push, mock/disabled adapter | | `services/storage.py` | 로컬/S3 저장, presigned upload/download, TTS write/cache/read/delete | @@ -561,6 +566,7 @@ Compose는 ignored `backend/private/`를 `/run/secrets/collog`에 read-only moun | `HF_TOKEN` | Hugging Face 계정에서 HAI-DEF 약관 수락 후 발급 | HeAR detector artifact를 처음 받을 때만 필요 | gated model download. 앱/런타임 API key가 아니며 Git에 넣지 않음 | | `LIVEKIT_API_KEY/SECRET` | 우리가 직접 강한 난수로 생성 | 필수 | self-hosted room token, server API, Egress, webhook 서명 | | `JWT_SECRET` | 우리가 직접 강한 난수로 생성 | 필수 | 콜록 사용자 인증 JWT | +| `FCM_SERVICE_ACCOUNT_PATH` | Firebase Console 프로젝트 설정의 서비스 계정 키(JSON) | Android 통화 수신 시 필수 | FCM 발송. 비밀값이며 Git에 넣지 않음 | | `GOOGLE_CLIENT_ID` | Google Cloud Console의 Web OAuth 클라이언트 ID | Android Google 로그인 시 필수 | ID 토큰 `aud` 검증. 비밀값 아님 | | APNs `.p8`/Key ID/Team ID/Bundle ID | Apple Developer 발급·확인 | 실기기 백그라운드 수신 시 필수 | PushKit VoIP push | | MinIO access key/secret | 우리가 직접 생성 | Egress 녹음 시 필수 | self-hosted S3 호환 오디오 저장 | @@ -927,3 +933,5 @@ API를 바꿀 때에는 기존 camelCase 계약과 테스트를 유지하고, 동기 함수로 분리했다. 브랜치를 머지할 때 `ruff`를 다시 돌려야 한다. - 2026-10-24: Android 포팅을 위해 Google 로그인 엔드포인트, `google_subject` 컬럼과 `google_login_challenges` 테이블(migration 0011), 검증기·라우트 테스트 40개를 추가했다. +- 2026-10-24: Android 기기로 수신 통화·리포트 알림을 FCM HTTP v1으로 보내는 발송기와 플랫폼별 발송 + 분기, 테스트 12개를 추가했다. diff --git a/backend/.env.example b/backend/.env.example index 928fbcb..a4c8ba2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,6 +19,8 @@ APPLE_KEY_ID= APPLE_PRIVATE_KEY_PATH= # Android Sign in with Google: Web OAuth client ID (ID token aud). Empty disables it. GOOGLE_CLIENT_ID= +# Android call/report push (FCM HTTP v1): Firebase service account JSON path. Empty disables it. +FCM_SERVICE_ACCOUNT_PATH= SOLAPI_API_KEY= SOLAPI_API_SECRET= SOLAPI_SENDER= diff --git a/backend/app/config.py b/backend/app/config.py index 50e18b5..7f7fb18 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -55,6 +55,9 @@ class Settings(BaseSettings): apns_key_id: str = "" apns_bundle_id: str = "" apns_private_key_path: Path | None = None + # Android 통화·리포트 알림(FCM HTTP v1). Firebase 서비스 계정 키 JSON 경로이며, + # 비워두면 Android 기기로는 보내지 않는다. + fcm_service_account_path: Path | None = None incoming_call_ttl_seconds: int = 45 # Track Egress worker가 없는 개발 환경에서 기기의 분석용 PCM만으로 파이프라인을 돌린다. diff --git a/backend/app/container.py b/backend/app/container.py index d0d7fc4..f8c1402 100644 --- a/backend/app/container.py +++ b/backend/app/container.py @@ -6,6 +6,7 @@ from app.services.apple_auth import AppleIdentityVerifier from app.services.calls import CallLifecycle from app.services.deepgram import create_stt_gateway +from app.services.fcm import create_android_push_gateways from app.services.gemini import create_extraction_gateway from app.services.google_auth import GoogleIdentityVerifier from app.services.livekit import create_livekit_gateway @@ -30,7 +31,10 @@ def __init__(self, settings: Settings) -> None: self.calls = CallLifecycle(settings, self.database, self.livekit, self.storage) self.voip_push = create_voip_push_gateway(settings) self.report_push = create_report_push_gateway(settings) - self.report_notifications = ReportNotifications(self.database, self.report_push) + self.android_voip_push, self.android_report_push = create_android_push_gateways(settings) + self.report_notifications = ReportNotifications( + self.database, self.report_push, self.android_report_push + ) self.stt = create_stt_gateway(settings) self.extraction = create_extraction_gateway(settings) self.acoustics = create_acoustic_analyzer(settings) diff --git a/backend/app/main.py b/backend/app/main.py index 0637f69..c21854f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -67,6 +67,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await task await container.voip_push.close() await container.report_push.close() + await container.android_voip_push.close() await container.apple_identity.close() await container.google_identity.close() await container.database.close() diff --git a/backend/app/routes/calls.py b/backend/app/routes/calls.py index 8c77544..569ba46 100644 --- a/backend/app/routes/calls.py +++ b/backend/app/routes/calls.py @@ -129,7 +129,7 @@ async def create_reserved_call( select(Device) .where( Device.user_id == callee.id, - Device.platform == "IOS", + Device.platform.in_(["IOS", "ANDROID"]), Device.voip_token.is_not(None), Device.call_notifications_enabled.is_(True), ) @@ -138,8 +138,9 @@ async def create_reserved_call( await session.commit() delivered = False for device in devices: + container = request.app.state.container device_delivered = await deliver_incoming_call_push( - request.app.state.container.voip_push, + container.android_voip_push if device.platform == "ANDROID" else container.voip_push, device, IncomingCallPush( call_id=call.id, diff --git a/backend/app/services/fcm.py b/backend/app/services/fcm.py new file mode 100644 index 0000000..ffe2cf4 --- /dev/null +++ b/backend/app/services/fcm.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import asyncio +import json +import time +from collections.abc import Callable +from datetime import UTC, datetime + +import httpx +import jwt + +from app.config import Settings +from app.services.notifications import ( + DisabledVoipPushGateway, + IncomingCallPush, + MockReportPushGateway, + MockVoipPushGateway, + PushNotificationError, + ReportPushGateway, + ReportReadyPush, + UnavailableReportPushGateway, + UnregisteredPushToken, + UnregisteredVoipToken, + VoipPushGateway, +) + +FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging" +DEFAULT_TOKEN_URI = "https://oauth2.googleapis.com/token" + + +class FcmUnregistered(PushNotificationError): + pass + + +class FcmClient: + """Android 기기로 FCM HTTP v1 데이터 메시지를 보낸다. + + 서비스 계정 키로 OAuth 접근 토큰을 직접 발급받아 1시간 가까이 재사용한다. + """ + + def __init__( + self, + settings: Settings, + *, + client: httpx.AsyncClient | None = None, + credentials: dict | None = None, + clock: Callable[[], float] = time.time, + ) -> None: + if credentials is None: + if settings.fcm_service_account_path is None: + raise PushNotificationError("FCM_SERVICE_ACCOUNT_PATH가 필요합니다") + try: + credentials = json.loads(settings.fcm_service_account_path.read_text("utf-8")) + except (OSError, ValueError) as exc: + raise PushNotificationError("FCM 서비스 계정 키를 읽을 수 없습니다") from exc + missing = [ + key for key in ("project_id", "client_email", "private_key") + if not isinstance(credentials.get(key), str) or not credentials[key] + ] + if missing: + raise PushNotificationError(f"FCM 서비스 계정 키에 값이 없습니다: {', '.join(missing)}") + self.project_id: str = credentials["project_id"] + self._email: str = credentials["client_email"] + self._private_key: str = credentials["private_key"] + self._key_id: str | None = credentials.get("private_key_id") + self._token_uri: str = credentials.get("token_uri") or DEFAULT_TOKEN_URI + self._client = client or httpx.AsyncClient(timeout=10) + self._owns_client = client is None + self._clock = clock + self._access_token: str | None = None + self._expires_at = 0.0 + self._lock = asyncio.Lock() + + async def close(self) -> None: + if self._owns_client: + await self._client.aclose() + + async def access_token(self) -> str: + async with self._lock: + now = self._clock() + if self._access_token and now < self._expires_at - 60: + return self._access_token + issued = int(now) + headers = {"kid": self._key_id} if self._key_id else None + try: + assertion = jwt.encode( + { + "iss": self._email, + "scope": FCM_SCOPE, + "aud": self._token_uri, + "iat": issued, + "exp": issued + 3600, + }, + self._private_key, + algorithm="RS256", + headers=headers, + ) + response = await self._client.post( + self._token_uri, + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + }, + ) + except (httpx.HTTPError, ValueError, jwt.PyJWTError) as exc: + raise PushNotificationError("FCM 인증 요청 실패") from exc + if response.status_code != 200: + raise PushNotificationError(f"FCM 인증 실패 HTTP {response.status_code}") + try: + body = response.json() + token = body["access_token"] + expires_in = int(body.get("expires_in", 3600)) + except (ValueError, KeyError, TypeError) as exc: + raise PushNotificationError("FCM 인증 응답을 이해하지 못했습니다") from exc + self._access_token = token + self._expires_at = now + expires_in + return token + + async def send(self, token: str, data: dict[str, str], ttl_seconds: int) -> None: + if not token: + raise FcmUnregistered("FCM 토큰이 비어 있습니다") + url = f"https://fcm.googleapis.com/v1/projects/{self.project_id}/messages:send" + message = { + "message": { + "token": token, + "data": data, + "android": {"priority": "HIGH", "ttl": f"{max(0, ttl_seconds)}s"}, + } + } + try: + response = await self._client.post( + url, + headers={"authorization": f"Bearer {await self.access_token()}"}, + json=message, + ) + except httpx.HTTPError as exc: + raise PushNotificationError("FCM 요청 실패") from exc + if response.status_code == 200: + return + code = self.error_code(response) + # 고정된 데이터 메시지만 보내므로 INVALID_ARGUMENT는 토큰 형식 문제로 본다. + if response.status_code == 404 or code in {"UNREGISTERED", "INVALID_ARGUMENT"}: + raise FcmUnregistered(code or "UNREGISTERED") + raise PushNotificationError(f"FCM 발송 실패 HTTP {response.status_code}: {code or '-'}") + + @staticmethod + def error_code(response: httpx.Response) -> str | None: + try: + error = response.json().get("error", {}) + except (ValueError, AttributeError): + return None + if not isinstance(error, dict): + return None + for detail in error.get("details") or []: + if isinstance(detail, dict) and isinstance(detail.get("errorCode"), str): + return detail["errorCode"] + status = error.get("status") + return status if isinstance(status, str) else None + + +def incoming_call_data(push: IncomingCallPush) -> dict[str, str]: + # FCM data 값은 문자열만 된다. 건강 정보와 LiveKit 자격 증명은 넣지 않는다(iOS와 같음). + data = { + "type": "incoming_call", + "callId": push.call_id, + "callUUID": push.call_id, + "callerId": push.caller_id, + "callerName": push.caller_name, + "expiresAt": push.expires_at.astimezone(UTC).isoformat(), + } + if push.callee_id: + data["calleeId"] = push.callee_id + return data + + +def report_ready_data(push: ReportReadyPush) -> dict[str, str]: + return { + "type": "report_ready", + "callId": push.call_id, + "title": "Collog", + "body": "새 통화 기록을 확인해 주세요.", + } + + +def seconds_until(moment: datetime) -> int: + return int((moment.astimezone(UTC) - datetime.now(UTC)).total_seconds()) + + +class FcmVoipPushGateway(VoipPushGateway): + def __init__(self, client: FcmClient) -> None: + self.client = client + + async def send_incoming_call(self, token: str, push: IncomingCallPush) -> None: + try: + await self.client.send(token, incoming_call_data(push), seconds_until(push.expires_at)) + except FcmUnregistered as exc: + raise UnregisteredVoipToken(str(exc)) from exc + return None + + async def close(self) -> None: + await self.client.close() + + +class FcmReportPushGateway(ReportPushGateway): + def __init__(self, client: FcmClient) -> None: + self.client = client + + async def send_report(self, token: str, push: ReportReadyPush) -> None: + try: + await self.client.send(token, report_ready_data(push), seconds_until(push.expires_at)) + except FcmUnregistered as exc: + raise UnregisteredPushToken(str(exc)) from exc + return None + + +def create_android_push_gateways(settings: Settings) -> tuple[VoipPushGateway, ReportPushGateway]: + """Android 통화·리포트 발송기. 둘은 FCM 접근 토큰을 같이 쓴다.""" + if settings.mock_external_services: + return MockVoipPushGateway(), MockReportPushGateway() + if settings.fcm_service_account_path is None: + return DisabledVoipPushGateway(), UnavailableReportPushGateway() + client = FcmClient(settings) + return FcmVoipPushGateway(client), FcmReportPushGateway(client) diff --git a/backend/app/services/report_notifications.py b/backend/app/services/report_notifications.py index ae00587..d445a6e 100644 --- a/backend/app/services/report_notifications.py +++ b/backend/app/services/report_notifications.py @@ -20,9 +20,15 @@ class ReportNotifications: - def __init__(self, database: Database, gateway: ReportPushGateway) -> None: + def __init__( + self, + database: Database, + gateway: ReportPushGateway, + android_gateway: ReportPushGateway | None = None, + ) -> None: self.database = database self.gateway = gateway + self.android_gateway = android_gateway self._lock = asyncio.Lock() async def notify(self, call_id: str) -> None: @@ -49,7 +55,9 @@ async def notify(self, call_id: str) -> None: select(Device) .where( Device.user_id.in_([call.parent_id, call.child_id]), - Device.platform == "IOS", + Device.platform.in_( + ["IOS", "ANDROID"] if self.android_gateway is not None else ["IOS"] + ), Device.report_notifications_enabled.is_(True), Device.push_token.is_not(None), Device.push_token != "", @@ -67,7 +75,12 @@ async def notify(self, call_id: str) -> None: device.apns_environment = delivered[token] continue try: - environment = await self.gateway.send_report( + gateway = ( + self.android_gateway + if device.platform == "ANDROID" and self.android_gateway is not None + else self.gateway + ) + environment = await gateway.send_report( token, replace(push, apns_environment=device.apns_environment) ) if environment: diff --git a/backend/deploy/production.env.example b/backend/deploy/production.env.example index 2535fb7..7d8db4b 100644 --- a/backend/deploy/production.env.example +++ b/backend/deploy/production.env.example @@ -24,6 +24,7 @@ APPLE_TEAM_ID=3X4434LYHD APPLE_KEY_ID= APPLE_KEY_FILE= GOOGLE_CLIENT_ID= +FCM_SERVICE_ACCOUNT_PATH= DEEPGRAM_API_KEY= GEMINI_API_KEY= GEMINI_DATA_PROCESSING_APPROVED=false diff --git a/backend/docker-compose.local.yml b/backend/docker-compose.local.yml index 8dcbbb4..86b498e 100644 --- a/backend/docker-compose.local.yml +++ b/backend/docker-compose.local.yml @@ -196,6 +196,7 @@ services: APPLE_KEY_ID: ${APPLE_KEY_ID:-} APPLE_PRIVATE_KEY_PATH: /run/secrets/apple-signin.p8 GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + FCM_SERVICE_ACCOUNT_PATH: ${FCM_SERVICE_ACCOUNT_PATH:-} APNS_VOIP_ENABLED: "true" APNS_ENVIRONMENT: ${APNS_ENVIRONMENT:-sandbox} APNS_TEAM_ID: ${APNS_TEAM_ID:?Set APNS_TEAM_ID} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index e6651e6..a289b79 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -31,6 +31,7 @@ x-backend-environment: &backend-environment APPLE_KEY_ID: ${APPLE_KEY_ID:-} APPLE_PRIVATE_KEY_PATH: /run/secrets/apple-signin.p8 GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + FCM_SERVICE_ACCOUNT_PATH: ${FCM_SERVICE_ACCOUNT_PATH:-} APNS_VOIP_ENABLED: "true" APNS_ENVIRONMENT: ${APNS_ENVIRONMENT:?Set APNS_ENVIRONMENT} APNS_TEAM_ID: ${APNS_TEAM_ID:?Set APNS_TEAM_ID} diff --git a/backend/tests/test_fcm.py b/backend/tests/test_fcm.py new file mode 100644 index 0000000..c638f96 --- /dev/null +++ b/backend/tests/test_fcm.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import json +import time +from datetime import UTC, datetime, timedelta +from pathlib import Path +from urllib.parse import parse_qs + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi.testclient import TestClient + +from app.config import Settings +from app.database import Database +from app.models import Device +from app.services.fcm import ( + FCM_SCOPE, + FcmClient, + FcmReportPushGateway, + FcmVoipPushGateway, + create_android_push_gateways, +) +from app.services.notifications import ( + DisabledVoipPushGateway, + IncomingCallPush, + MockReportPushGateway, + PushNotificationError, + ReportPushGateway, + ReportReadyPush, + UnavailableReportPushGateway, + UnregisteredPushToken, + UnregisteredVoipToken, + VoipPushGateway, +) +from app.services.report_notifications import ReportNotifications +from tests.conftest import auth +from tests.test_api_flow import onboard_family +from tests.test_report_notifications import seed_call + + +@pytest.fixture(scope="module") +def key() -> rsa.RSAPrivateKey: + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def credentials(key: rsa.RSAPrivateKey) -> dict: + return { + "project_id": "collog-test", + "client_email": "fcm@collog-test.iam.gserviceaccount.com", + "private_key_id": "key-1", + "private_key": key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode(), + "token_uri": "https://oauth2.googleapis.com/token", + } + + +class FakeGoogle: + def __init__( + self, key: rsa.RSAPrivateKey, send_status: int = 200, send_body: dict | None = None + ): + self.key = key + self.send_status = send_status + self.send_body = send_body or {"name": "projects/collog-test/messages/1"} + self.token_requests = 0 + self.messages: list[dict] = [] + self.authorizations: list[str] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + if request.url.host == "oauth2.googleapis.com": + self.token_requests += 1 + form = parse_qs(request.content.decode()) + assert form["grant_type"] == ["urn:ietf:params:oauth:grant-type:jwt-bearer"] + claims = jwt.decode( + form["assertion"][0], + self.key.public_key(), + algorithms=["RS256"], + audience="https://oauth2.googleapis.com/token", + leeway=timedelta(hours=2), + ) + assert claims["iss"] == "fcm@collog-test.iam.gserviceaccount.com" + assert claims["scope"] == FCM_SCOPE + assert jwt.get_unverified_header(form["assertion"][0])["kid"] == "key-1" + return httpx.Response(200, json={"access_token": "access-1", "expires_in": 3599}) + assert str(request.url) == "https://fcm.googleapis.com/v1/projects/collog-test/messages:send" + self.authorizations.append(request.headers["authorization"]) + self.messages.append(json.loads(request.content)) + return httpx.Response(self.send_status, json=self.send_body) + + +def push(expires_in: int = 30) -> IncomingCallPush: + return IncomingCallPush( + call_id="call-1", + caller_id="child", + caller_name="자녀", + expires_at=datetime.now(UTC) + timedelta(seconds=expires_in), + callee_id="parent", + ) + + +async def test_incoming_call_is_a_high_priority_data_message(key) -> None: + google = FakeGoogle(key) + async with httpx.AsyncClient(transport=httpx.MockTransport(google)) as http: + client = FcmClient(Settings(_env_file=None), client=http, credentials=credentials(key)) + gateway = FcmVoipPushGateway(client) + for _ in range(2): + assert await gateway.send_incoming_call("android-token", push()) is None + assert google.token_requests == 1 + assert google.authorizations == ["Bearer access-1"] * 2 + message = google.messages[0]["message"] + assert message["token"] == "android-token" + assert message["android"]["priority"] == "HIGH" + assert message["android"]["ttl"] in {"29s", "30s"} + assert "notification" not in message + assert message["data"] == { + "type": "incoming_call", + "callId": "call-1", + "callUUID": "call-1", + "callerId": "child", + "calleeId": "parent", + "callerName": "자녀", + "expiresAt": message["data"]["expiresAt"], + } + assert all(isinstance(value, str) for value in message["data"].values()) + + +async def test_access_token_is_renewed_before_expiry(key) -> None: + google = FakeGoogle(key) + now = [time.time()] + async with httpx.AsyncClient(transport=httpx.MockTransport(google)) as http: + client = FcmClient( + Settings(_env_file=None), + client=http, + credentials=credentials(key), + clock=lambda: now[0], + ) + await client.access_token() + now[0] += 3_500 + await client.access_token() + now[0] += 100 + await client.access_token() + assert google.token_requests == 2 + + +@pytest.mark.parametrize( + ("status", "body"), + [ + (404, {"error": {"status": "NOT_FOUND", "details": [{ + "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", + "errorCode": "UNREGISTERED", + }]}}), + (400, {"error": {"status": "INVALID_ARGUMENT"}}), + ], +) +async def test_dead_tokens_are_reported_as_unregistered(key, status, body) -> None: + google = FakeGoogle(key, send_status=status, send_body=body) + async with httpx.AsyncClient(transport=httpx.MockTransport(google)) as http: + client = FcmClient(Settings(_env_file=None), client=http, credentials=credentials(key)) + with pytest.raises(UnregisteredVoipToken): + await FcmVoipPushGateway(client).send_incoming_call("gone", push()) + with pytest.raises(UnregisteredPushToken): + await FcmReportPushGateway(client).send_report( + "gone", ReportReadyPush("call-1", datetime.now(UTC) + timedelta(days=1)) + ) + + +@pytest.mark.parametrize("status", [401, 429, 500, 503]) +async def test_transient_failures_keep_the_token(key, status) -> None: + google = FakeGoogle(key, send_status=status, send_body={"error": {"status": "UNAVAILABLE"}}) + async with httpx.AsyncClient(transport=httpx.MockTransport(google)) as http: + client = FcmClient(Settings(_env_file=None), client=http, credentials=credentials(key)) + with pytest.raises(PushNotificationError) as raised: + await FcmVoipPushGateway(client).send_incoming_call("token", push()) + assert not isinstance(raised.value, UnregisteredVoipToken) + + +async def test_report_message_carries_no_health_data(key) -> None: + google = FakeGoogle(key) + async with httpx.AsyncClient(transport=httpx.MockTransport(google)) as http: + client = FcmClient(Settings(_env_file=None), client=http, credentials=credentials(key)) + await FcmReportPushGateway(client).send_report( + "token", ReportReadyPush("call-1", datetime.now(UTC) + timedelta(days=1)) + ) + assert google.messages[0]["message"]["data"] == { + "type": "report_ready", + "callId": "call-1", + "title": "Collog", + "body": "새 통화 기록을 확인해 주세요.", + } + + +def test_missing_service_account_disables_android_push(tmp_path: Path, key) -> None: + voip, report = create_android_push_gateways( + Settings(_env_file=None, mock_external_services=False, fcm_service_account_path=None) + ) + assert isinstance(voip, DisabledVoipPushGateway) + assert isinstance(report, UnavailableReportPushGateway) + broken = tmp_path / "broken.json" + broken.write_text("{}") + with pytest.raises(PushNotificationError): + create_android_push_gateways( + Settings(_env_file=None, mock_external_services=False, fcm_service_account_path=broken) + ) + + +def test_android_callee_gets_the_fcm_push(client: TestClient) -> None: + child_token, _, parent_token, parent = onboard_family(client) + response = client.post("/v1/devices", headers=auth(parent_token), json={ + "platform": "ANDROID", "token": "fcm-token", + "voipToken": "fcm-token", "pushToken": "fcm-token", + }) + assert response.status_code == 201, response.text + + class Recorder(VoipPushGateway): + def __init__(self) -> None: + self.sent: list[tuple[str, IncomingCallPush]] = [] + + async def send_incoming_call(self, token: str, push: IncomingCallPush) -> None: + self.sent.append((token, push)) + + class Unused(VoipPushGateway): + async def send_incoming_call(self, token: str, push: IncomingCallPush) -> None: + pytest.fail("Android 기기에 APNs로 보내면 안 된다") + + container = client.app.state.container + container.android_voip_push = Recorder() + container.voip_push = Unused() + created = client.post("/v1/calls", headers=auth(child_token), json={"calleeId": parent["id"]}) + assert created.status_code == 201, created.text + assert [token for token, _ in container.android_voip_push.sent] == ["fcm-token"] + assert container.android_voip_push.sent[0][1].callee_id == parent["id"] + + +async def test_report_routes_android_devices_to_fcm(tmp_path: Path) -> None: + database = Database(f"sqlite+aiosqlite:///{tmp_path / 'android-reports.db'}") + await database.ensure_schema(auto_reset=True) + try: + call_id, parent_id, child_id = await seed_call(database) + async with database.sessions() as session: + session.add_all([ + Device(user_id=parent_id, platform="IOS", token="ios", push_token="a" * 64), + Device( + user_id=child_id, platform="ANDROID", token="android", push_token="fcm-token" + ), + ]) + await session.commit() + + ios: ReportPushGateway = MockReportPushGateway() + android = MockReportPushGateway() + await ReportNotifications(database, ios, android).notify(call_id) + assert [token for token, _ in ios.sent] == ["a" * 64] + assert [token for token, _ in android.sent] == ["fcm-token"] + finally: + await database.close() From 392b90d2a447b7d635ba11f337f910dea9a521b3 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Sat, 24 Oct 2026 23:35:19 +0900 Subject: [PATCH 03/23] fix: harden the FCM delivery after the security review - An empty FCM_SERVICE_ACCOUNT_PATH (compose default) means unset instead of Path('.') crashing the whole backend on start. - Without FCM, Android rows are left out of report delivery, and report retries skip devices that already received the notification. - The callee receiver check accepts Android devices when FCM is configured. - Only UNREGISTERED or an INVALID_ARGUMENT naming message.token counts as a dead token, and clearing it touches that device row only. - A 401 drops the cached access token; token_uri must be Google's. - Report messages collapse per call; service-account JSON is git-ignored. --- .gitignore | 5 + HANDOFF.md | 11 +- backend/app/config.py | 9 +- backend/app/routes/calls.py | 7 +- backend/app/services/fcm.py | 55 +++++-- backend/app/services/report_notifications.py | 23 ++- backend/tests/test_fcm.py | 144 ++++++++++++++++++- 7 files changed, 228 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 1ceb466..2c0138f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ *-env.txt *.p8 secrets/ +# Firebase·Google 서비스 계정 키(JSON). backend/private에는 README만 둔다. +backend/private/* +!backend/private/README.md +*firebase-adminsdk*.json +*service-account*.json .data/ *.db diff --git a/HANDOFF.md b/HANDOFF.md index b6baa93..78952a0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -168,9 +168,11 @@ STT/LLM/음향 파이프라인이 끝까지 도는 것을 확인했다. 다만 가입하면 계정이 따로 생기며 병합하지 않는다. Google 계정 삭제는 OTP 계정처럼 재인증 없이 진행한다. - **Android 통화·리포트 알림은 `FCM_SERVICE_ACCOUNT_PATH`를 설정해야 나간다.** Firebase 서비스 - 계정 키 JSON을 컨테이너에 읽기 전용으로 넣고 그 경로를 준다. 값이 비면 Android 기기가 받을 통화는 - APNs와 같은 규칙으로 502가 된다. Android 기기는 `platform=ANDROID`로 FCM 토큰 하나를 - `token`·`voipToken`·`pushToken`에 같이 등록한다. + 계정 키 JSON을 컨테이너에 읽기 전용으로 넣고 컨테이너 안 경로를 준다. compose에는 아직 이 키의 + bind가 없으므로 배포할 때 APNs 키처럼 `/run/secrets/fcm.json` bind를 추가해야 한다. 값이 비면 + Android 기기는 수신 기기로 치지 않아 Android만 가진 상대에게 거는 통화는 409(수신 기기 없음)이고, + 리포트 알림도 Android 기기를 대상에서 뺀다. Android 기기는 `platform=ANDROID`로 FCM 토큰 하나를 + `token`·`voipToken`·`pushToken`에 같이 등록한다. 키 JSON은 `.gitignore`가 막는다. - **새 피그마와 현재 백엔드의 건강 주체 모델이 다르다.** 현재 API는 자녀 발신·부모 분석, 부모 질환 코드 배열에 고정돼 있다. 부모/자녀 양방향 통화와 양 참여자 동시 분석, 복용약·걱정·출처·본인 확인, Q/A 관찰 event와 리포트 v2는 설계만 완료됐다. @@ -935,3 +937,6 @@ API를 바꿀 때에는 기존 camelCase 계약과 테스트를 유지하고, `google_login_challenges` 테이블(migration 0011), 검증기·라우트 테스트 40개를 추가했다. - 2026-10-24: Android 기기로 수신 통화·리포트 알림을 FCM HTTP v1으로 보내는 발송기와 플랫폼별 발송 분기, 테스트 12개를 추가했다. +- 2026-10-24: FCM 보안 리뷰 반영. 빈 키 경로를 미설정으로 처리(기동 실패 방지), 리포트 재시도 때 + 이미 받은 기기 재발송 방지, 수신 기기 사전 검사에 ANDROID 포함, 죽은 토큰 판정을 `message.token` + 지목 시로 한정, 토큰 정리를 해당 기기 행으로 한정, token_uri 고정, 서비스 계정 JSON gitignore. diff --git a/backend/app/config.py b/backend/app/config.py index 7f7fb18..bd1ca22 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Literal -from pydantic import Field +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -150,6 +150,13 @@ class Settings(BaseSettings): team_portal_enabled: bool = True team_portal_repository_url: str = "https://github.com/Collog-App/14th-hackathon" + @field_validator("fcm_service_account_path", mode="before") + @classmethod + def empty_fcm_path_is_unset(cls, value: object) -> object: + # compose의 `${FCM_SERVICE_ACCOUNT_PATH:-}`는 빈 문자열을 넘기고, + # pydantic은 그걸 Path(".")로 바꾼다. 빈 값은 "설정 안 됨"으로 본다. + return None if isinstance(value, str) and not value.strip() else value + @property def livekit_http_url(self) -> str: value = self.livekit_internal_url or self.livekit_url diff --git a/backend/app/routes/calls.py b/backend/app/routes/calls.py index 569ba46..4479ae8 100644 --- a/backend/app/routes/calls.py +++ b/backend/app/routes/calls.py @@ -80,10 +80,15 @@ async def create_reserved_call( if busy is not None: raise HTTPException(status.HTTP_409_CONFLICT, "본인 또는 상대방이 이미 통화 중입니다") if not settings_from(request).mock_external_services: + platforms = ( + ["IOS", "ANDROID"] + if settings_from(request).fcm_service_account_path is not None + else ["IOS"] + ) receiver = await session.scalar( select(Device.id).where( Device.user_id == callee.id, - Device.platform == "IOS", + Device.platform.in_(platforms), Device.voip_token.is_not(None), Device.call_notifications_enabled.is_(True), ) diff --git a/backend/app/services/fcm.py b/backend/app/services/fcm.py index ffe2cf4..43e25d0 100644 --- a/backend/app/services/fcm.py +++ b/backend/app/services/fcm.py @@ -18,7 +18,6 @@ PushNotificationError, ReportPushGateway, ReportReadyPush, - UnavailableReportPushGateway, UnregisteredPushToken, UnregisteredVoipToken, VoipPushGateway, @@ -64,6 +63,8 @@ def __init__( self._private_key: str = credentials["private_key"] self._key_id: str | None = credentials.get("private_key_id") self._token_uri: str = credentials.get("token_uri") or DEFAULT_TOKEN_URI + if self._token_uri != DEFAULT_TOKEN_URI: + raise PushNotificationError("FCM 서비스 계정 키의 token_uri가 Google 기본값이 아닙니다") self._client = client or httpx.AsyncClient(timeout=10) self._owns_client = client is None self._clock = clock @@ -116,7 +117,9 @@ async def access_token(self) -> str: self._expires_at = now + expires_in return token - async def send(self, token: str, data: dict[str, str], ttl_seconds: int) -> None: + async def send( + self, token: str, data: dict[str, str], ttl_seconds: int, collapse_key: str | None = None + ) -> None: if not token: raise FcmUnregistered("FCM 토큰이 비어 있습니다") url = f"https://fcm.googleapis.com/v1/projects/{self.project_id}/messages:send" @@ -127,6 +130,8 @@ async def send(self, token: str, data: dict[str, str], ttl_seconds: int) -> None "android": {"priority": "HIGH", "ttl": f"{max(0, ttl_seconds)}s"}, } } + if collapse_key: + message["message"]["android"]["collapse_key"] = collapse_key try: response = await self._client.post( url, @@ -138,11 +143,34 @@ async def send(self, token: str, data: dict[str, str], ttl_seconds: int) -> None if response.status_code == 200: return code = self.error_code(response) - # 고정된 데이터 메시지만 보내므로 INVALID_ARGUMENT는 토큰 형식 문제로 본다. - if response.status_code == 404 or code in {"UNREGISTERED", "INVALID_ARGUMENT"}: + if response.status_code == 401: + # 캐시한 접근 토큰이 무효가 됐을 수 있다. 다음 발송에서 새로 받는다. + self._access_token = None + if self.is_dead_token(response, code): raise FcmUnregistered(code or "UNREGISTERED") raise PushNotificationError(f"FCM 발송 실패 HTTP {response.status_code}: {code or '-'}") + @staticmethod + def is_dead_token(response: httpx.Response, code: str | None) -> bool: + """기기 행에서 지워도 되는 토큰인지. INVALID_ARGUMENT는 크기·키·TTL 문제일 수도 있어서 + Google이 `message.token` 필드를 지목했을 때만 죽은 토큰으로 본다.""" + if code == "UNREGISTERED": + return True + if code != "INVALID_ARGUMENT": + return False + try: + details = response.json().get("error", {}).get("details") or [] + except (ValueError, AttributeError): + return False + return any( + isinstance(detail, dict) + and any( + isinstance(violation, dict) and violation.get("field") == "message.token" + for violation in detail.get("fieldViolations") or [] + ) + for detail in details + ) + @staticmethod def error_code(response: httpx.Response) -> str | None: try: @@ -207,17 +235,28 @@ def __init__(self, client: FcmClient) -> None: async def send_report(self, token: str, push: ReportReadyPush) -> None: try: - await self.client.send(token, report_ready_data(push), seconds_until(push.expires_at)) + await self.client.send( + token, + report_ready_data(push), + seconds_until(push.expires_at), + collapse_key=push.call_id, + ) except FcmUnregistered as exc: raise UnregisteredPushToken(str(exc)) from exc return None -def create_android_push_gateways(settings: Settings) -> tuple[VoipPushGateway, ReportPushGateway]: - """Android 통화·리포트 발송기. 둘은 FCM 접근 토큰을 같이 쓴다.""" +def create_android_push_gateways( + settings: Settings, +) -> tuple[VoipPushGateway, ReportPushGateway | None]: + """Android 통화·리포트 발송기. 둘은 FCM 접근 토큰을 같이 쓴다. + + FCM을 설정하지 않으면 리포트 발송기는 None이다. 그러면 리포트 알림이 Android 기기를 + 아예 대상에서 빼서, 실패한 Android 행 때문에 리포트가 계속 재시도되지 않는다. + """ if settings.mock_external_services: return MockVoipPushGateway(), MockReportPushGateway() if settings.fcm_service_account_path is None: - return DisabledVoipPushGateway(), UnavailableReportPushGateway() + return DisabledVoipPushGateway(), None client = FcmClient(settings) return FcmVoipPushGateway(client), FcmReportPushGateway(client) diff --git a/backend/app/services/report_notifications.py b/backend/app/services/report_notifications.py index d445a6e..3739717 100644 --- a/backend/app/services/report_notifications.py +++ b/backend/app/services/report_notifications.py @@ -30,6 +30,10 @@ def __init__( self.gateway = gateway self.android_gateway = android_gateway self._lock = asyncio.Lock() + # 재시도 때 이미 받은 기기에 다시 보내지 않도록 통화별로 보낸 (플랫폼, 토큰)을 기억한다. + # 프로세스가 다시 뜨면 비지만, 실패한 기기 하나 때문에 다른 기기가 + # 10초마다 받는 일은 막는다. + self._delivered: dict[str, dict[tuple[str, str], str | None]] = {} async def notify(self, call_id: str) -> None: async with self._lock, self.database.sessions() as session: @@ -49,6 +53,7 @@ async def notify(self, call_id: str) -> None: expires_at = ended_at.replace(tzinfo=UTC) + timedelta(days=1) if expires_at <= now: call.report_notified_at = now + self._delivered.pop(call.id, None) await session.commit() return devices = list(await session.scalars( @@ -64,15 +69,16 @@ async def notify(self, call_id: str) -> None: ) )) failed = False - delivered: dict[str, str | None] = {} + delivered = self._delivered.setdefault(call.id, {}) push = ReportReadyPush(call_id=call.id, expires_at=expires_at) for device in devices: token = device.push_token if token is None: continue - if token in delivered: - if delivered[token]: - device.apns_environment = delivered[token] + key = (device.platform, token) + if key in delivered: + if delivered[key]: + device.apns_environment = delivered[key] continue try: gateway = ( @@ -85,16 +91,21 @@ async def notify(self, call_id: str) -> None: ) if environment: device.apns_environment = environment - delivered[token] = environment + delivered[key] = environment except UnregisteredPushToken: + # 이 기기 행만 지운다. 같은 문자열을 등록한 + # 다른 사용자·플랫폼 행은 건드리지 않는다. await session.execute( - update(Device).where(Device.push_token == token).values(push_token=None) + update(Device) + .where(Device.id == device.id, Device.push_token == token) + .values(push_token=None) ) except PushNotificationError: failed = True logger.warning("Report notification will retry", extra={"call_id": call.id}) if not failed: call.report_notified_at = now + self._delivered.pop(call.id, None) await session.commit() async def maintain(self) -> None: diff --git a/backend/tests/test_fcm.py b/backend/tests/test_fcm.py index c638f96..68eb42a 100644 --- a/backend/tests/test_fcm.py +++ b/backend/tests/test_fcm.py @@ -30,7 +30,6 @@ PushNotificationError, ReportPushGateway, ReportReadyPush, - UnavailableReportPushGateway, UnregisteredPushToken, UnregisteredVoipToken, VoipPushGateway, @@ -154,7 +153,12 @@ async def test_access_token_is_renewed_before_expiry(key) -> None: "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNREGISTERED", }]}}), - (400, {"error": {"status": "INVALID_ARGUMENT"}}), + (400, {"error": {"status": "INVALID_ARGUMENT", "details": [ + {"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", + "errorCode": "INVALID_ARGUMENT"}, + {"@type": "type.googleapis.com/google.rpc.BadRequest", + "fieldViolations": [{"field": "message.token", "description": "invalid"}]}, + ]}}), ], ) async def test_dead_tokens_are_reported_as_unregistered(key, status, body) -> None: @@ -195,11 +199,14 @@ async def test_report_message_carries_no_health_data(key) -> None: def test_missing_service_account_disables_android_push(tmp_path: Path, key) -> None: - voip, report = create_android_push_gateways( - Settings(_env_file=None, mock_external_services=False, fcm_service_account_path=None) - ) - assert isinstance(voip, DisabledVoipPushGateway) - assert isinstance(report, UnavailableReportPushGateway) + for unset in (None, "", " "): + settings = Settings( + _env_file=None, mock_external_services=False, fcm_service_account_path=unset + ) + assert settings.fcm_service_account_path is None + voip, report = create_android_push_gateways(settings) + assert isinstance(voip, DisabledVoipPushGateway) + assert report is None broken = tmp_path / "broken.json" broken.write_text("{}") with pytest.raises(PushNotificationError): @@ -257,3 +264,126 @@ async def test_report_routes_android_devices_to_fcm(tmp_path: Path) -> None: assert [token for token, _ in android.sent] == ["fcm-token"] finally: await database.close() + + +@pytest.mark.parametrize( + "body", + [ + {"error": {"status": "INVALID_ARGUMENT"}}, + {"error": {"status": "INVALID_ARGUMENT", "details": [ + {"@type": "type.googleapis.com/google.rpc.BadRequest", + "fieldViolations": [{"field": "message.data", "description": "too big"}]}, + ]}}, + ], +) +async def test_other_invalid_arguments_keep_the_token(key, body) -> None: + google = FakeGoogle(key, send_status=400, send_body=body) + async with httpx.AsyncClient(transport=httpx.MockTransport(google)) as http: + client = FcmClient(Settings(_env_file=None), client=http, credentials=credentials(key)) + with pytest.raises(PushNotificationError) as raised: + await FcmVoipPushGateway(client).send_incoming_call("token", push()) + assert not isinstance(raised.value, UnregisteredVoipToken) + + +async def test_unauthorized_send_drops_the_cached_access_token(key) -> None: + google = FakeGoogle(key, send_status=401, send_body={"error": {"status": "UNAUTHENTICATED"}}) + async with httpx.AsyncClient(transport=httpx.MockTransport(google)) as http: + client = FcmClient(Settings(_env_file=None), client=http, credentials=credentials(key)) + for _ in range(2): + with pytest.raises(PushNotificationError): + await client.send("token", {"type": "x"}, 30) + assert google.token_requests == 2 + + +def test_unexpected_token_uri_is_rejected(key) -> None: + with pytest.raises(PushNotificationError): + FcmClient( + Settings(_env_file=None), + credentials={**credentials(key), "token_uri": "https://attacker.example/token"}, + ) + + +async def test_report_messages_collapse_per_call(key) -> None: + google = FakeGoogle(key) + async with httpx.AsyncClient(transport=httpx.MockTransport(google)) as http: + client = FcmClient(Settings(_env_file=None), client=http, credentials=credentials(key)) + await FcmReportPushGateway(client).send_report( + "token", ReportReadyPush("call-1", datetime.now(UTC) + timedelta(days=1)) + ) + assert google.messages[0]["message"]["android"]["collapse_key"] == "call-1" + + +def test_android_only_callee_passes_the_receiver_check(client: TestClient, tmp_path: Path) -> None: + child_token, _, parent_token, parent = onboard_family(client) + client.post("/v1/devices", headers=auth(parent_token), json={ + "platform": "ANDROID", "token": "fcm-token", + "voipToken": "fcm-token", "pushToken": "fcm-token", + }) + + class Recorder(VoipPushGateway): + sent: list[str] = [] + + async def send_incoming_call(self, token: str, push: IncomingCallPush) -> None: + self.sent.append(token) + + container = client.app.state.container + container.settings.mock_external_services = False + container.settings.fcm_service_account_path = tmp_path / "fcm.json" + container.android_voip_push = Recorder() + created = client.post("/v1/calls", headers=auth(child_token), json={"calleeId": parent["id"]}) + assert created.status_code == 201, created.text + assert Recorder.sent == ["fcm-token"] + + +async def test_failed_android_report_does_not_resend_to_ios(tmp_path: Path) -> None: + database = Database(f"sqlite+aiosqlite:///{tmp_path / 'resend.db'}") + await database.ensure_schema(auto_reset=True) + try: + call_id, parent_id, child_id = await seed_call(database) + async with database.sessions() as session: + session.add_all([ + Device(user_id=parent_id, platform="IOS", token="ios", push_token="a" * 64), + Device( + user_id=child_id, platform="ANDROID", token="android", push_token="fcm-token" + ), + ]) + await session.commit() + + class FailingAndroid(MockReportPushGateway): + async def send_report(self, token: str, push: ReportReadyPush) -> None: + raise PushNotificationError("FCM 403") + + ios = MockReportPushGateway() + notifications = ReportNotifications(database, ios, FailingAndroid()) + for _ in range(3): + await notifications.maintain() + assert [token for token, _ in ios.sent] == ["a" * 64] + finally: + await database.close() + + +async def test_dead_android_token_only_clears_its_own_row(tmp_path: Path) -> None: + database = Database(f"sqlite+aiosqlite:///{tmp_path / 'scoped.db'}") + await database.ensure_schema(auto_reset=True) + try: + call_id, parent_id, child_id = await seed_call(database) + shared = "a" * 64 + async with database.sessions() as session: + ios = Device(user_id=parent_id, platform="IOS", token="ios", push_token=shared) + android = Device( + user_id=child_id, platform="ANDROID", token="android", push_token=shared + ) + session.add_all([ios, android]) + await session.commit() + ios_id, android_id = ios.id, android.id + + class GoneAndroid(MockReportPushGateway): + async def send_report(self, token: str, push: ReportReadyPush) -> None: + raise UnregisteredPushToken("INVALID_ARGUMENT") + + await ReportNotifications(database, MockReportPushGateway(), GoneAndroid()).notify(call_id) + async with database.sessions() as session: + assert (await session.get(Device, ios_id)).push_token == shared + assert (await session.get(Device, android_id)).push_token is None + finally: + await database.close() From 8a07f412b5595e5709e7331b8c9bdbb17311a603 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Sun, 25 Oct 2026 14:21:39 +0900 Subject: [PATCH 04/23] fix: allow clock skew when verifying Google ID tokens --- HANDOFF.md | 5 +++++ backend/app/services/google_auth.py | 3 +++ backend/tests/test_google_identity.py | 9 +++++++++ 3 files changed, 17 insertions(+) diff --git a/HANDOFF.md b/HANDOFF.md index 78952a0..a2b57ae 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -940,3 +940,8 @@ API를 바꿀 때에는 기존 camelCase 계약과 테스트를 유지하고, - 2026-10-24: FCM 보안 리뷰 반영. 빈 키 경로를 미설정으로 처리(기동 실패 방지), 리포트 재시도 때 이미 받은 기기 재발송 방지, 수신 기기 사전 검사에 ANDROID 포함, 죽은 토큰 판정을 `message.token` 지목 시로 한정, 토큰 정리를 해당 기기 행으로 한정, token_uri 고정, 서비스 계정 JSON gitignore. +- 2026-10-25: 실기기(에뮬레이터) Google 로그인이 401이었다. 맥 시계가 Google보다 약 4초 느려 막 발급된 + ID 토큰의 `iat`가 미래로 보였고, 검증이 시계 오차를 0초 허용했다. Google 검증에 60초 leeway를 + 두고 테스트를 추가했다. Apple 검증(`apple_auth.py`)도 leeway가 0이라 같은 위험이 있으나 손대지 않았다. + `backend/.env`가 있으면 pytest도 그 값을 읽어 Apple·팀 포털 테스트가 실패한다. 로컬 실행용 값은 + `backend/private/`에 두고 실행할 때만 불러온다. diff --git a/backend/app/services/google_auth.py b/backend/app/services/google_auth.py index d4c8b05..bcadd72 100644 --- a/backend/app/services/google_auth.py +++ b/backend/app/services/google_auth.py @@ -12,6 +12,8 @@ GOOGLE_KEYS_URL = "https://www.googleapis.com/oauth2/v3/certs" GOOGLE_ISSUERS = ("accounts.google.com", "https://accounts.google.com") +# 서버 시계가 Google보다 몇 초만 느려도 막 발급된 토큰의 iat가 미래가 된다. +CLOCK_SKEW_SECONDS = 60 class GoogleIdentityError(Exception): @@ -105,6 +107,7 @@ async def verify(self, id_token: str) -> GoogleIdentity: algorithms=["RS256"], audience=self._audience, issuer=GOOGLE_ISSUERS, + leeway=CLOCK_SKEW_SECONDS, options={"require": ["exp", "iat", "sub", "nonce", "iss", "aud"]}, ) subject = claims["sub"] diff --git a/backend/tests/test_google_identity.py b/backend/tests/test_google_identity.py index 72a0422..5c9a0b9 100644 --- a/backend/tests/test_google_identity.py +++ b/backend/tests/test_google_identity.py @@ -85,6 +85,15 @@ async def test_accepts_both_google_issuers(signing_key, claims, issuer): assert (await verifier.verify(encode(signing_key, claims))).subject == claims["sub"] +@pytest.mark.parametrize("seconds_ahead", [5, 55]) +async def test_tolerates_server_clock_behind_google(signing_key, claims, seconds_ahead): + # 서버 시계가 Google보다 몇 초 느리면 막 발급된 토큰의 iat가 미래로 보인다. + claims["iat"] = int(time.time()) + seconds_ahead + async with keys_client(signing_key) as client: + verifier = GoogleIdentityVerifier(settings(), client=client) + assert (await verifier.verify(encode(signing_key, claims))).subject == claims["sub"] + + @pytest.mark.parametrize( ("claim", "value"), [ From ccd981ef059e2be1624faa52a165b9a535dacd03 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Mon, 26 Oct 2026 10:29:56 +0900 Subject: [PATCH 05/23] fix: mount the FCM service account key in compose The compose files passed FCM_SERVICE_ACCOUNT_PATH through as a raw path but mounted no key file, so a host path in the deploy env made AppContainer fail at startup and took the API down for every client. Mount FCM_KEY_FILE like the APNs key and fill the container path only when it is set; when it is empty, /dev/null is mounted and Android push stays off. --- backend/app/config.py | 2 +- backend/deploy/local.env.example | 2 ++ backend/deploy/production.env.example | 3 ++- backend/docker-compose.local.yml | 8 +++++++- backend/docker-compose.yml | 8 +++++++- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index bd1ca22..acb105e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -153,7 +153,7 @@ class Settings(BaseSettings): @field_validator("fcm_service_account_path", mode="before") @classmethod def empty_fcm_path_is_unset(cls, value: object) -> object: - # compose의 `${FCM_SERVICE_ACCOUNT_PATH:-}`는 빈 문자열을 넘기고, + # compose의 `${FCM_KEY_FILE:+...}`는 키 파일이 없으면 빈 문자열을 넘기고, # pydantic은 그걸 Path(".")로 바꾼다. 빈 값은 "설정 안 됨"으로 본다. return None if isinstance(value, str) and not value.strip() else value diff --git a/backend/deploy/local.env.example b/backend/deploy/local.env.example index fc3439a..8406a9f 100644 --- a/backend/deploy/local.env.example +++ b/backend/deploy/local.env.example @@ -11,3 +11,5 @@ APNS_KEY_FILE= APPLE_TEAM_ID=3X4434LYHD APPLE_KEY_ID= APPLE_KEY_FILE= +# Absolute host path to the Firebase service account JSON. Empty disables Android push. +FCM_KEY_FILE= diff --git a/backend/deploy/production.env.example b/backend/deploy/production.env.example index 7d8db4b..efdffc5 100644 --- a/backend/deploy/production.env.example +++ b/backend/deploy/production.env.example @@ -24,7 +24,8 @@ APPLE_TEAM_ID=3X4434LYHD APPLE_KEY_ID= APPLE_KEY_FILE= GOOGLE_CLIENT_ID= -FCM_SERVICE_ACCOUNT_PATH= +# Absolute host path to the Firebase service account JSON. Empty disables Android push. +FCM_KEY_FILE= DEEPGRAM_API_KEY= GEMINI_API_KEY= GEMINI_DATA_PROCESSING_APPROVED=false diff --git a/backend/docker-compose.local.yml b/backend/docker-compose.local.yml index 86b498e..c6aa86b 100644 --- a/backend/docker-compose.local.yml +++ b/backend/docker-compose.local.yml @@ -196,7 +196,7 @@ services: APPLE_KEY_ID: ${APPLE_KEY_ID:-} APPLE_PRIVATE_KEY_PATH: /run/secrets/apple-signin.p8 GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} - FCM_SERVICE_ACCOUNT_PATH: ${FCM_SERVICE_ACCOUNT_PATH:-} + FCM_SERVICE_ACCOUNT_PATH: ${FCM_KEY_FILE:+/run/secrets/fcm-service-account.json} APNS_VOIP_ENABLED: "true" APNS_ENVIRONMENT: ${APNS_ENVIRONMENT:-sandbox} APNS_TEAM_ID: ${APNS_TEAM_ID:?Set APNS_TEAM_ID} @@ -230,6 +230,12 @@ services: read_only: true bind: create_host_path: false + - type: bind + source: ${FCM_KEY_FILE:-/dev/null} + target: /run/secrets/fcm-service-account.json + read_only: true + bind: + create_host_path: false healthcheck: test: - CMD diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index a289b79..004dee4 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -31,7 +31,7 @@ x-backend-environment: &backend-environment APPLE_KEY_ID: ${APPLE_KEY_ID:-} APPLE_PRIVATE_KEY_PATH: /run/secrets/apple-signin.p8 GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} - FCM_SERVICE_ACCOUNT_PATH: ${FCM_SERVICE_ACCOUNT_PATH:-} + FCM_SERVICE_ACCOUNT_PATH: ${FCM_KEY_FILE:+/run/secrets/fcm-service-account.json} APNS_VOIP_ENABLED: "true" APNS_ENVIRONMENT: ${APNS_ENVIRONMENT:?Set APNS_ENVIRONMENT} APNS_TEAM_ID: ${APNS_TEAM_ID:?Set APNS_TEAM_ID} @@ -175,6 +175,12 @@ services: read_only: true bind: create_host_path: false + - type: bind + source: ${FCM_KEY_FILE:-/dev/null} + target: /run/secrets/fcm-service-account.json + read_only: true + bind: + create_host_path: false healthcheck: test: - CMD From 5b036fe307bac7eba1430560aaaaffcc191a23c1 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Mon, 26 Oct 2026 10:29:56 +0900 Subject: [PATCH 06/23] fix: describe both phone platforms in the consent document Android also requests question audio from ElevenLabs directly and signs in with Google, so the iPhone-only wording was not true for Android users. Say phone instead of iPhone, name both login tokens, and bump the document version so the change is recorded as a new consent. --- backend/app/config.py | 2 +- backend/app/routes/consents.py | 6 +++--- backend/app/routes/questions.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index acb105e..7fad36d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -96,7 +96,7 @@ class Settings(BaseSettings): s3_use_instance_role: bool = False s3_force_path_style: bool = True - consent_document_version: str = "2026-09-13.v4" + consent_document_version: str = "2026-10-26.v5" parent_min_speech_seconds: int = 20 raw_audio_wait_seconds: int = 30 egress_wait_seconds: int = Field(default=120, ge=1) diff --git a/backend/app/routes/consents.py b/backend/app/routes/consents.py index f556e6e..1ee8955 100644 --- a/backend/app/routes/consents.py +++ b/backend/app/routes/consents.py @@ -25,8 +25,8 @@ async def consent_document(request: Request) -> dict: "거절해도 가족 통화 이용 가능함. Deepgram에 부모와 자녀의 통화 음성을 보내 " "대화를 글로 변환함. Google Gemini에 두 참여자의 전사문과 그 안의 증상, 복약, " "활동, 수면 정보를 보내 건강 기록을 생성함. ElevenLabs에는 질문 문장을 보내 " - "질문 음성을 생성하며, 아이폰 직접 요청 시 IP 주소가 전달됨. " - "이름, 전화번호, Apple 로그인 토큰은 AI 요청에 포함하지 않지만 대화에 말한 " + "질문 음성을 생성하며, 휴대폰 직접 요청 시 IP 주소가 전달됨. " + "이름, 전화번호, Apple·Google 로그인 토큰은 AI 요청에 포함하지 않지만 대화에 말한 " "개인정보는 음성과 전사문에 포함될 수 있음. 해외 제공사 서버에서 처리될 수 있음. " "부모의 건강 기록과 음성 특징값은 가족 자녀에게 제공함. " "의료 진단이나 치료 용도가 아님. " @@ -36,7 +36,7 @@ async def consent_document(request: Request) -> dict: "Deepgram 요청에는 모델 개선 참여 제외 옵션을 사용함. Google의 유료 서비스 " "데이터 처리 조건을 운영자가 확인하기 전에는 녹음과 건강 분석을 비활성화함. " "ElevenLabs는 모델 학습 이용을 제외한 계정 설정으로 사용함. " - "외부 음성 서비스를 사용할 수 없으면 아이폰 기본 음성으로 재생함. " + "외부 음성 서비스를 사용할 수 없으면 휴대폰 기본 음성으로 재생함. " "설정에서 동의를 변경할 수 있으며 거절하면 이후 녹음과 AI 분석을 중단함." ), "collectedItems": [ diff --git a/backend/app/routes/questions.py b/backend/app/routes/questions.py index f6b4c8d..29f9e14 100644 --- a/backend/app/routes/questions.py +++ b/backend/app/routes/questions.py @@ -98,7 +98,7 @@ async def create_question_tts_token( not container.settings.mock_external_services and not container.settings.elevenlabs_data_processing_approved ): - raise HTTPException(status.HTTP_409_CONFLICT, "아이폰 기본 음성으로 재생해주세요") + raise HTTPException(status.HTTP_409_CONFLICT, "휴대폰 기본 음성으로 재생해주세요") if not isinstance(gateway, ElevenLabsDirectTtsGateway): raise HTTPException(status.HTTP_409_CONFLICT, "직접 음성 재생이 설정되지 않았습니다") question_count = await session.scalar( From db5bb409aad3454f9f01b2166fa7ecdbc81f5dac Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Mon, 26 Oct 2026 10:29:56 +0900 Subject: [PATCH 07/23] fix: allow clock skew when verifying Apple identity tokens --- backend/app/services/apple_auth.py | 4 ++++ backend/tests/test_apple_identity.py | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/backend/app/services/apple_auth.py b/backend/app/services/apple_auth.py index 7726b09..d634983 100644 --- a/backend/app/services/apple_auth.py +++ b/backend/app/services/apple_auth.py @@ -10,6 +10,9 @@ from app.config import Settings +# 서버 시계가 Apple보다 몇 초만 느려도 막 발급된 토큰의 iat가 미래가 된다. +CLOCK_SKEW_SECONDS = 60 + class AppleIdentityError(Exception): pass @@ -97,6 +100,7 @@ async def verify(self, identity_token: str) -> AppleIdentity: algorithms=["RS256"], audience=self._audience, issuer="https://appleid.apple.com", + leeway=CLOCK_SKEW_SECONDS, options={"require": ["exp", "iat", "sub", "nonce", "iss", "aud"]}, ) subject = claims["sub"] diff --git a/backend/tests/test_apple_identity.py b/backend/tests/test_apple_identity.py index b96992d..23277bf 100644 --- a/backend/tests/test_apple_identity.py +++ b/backend/tests/test_apple_identity.py @@ -59,6 +59,17 @@ def respond(request): assert len(requests) == 1 +@pytest.mark.parametrize("seconds_ahead", [5, 55]) +async def test_tolerates_server_clock_behind_apple(signing_key, claims, seconds_ahead): + # 서버 시계가 Apple보다 몇 초 느리면 막 발급된 토큰의 iat가 미래로 보인다. + claims["iat"] = int(time.time()) + seconds_ahead + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: httpx.Response(200, json=key_document(signing_key))) + ) as client: + verifier = AppleIdentityVerifier(Settings(_env_file=None), client=client) + assert (await verifier.verify(encode(signing_key, claims))).subject == claims["sub"] + + @pytest.mark.parametrize( ("claim", "value"), [ From b7f0e444b0666b69756dbf0f2057456b7ea462fa Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Mon, 26 Oct 2026 10:29:56 +0900 Subject: [PATCH 08/23] build: upgrade dependencies with known vulnerabilities pyjwt 2.15.1, aiohttp 3.14.5, urllib3 2.8.0, certifi 2026.10.23 and multidict 6.9.1 (kept on 6.x). pip-audit reports none afterwards. --- backend/uv.lock | 637 ++++++++++++++++++++++++++++-------------------- 1 file changed, 376 insertions(+), 261 deletions(-) diff --git a/backend/uv.lock b/backend/uv.lock index 3fcdc42..1c06c36 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -17,7 +17,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.3" +version = "3.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -29,90 +29,132 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, - { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, - { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, - { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, - { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, - { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, - { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, - { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, - { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, - { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, - { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, - { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, - { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, - { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, - { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, - { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, - { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, - { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, - { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, - { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, - { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, - { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, - { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, - { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, - { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, - { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, - { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, - { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, - { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, - { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, - { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, - { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, - { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, - { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, - { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, - { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, - { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, - { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, - { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, - { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, - { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, - { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, - { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, - { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, - { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, - { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, - { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" }, + { url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" }, + { url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" }, + { url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" }, + { url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" }, + { url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" }, + { url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" }, + { url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" }, + { url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" }, + { url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" }, + { url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" }, + { url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" }, + { url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/3184a1d34a8be665569eadb7e9e764b4629e4f3413e241cb2e4d6fecf3b3/aiohttp-3.14.5-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5", size = 540673, upload-time = "2026-10-11T01:01:41.354Z" }, + { url = "https://files.pythonhosted.org/packages/60/2a/d35f3ba4cf157b072e3b674bf9983047ca5ea5173c995d32d877e1191d36/aiohttp-3.14.5-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343", size = 554087, upload-time = "2026-10-11T01:01:43.719Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d2/61a33880ca4eaca95a9c60ca3f6beed15555af1028652dfaac601627787b/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738", size = 522613, upload-time = "2026-10-11T01:01:46.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/de579b299d2225dc2c6fd99d579d91f16c02a913fb5af9a3cf2fe9bd88ba/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c", size = 529682, upload-time = "2026-10-11T01:01:48.477Z" }, + { url = "https://files.pythonhosted.org/packages/f4/4a/ddb923564e15e053b6e060b0036e1694dcadcb13aa476c5a87dcad20e336/aiohttp-3.14.5-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3", size = 540120, upload-time = "2026-10-11T01:01:50.474Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/a640fbecaa53727a5900b892bdbe17b5f3e8cc88903e22864fe41b654def/aiohttp-3.14.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9", size = 818795, upload-time = "2026-10-11T01:01:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b6/d52ca608859e271b5fa7944074802dc45f60e52c318a4ddc34edbf73586e/aiohttp-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db", size = 549667, upload-time = "2026-10-11T01:01:54.65Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/05b8ac39a76ff4bca89f42a4c2471560c71f894ff6e4bc16158c951874e3/aiohttp-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba", size = 548922, upload-time = "2026-10-11T01:01:56.547Z" }, + { url = "https://files.pythonhosted.org/packages/19/b0/5aa186d56ce2334dabe29b70bd99dc8ae926ee44184c0de64a53d415a4f3/aiohttp-3.14.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382", size = 1882422, upload-time = "2026-10-11T01:01:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/db/f7/7d5c91bb9620db300c8ddb05337a9014301acb626223faff3abcb8ea47d7/aiohttp-3.14.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4", size = 1843621, upload-time = "2026-10-11T01:02:01.417Z" }, + { url = "https://files.pythonhosted.org/packages/30/0a/b208953b96d8f24b75f6da704f508e6c5cf3022f52b60c61933df082e89c/aiohttp-3.14.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1", size = 1927915, upload-time = "2026-10-11T01:02:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/f6/79/90ebcccb55e2d1e11a1fed581d83bb966e38fb35fb4b2577fdc980f8707a/aiohttp-3.14.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677", size = 2036339, upload-time = "2026-10-11T01:02:06.046Z" }, + { url = "https://files.pythonhosted.org/packages/a6/66/55a8904b3a129fafdf94f9cc0a2e4ca09a3c914650be52355db7ad0bbdb6/aiohttp-3.14.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c", size = 1888041, upload-time = "2026-10-11T01:02:08.384Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b8/96b25da7329a52e42c812b1e8b076386039ec4fc312afa043d173d8147fc/aiohttp-3.14.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622", size = 1703849, upload-time = "2026-10-11T01:02:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/1b/43/fbf976e3ae4c038d6f5c84945ab2150298c2d71201674d4e53d158063e75/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3", size = 1849703, upload-time = "2026-10-11T01:02:13.15Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7d/218e912f4c1d89bde7ac551409be57ad2f6121638e56942d395a7cb1fa58/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0", size = 1852695, upload-time = "2026-10-11T01:02:15.295Z" }, + { url = "https://files.pythonhosted.org/packages/5a/42/252a1b9287e3b6e393a1c3bd1776f36af30f5f25f071bbf2b0cb7eba9116/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480", size = 1893493, upload-time = "2026-10-11T01:02:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/78/97/71cae83d5100556fad1521684f7cd1e3e578432644f850245ed3bd969310/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f", size = 1693468, upload-time = "2026-10-11T01:02:20.666Z" }, + { url = "https://files.pythonhosted.org/packages/1f/69/73d88e97a8b5f0ca7a946d0011c0de99fb188b1687c7948ecd0553dc5bf0/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115", size = 1915398, upload-time = "2026-10-11T01:02:23.011Z" }, + { url = "https://files.pythonhosted.org/packages/05/f0/881644bcb15d4b258daea9b720a0af9dc4330496cc8d6ade9090cdd0cffc/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a", size = 1871470, upload-time = "2026-10-11T01:02:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/7f/de/19d9ebbcce5aedaa3242d8a99ff8816a60bdb7629fb0084bf4a45bfd1f62/aiohttp-3.14.5-cp314-cp314-win32.whl", hash = "sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2", size = 485517, upload-time = "2026-10-11T01:02:27.579Z" }, + { url = "https://files.pythonhosted.org/packages/a9/74/8cdaf0e58c2588371670d5a9a8215bbb971d36940b6e5d051967dd05c07d/aiohttp-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67", size = 511755, upload-time = "2026-10-11T01:02:29.685Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6b/e0100e25502430a531c7cf1482a378d0b65bf728ab60c01ee270e56bc469/aiohttp-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7", size = 495151, upload-time = "2026-10-11T01:02:32.163Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c2/ca2ead7b655688c53c03aeeb6e96e6851c9ff08d6be7b13802f53a6ae8fd/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed", size = 855396, upload-time = "2026-10-11T01:02:34.443Z" }, + { url = "https://files.pythonhosted.org/packages/a7/70/22206fea409255a240c926ce11de48de354ae2bb90ca44f709c05497585d/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781", size = 567734, upload-time = "2026-10-11T01:02:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e5/79a36c118308b56f8667d67e05d2fb6dc638ab45985704cdb199631bedaa/aiohttp-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550", size = 567379, upload-time = "2026-10-11T01:02:38.757Z" }, + { url = "https://files.pythonhosted.org/packages/63/a3/2ebec7dece3b1f02c30d2e484647f6f7b13952b1bb40a4cb285b849e8432/aiohttp-3.14.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d", size = 2050686, upload-time = "2026-10-11T01:02:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/7e4d093db2f4450482652e7ef19a9e19919028f5135aa52bc4078c3beb80/aiohttp-3.14.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863", size = 1915077, upload-time = "2026-10-11T01:02:43.563Z" }, + { url = "https://files.pythonhosted.org/packages/a6/88/bd40d09958442a0a1df67da81de496361d6e2afc04f9db2950d835da750b/aiohttp-3.14.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99", size = 2027204, upload-time = "2026-10-11T01:02:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/3a601c1f8d3103c1a60ea981f20924855da9f575d2007fc38f8898b792fb/aiohttp-3.14.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5", size = 2149520, upload-time = "2026-10-11T01:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/22/d0/4e41bfe1b1ce1cb6f6d2e59fa7a88ef5cf92c402b2d07e9018778ba9edbc/aiohttp-3.14.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f", size = 2002039, upload-time = "2026-10-11T01:02:51.293Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/72067019545c502b881b031153c437752ecef48d7d213bc0218ccebb4bfb/aiohttp-3.14.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0", size = 1786164, upload-time = "2026-10-11T01:02:53.533Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fe/7741efd6119bfb7a00827fe6f7b84b4409de58d888ae21adc5a9a6824992/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d", size = 1970689, upload-time = "2026-10-11T01:02:55.888Z" }, + { url = "https://files.pythonhosted.org/packages/43/e7/342a13bf67f34d269bf2f7e870ecd72b99c832cc6f0a271a9210c0ebfb84/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658", size = 1951967, upload-time = "2026-10-11T01:02:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/fdb3b27340617e5e64df18a70fa89097778652ea5c7c7e2f79def76999c3/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84", size = 1968511, upload-time = "2026-10-11T01:03:00.883Z" }, + { url = "https://files.pythonhosted.org/packages/83/b2/e8f88298de78d1a951f36f9f966d38ec6ed1d4721303ba02064a545e8aa6/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441", size = 1778497, upload-time = "2026-10-11T01:03:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/22/68/9ccdb93d664345c546be7f34480b921774d8c0d98f47e70d7e03b115d475/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210", size = 1999676, upload-time = "2026-10-11T01:03:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/57/4a/a33cfa6dcb00e94194ae4fe710432ca4ed111e016356b4ba4d2f4c3a124c/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d", size = 1963437, upload-time = "2026-10-11T01:03:08.196Z" }, + { url = "https://files.pythonhosted.org/packages/f4/20/eacbecfea3b5c3dcbfc9b023e3a5460f43e5dda16d06a2877ebe7184c3f3/aiohttp-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab", size = 503973, upload-time = "2026-10-11T01:03:10.481Z" }, + { url = "https://files.pythonhosted.org/packages/ff/78/18eec294f6c8c5dc845dcf6d730a0147d8d0f17e86138a7bdb85e43a30fa/aiohttp-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3", size = 531190, upload-time = "2026-10-11T01:03:12.716Z" }, + { url = "https://files.pythonhosted.org/packages/9d/39/e53f8169acc85271ebd12b5b32ad7f1541b35639ccbe0f49034c64785d10/aiohttp-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e", size = 510360, upload-time = "2026-10-11T01:03:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1c/06d89f58b2db3ee92dd377217659d587e06f973e57bf0a97a0d8a4586c0c/aiohttp-3.14.5-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:788ecaa9c10533b786ce5ba70c4f2df78ad41819fd00a6c99d92b66f9a32e1da", size = 540674, upload-time = "2026-10-11T01:03:17.483Z" }, + { url = "https://files.pythonhosted.org/packages/18/39/5e822e038f496f0540ada91e27099d48f9e7f919b6c6deb4cf6db36cc706/aiohttp-3.14.5-cp315-cp315-android_24_x86_64.whl", hash = "sha256:5c76f1802bab718a68ac3cce447160605c734551f95c67ae90fa1132b215cb29", size = 554183, upload-time = "2026-10-11T01:03:19.71Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/0cf2619d902b5b160762422a7e5e02091295766a7fe4fcf5b9a655e386c1/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a6d02b4c38de03d9c7617813433e6a0fb6b522797974177d69d9dad431900833", size = 523160, upload-time = "2026-10-11T01:03:22.193Z" }, + { url = "https://files.pythonhosted.org/packages/86/99/3553abfc53a40849dbaacc3f54c730ec58410809ffa2d05885ae56108f2d/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:20726f9782d5c2744c1c66255842d1d163bb3edcf768b8de25216bf47f7b6ccf", size = 530311, upload-time = "2026-10-11T01:03:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a4/5d25f73754bc1e8f983ba704e86d641aea290c967f195f2aeebdaed2bd84/aiohttp-3.14.5-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:248d779ad720b49d4fb355720e60c9e5f444f95887bc16974fea48fc56c41789", size = 540274, upload-time = "2026-10-11T01:03:26.698Z" }, + { url = "https://files.pythonhosted.org/packages/29/a4/07eda5db2e3ee017d9590f36c12a94ec6f1f50516e8df78672373dfc7185/aiohttp-3.14.5-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0a8ea271867e360ac985ae607f4a23ad9a38414b9aca1d49ec98839ae660e49f", size = 819239, upload-time = "2026-10-11T01:03:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/a8723dd987a696dd48d4cf2f0088e589ce77caebff0b96f2a78c20424380/aiohttp-3.14.5-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:823c910f046f23f4c713b8d99a2242dc65f591cb45ee86418fa11762a3c2963c", size = 549832, upload-time = "2026-10-11T01:03:32.02Z" }, + { url = "https://files.pythonhosted.org/packages/29/5c/969a1b72692055fd2a419590c41847ec9144fefb97b75ff1cde5b6372891/aiohttp-3.14.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9b42db919715e91eb76acf3bc492a9a7ccd8bd9adc6745c1412b689735269f14", size = 549234, upload-time = "2026-10-11T01:03:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/8e3e07fd8a0d33d06955ff4e54a1cb92f4bce347ff55e441dc3e25a7b5e5/aiohttp-3.14.5-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3112585250b199296c26ca6e0131640b6a8d01bab8b232d2eb3763ed469de11", size = 1891314, upload-time = "2026-10-11T01:03:36.899Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b3/05a79ce2e25f024e93de30c94f39dc6aa6e2bc1e9c531a5c4b18dc61b7a4/aiohttp-3.14.5-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c147451b4a58e7050f7f7394e6c467867c84161560001f9ad4fb2d1446743946", size = 1884372, upload-time = "2026-10-11T01:03:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/94/52/0fd8af0717db109eea258191b326b5cb5847fb88928bfa6c862fd78b9ad3/aiohttp-3.14.5-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bf163cc701f3d4ac43ba7d97771bf5fd955220ef5500ef3ee847bc0ecfbf4ec1", size = 1930957, upload-time = "2026-10-11T01:03:42.172Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b3/fa78733da88812bf9fb193913fb0ce1548f8b6912047633fdf88a758ff8c/aiohttp-3.14.5-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d40ce41991e9d56fab4f5dc4a51fe59bc3b5c77c27f4b148963064d00232e8", size = 2038886, upload-time = "2026-10-11T01:03:44.646Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f5/2fcc5e30053a938286f17d0edf3f0850b8061b984256fa7c26850b9c8809/aiohttp-3.14.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:276a4fc00b1d9ae492b802763a789c5b86328b989c5ea169f2faa447d6a11c7c", size = 1894470, upload-time = "2026-10-11T01:03:47.304Z" }, + { url = "https://files.pythonhosted.org/packages/62/2a/f87feb42abe8e6a7c03814dbcb711540849a1e90aa392055f3183c643610/aiohttp-3.14.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50983e3be33d8c0942ab88cec3905b10602f64c469b20153c48c5d4e558dd016", size = 1716273, upload-time = "2026-10-11T01:03:50.121Z" }, + { url = "https://files.pythonhosted.org/packages/81/b2/adf1f960dd977722ed1347d33da512a1624807114f57a3b91f5cc828e081/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:16c8abd5bca220a47efe667d26f8460124c81810787e79ee87b242677563d9dd", size = 1857513, upload-time = "2026-10-11T01:03:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/ef8d910e641de4160026a513ace5888b7f92826bbc3fd90ced05d55a828e/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0790ec66fa4013e83c53b9025a45d454723da1a2fce28b3208c9b32d08af162f", size = 1887704, upload-time = "2026-10-11T01:03:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/7e/db/6c9142f941cba8d35be8e1fae6ea2bd390e754fc14076b8147aee1a4592f/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:cb11a971a3aea10f9b8373be628f1df932964fc6c6b174516d318a48c3ac4412", size = 1895684, upload-time = "2026-10-11T01:03:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/e6/7c/6a6bd9a72e576d376c668333b00c51c6147aba4fb863ed6c94996d497eca/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:932ce7e694bbc29b2bf6f64f2343c27d148d4997c771d01bdade4639b6749ff4", size = 1706299, upload-time = "2026-10-11T01:04:00.8Z" }, + { url = "https://files.pythonhosted.org/packages/69/ec/d2cc494242f8c4d3d1cd70baf591742818a74386dc3b84af3195c5c4fced/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:a7d470cf7b206e6359fc77b1b860632fde400d5a2ed59cd0181b93a686bc81ee", size = 1916645, upload-time = "2026-10-11T01:04:03.892Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6e/e852c53647e815db09a1b6b5ab634f54bd736412397e11940feef4d2c89a/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6e1d8637cf73eebc92eba2e11d4cfff98a3b562f2505bd75bba766d908926e8d", size = 1875408, upload-time = "2026-10-11T01:04:06.922Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f6/72ab6ef20c332399be593bac543d2c24a6d241e39d3540ce9f95a63e4bd2/aiohttp-3.14.5-cp315-cp315-win32.whl", hash = "sha256:fbdc5ec49f9ca3cd24955cf3520b10a4d4c901ba2572094c84274e9e7eb30534", size = 485590, upload-time = "2026-10-11T01:04:09.626Z" }, + { url = "https://files.pythonhosted.org/packages/06/eb/e9de75b8c6d2170c42c08ff303abf857ea8a6d9d9b6e99b5aba40f15e962/aiohttp-3.14.5-cp315-cp315-win_amd64.whl", hash = "sha256:a9d3983bd6ab7aa1cfd573544ae98df9b6cb6912a5185a198263e024a636861d", size = 511740, upload-time = "2026-10-11T01:04:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/fc/25/455f3c2785eb0d50748cffd0abd07500815f419a9495b14610b7622d8d2d/aiohttp-3.14.5-cp315-cp315-win_arm64.whl", hash = "sha256:e29347c142cf6e99e0dff5e2995ead1d50fa3b51bf37a7c726a7ccfe5419745a", size = 495171, upload-time = "2026-10-11T01:04:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6c/497f0a98782eebfcf0f02a7fbdef5428148bd426027140cbad494cf842b5/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9bf1d5dcc15204d9ec8b8ea4c18fd66e6b80e5de1f4ecbafb3a2f2740f8039d4", size = 855638, upload-time = "2026-10-11T01:04:17.154Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c5/55c0cef2572af9b1ee81608f7c0a1bf74e6c9151a73b04915d933f192335/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:14f04769cfefe4734016a856a83af36133cd17779cef9ae817f812b8ba9d6d51", size = 567455, upload-time = "2026-10-11T01:04:19.702Z" }, + { url = "https://files.pythonhosted.org/packages/4d/47/e1a0e39f4a2b881f6071225afaf94a6547001b5aefc1566734d73c685934/aiohttp-3.14.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6e4251c0ba4624a68a2c11471a1ac54c3306876c21f0ae86de085cc9241c8905", size = 567836, upload-time = "2026-10-11T01:04:22.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1d/817d85836f52b687160064a326e62e037dc42f1ad5b6b5188a70e5c134b5/aiohttp-3.14.5-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4f5cf4dc72a71c4cfa9751b4950be22f733626670230d46e7d606592aa22d59", size = 2051409, upload-time = "2026-10-11T01:04:24.821Z" }, + { url = "https://files.pythonhosted.org/packages/f7/25/e8ea6fc212a9aabce982917346ce8ecab0929c74b60232a056dd11c99da0/aiohttp-3.14.5-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dbf53ae2601b7fd5a93c3944deea3a78d40f495226d582c35ef7a433425ce2b2", size = 1936493, upload-time = "2026-10-11T01:04:27.456Z" }, + { url = "https://files.pythonhosted.org/packages/24/33/de0517f71f1a19feea4aff78a2ec4ec2d98634129ec30ead78fce2f8f81b/aiohttp-3.14.5-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:657291433bf4dd3142f3abac495764cd47d0c7c92087751e6666c6447e65fcef", size = 2021405, upload-time = "2026-10-11T01:04:30.276Z" }, + { url = "https://files.pythonhosted.org/packages/c4/11/ddaf2e7930543e9f0cad3a1c54e1af0c1bd2fae3973d568c4263d3b010e9/aiohttp-3.14.5-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e51a27980c3788e6e6b3325d694fdd4898087fa8a86b2763af77b39353da41e", size = 2142017, upload-time = "2026-10-11T01:04:33.022Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7b/58784353c06de8adc20f426daad3d85dd86fd55331c713a3f4b638c94573/aiohttp-3.14.5-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c7583cd3dfdc7dcc927835b4f6c7faae7ecc1ba3ca5879321621ae2e6f8e84", size = 2002482, upload-time = "2026-10-11T01:04:35.94Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f0/417d9535caa9e165ffe6a53e2347a78107e7c87dcb0e10aca315c3af0344/aiohttp-3.14.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6fdcd6af7e2e51d1ba1b4bea16e97b074bcb7b5dd0246a9d8201341bb28085a0", size = 1791774, upload-time = "2026-10-11T01:04:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/98/01/25e49c2e8a01b9f0e19ca0a8448ad50aa2bdf96c8cd41e92bd45af044784/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c8859a013ae0de1074660992139a1a440df3e6b219b86cf0d3f11c2692bb4fe3", size = 1970358, upload-time = "2026-10-11T01:04:41.66Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fc/c132fd3465b6c7e4ce0193154f602c3e6c46b680e4da7eb3bd0d8a40d1c7/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:8966ecac808dd5f473c9c4cefd10cd3ffda71c18a4d3493b7c7d2ae1803bf2cc", size = 1969192, upload-time = "2026-10-11T01:04:44.66Z" }, + { url = "https://files.pythonhosted.org/packages/95/4e/d58b45e7dba4eb607eca11fc0a4aa77ae0f39c11afa804635a61ce18cff4/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3093b72c215bda16ce961a6d073f6e71d46e022962a9d5d457c5d4d421c78b57", size = 1963618, upload-time = "2026-10-11T01:04:47.505Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d9/f6ac50946efb3490428ef52b56e62c1c6b7f9c6ff3ec6083535526c83e60/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:149fb56caf7acb67073126f675d0958d9c4b3125fcd3f6d4877df98aa8a97ce9", size = 1784817, upload-time = "2026-10-11T01:04:50.287Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2f/f255eb63da788cd8a452fe350869c03266ccad7d884925f6963c87808f24/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:293d3ae7c6a0ed176a42e59a1b5fde825ead65c835360f734148e96729f928d2", size = 1995956, upload-time = "2026-10-11T01:04:53.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/241aa3393eaafda0034470add1625f82a4c252901108723b283a9b0b32ca/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3f2dcc00191fd563e9075181a14ec31d7dd63223ced7582cc70a15a499de0c79", size = 1967794, upload-time = "2026-10-11T01:04:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/a68e689288c9e4bfcf8d0979f2b12b774bf01861985420df6150eba5448e/aiohttp-3.14.5-cp315-cp315t-win32.whl", hash = "sha256:7779cd97e61ebe583ec2f1c5616cdd038aa08a4453b1848c67842176d054948e", size = 503876, upload-time = "2026-10-11T01:04:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/a4/78/49b0299da6d54de19fc6fdc6889d50233ac47d192a461624f4fc010fde83/aiohttp-3.14.5-cp315-cp315t-win_amd64.whl", hash = "sha256:0e6f16f5e49c4b8267988c05ab07760d7064cea57d077c3d068d04b0fbb992cb", size = 530957, upload-time = "2026-10-11T01:05:02.23Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/b0afc936aeb6d2f93157e3408b069ec5d7934429ae7d023de5e0953b1887/aiohttp-3.14.5-cp315-cp315t-win_arm64.whl", hash = "sha256:1aead151c3abbac6b32942e452020cb66d7efc099d253cc6c20f748e926c858b", size = 510408, upload-time = "2026-10-11T01:05:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, ] [[package]] @@ -330,11 +372,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] [[package]] @@ -1163,101 +1205,140 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +version = "6.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, ] [[package]] @@ -1664,11 +1745,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] @@ -2108,11 +2189,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] @@ -2347,82 +2428,116 @@ wheels = [ [[package]] name = "yarl" -version = "1.24.5" +version = "1.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, - { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, - { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, - { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, - { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, - { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, - { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, - { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, - { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, - { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, - { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, - { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, - { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, - { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, - { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, - { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, - { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, - { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, - { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, - { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, - { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, - { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, - { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, - { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, - { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, - { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, - { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, - { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, - { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, - { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, - { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, - { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, - { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, - { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, - { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, - { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, - { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, - { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, - { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, - { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, - { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, - { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, - { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, - { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, - { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, - { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, ] From 48697d3122b4e6585520e18d431ba2a87b8133f2 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Mon, 26 Oct 2026 10:29:56 +0900 Subject: [PATCH 09/23] docs: record the Android call test and the follow-up fixes --- HANDOFF.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index a2b57ae..eb32703 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -167,9 +167,10 @@ STT/LLM/음향 파이프라인이 끝까지 도는 것을 확인했다. 다만 SHA-1)를 같은 프로젝트에 만들어야 한다. 같은 사람이 iOS(Apple)와 Android(Google)로 가입하면 계정이 따로 생기며 병합하지 않는다. Google 계정 삭제는 OTP 계정처럼 재인증 없이 진행한다. -- **Android 통화·리포트 알림은 `FCM_SERVICE_ACCOUNT_PATH`를 설정해야 나간다.** Firebase 서비스 - 계정 키 JSON을 컨테이너에 읽기 전용으로 넣고 컨테이너 안 경로를 준다. compose에는 아직 이 키의 - bind가 없으므로 배포할 때 APNs 키처럼 `/run/secrets/fcm.json` bind를 추가해야 한다. 값이 비면 +- **Android 통화·리포트 알림은 Firebase 서비스 계정 키가 있어야 나간다.** compose에서는 APNs 키처럼 + `FCM_KEY_FILE`에 호스트 절대 경로를 주면 `/run/secrets/fcm-service-account.json`으로 읽기 전용 + bind되고 `FCM_SERVICE_ACCOUNT_PATH`가 그 경로로 채워진다. compose 없이 직접 실행할 때만 + `FCM_SERVICE_ACCOUNT_PATH`에 키 경로를 준다. 값이 비면 Android 기기는 수신 기기로 치지 않아 Android만 가진 상대에게 거는 통화는 409(수신 기기 없음)이고, 리포트 알림도 Android 기기를 대상에서 뺀다. Android 기기는 `platform=ANDROID`로 FCM 토큰 하나를 `token`·`voipToken`·`pushToken`에 같이 등록한다. 키 JSON은 `.gitignore`가 막는다. @@ -568,7 +569,7 @@ Compose는 ignored `backend/private/`를 `/run/secrets/collog`에 read-only moun | `HF_TOKEN` | Hugging Face 계정에서 HAI-DEF 약관 수락 후 발급 | HeAR detector artifact를 처음 받을 때만 필요 | gated model download. 앱/런타임 API key가 아니며 Git에 넣지 않음 | | `LIVEKIT_API_KEY/SECRET` | 우리가 직접 강한 난수로 생성 | 필수 | self-hosted room token, server API, Egress, webhook 서명 | | `JWT_SECRET` | 우리가 직접 강한 난수로 생성 | 필수 | 콜록 사용자 인증 JWT | -| `FCM_SERVICE_ACCOUNT_PATH` | Firebase Console 프로젝트 설정의 서비스 계정 키(JSON) | Android 통화 수신 시 필수 | FCM 발송. 비밀값이며 Git에 넣지 않음 | +| `FCM_KEY_FILE`(compose) / `FCM_SERVICE_ACCOUNT_PATH`(직접 실행) | Firebase Console 프로젝트 설정의 서비스 계정 키(JSON) | Android 통화 수신 시 필수 | FCM 발송. 비밀값이며 Git에 넣지 않음 | | `GOOGLE_CLIENT_ID` | Google Cloud Console의 Web OAuth 클라이언트 ID | Android Google 로그인 시 필수 | ID 토큰 `aud` 검증. 비밀값 아님 | | APNs `.p8`/Key ID/Team ID/Bundle ID | Apple Developer 발급·확인 | 실기기 백그라운드 수신 시 필수 | PushKit VoIP push | | MinIO access key/secret | 우리가 직접 생성 | Egress 녹음 시 필수 | self-hosted S3 호환 오디오 저장 | @@ -942,6 +943,19 @@ API를 바꿀 때에는 기존 camelCase 계약과 테스트를 유지하고, 지목 시로 한정, 토큰 정리를 해당 기기 행으로 한정, token_uri 고정, 서비스 계정 JSON gitignore. - 2026-10-25: 실기기(에뮬레이터) Google 로그인이 401이었다. 맥 시계가 Google보다 약 4초 느려 막 발급된 ID 토큰의 `iat`가 미래로 보였고, 검증이 시계 오차를 0초 허용했다. Google 검증에 60초 leeway를 - 두고 테스트를 추가했다. Apple 검증(`apple_auth.py`)도 leeway가 0이라 같은 위험이 있으나 손대지 않았다. + 두고 테스트를 추가했다. `backend/.env`가 있으면 pytest도 그 값을 읽어 Apple·팀 포털 테스트가 실패한다. 로컬 실행용 값은 `backend/private/`에 두고 실행할 때만 불러온다. +- 2026-10-26: 에뮬레이터 자녀 → Android 휴대폰 부모 통화에서 양방향 음성까지 로컬 E2E로 확인했다 + (Google 로그인, FCM 수신, 받기, 음성, 끊으면 양쪽 종료). +- 2026-10-26: compose에 FCM 키 bind가 없어, 운영 env에 호스트 경로를 넣으면 컨테이너가 키를 못 읽고 + `AppContainer` 생성에서 기동 실패하던 문제를 고쳤다. `FCM_KEY_FILE` → `/run/secrets/fcm-service-account.json`, + 비우면 `/dev/null`을 bind하고 Android 발송을 끈다(운영·로컬 compose 모두). +- 2026-10-26: 동의 문서를 Android에도 맞게 고치고 버전을 `2026-10-26.v5`로 올렸다. "아이폰 직접 요청"· + "아이폰 기본 음성"을 "휴대폰"으로, 로그인 토큰을 "Apple·Google"로 바꿨다. Android도 질문 음성을 + ElevenLabs에 직접 요청하므로 IP 전달 문구가 Android 사용자에게도 사실이어야 한다. 버전이 바뀌므로 + 배포 후 기존 사용자는 다시 동의해야 녹음·AI 분석이 켜진다(통화는 그대로 된다). +- 2026-10-26: Apple 신원 토큰 검증에도 Google과 같은 60초 leeway를 두고 테스트를 추가했다. +- 2026-10-26: 알려진 취약점이 있던 의존성을 잠금 파일에서 올렸다. pyjwt 2.13.0→2.15.1, + aiohttp 3.14.3→3.14.5, urllib3 2.7.0→2.8.0, certifi→2026.10.23, multidict 6.7.1→6.9.1(yarl 1.25.1 따라옴). + 올린 뒤 `pip-audit` 0건. From 2701435cc1c41758540693ca0cb2cd4438560bf5 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Mon, 26 Oct 2026 11:25:46 +0900 Subject: [PATCH 10/23] fix: keep the consent version constant in step with the settings --- backend/app/consent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/consent.py b/backend/app/consent.py index bb74d79..c376ec7 100644 --- a/backend/app/consent.py +++ b/backend/app/consent.py @@ -1,4 +1,4 @@ -CONSENT_VERSION = "2026-09-13.v4" +CONSENT_VERSION = "2026-10-26.v5" CONSENT_ITEMS = [ "SENSITIVE_HEALTH_COLLECTION", "VOICE_FEATURE_EXTRACTION", From 7d5e7af84a4892c33ee9ec77c58432414fab80b8 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Fri, 30 Oct 2026 14:52:01 +0900 Subject: [PATCH 11/23] feat: add the final-design conditions and a free-text concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final Figma asks for 건망증 · 호흡기 질환 · 비염 plus a free-text "추가적으로 걱정되는 부분". Health profiles now accept FORGETFULNESS, RESPIRATORY and RHINITIS alongside the earlier codes (the iOS app still sends those) and store an optional `concerns` note of up to 500 chars. - Migration 0012 adds parent_profiles.concerns. - PUT keeps the stored note when a client sends only conditions. - Daily question pools for the three new conditions use open questions, as docs/profile-question-report-design.md §5 asks. --- .../alembic/versions/0012_profile_concerns.py | 16 ++++++++++ backend/app/models.py | 1 + backend/app/routes/profiles.py | 23 +++++++++---- backend/app/schemas.py | 22 ++++++++++++- backend/app/services/questions.py | 14 ++++++++ backend/tests/test_health_profile.py | 32 +++++++++++++++++++ 6 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 backend/alembic/versions/0012_profile_concerns.py diff --git a/backend/alembic/versions/0012_profile_concerns.py b/backend/alembic/versions/0012_profile_concerns.py new file mode 100644 index 0000000..4e0c5b8 --- /dev/null +++ b/backend/alembic/versions/0012_profile_concerns.py @@ -0,0 +1,16 @@ +import sqlalchemy as sa +from alembic import op + +revision = "0012_profile_concerns" +down_revision = "0011_google_login" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("parent_profiles") as batch: + batch.add_column(sa.Column("concerns", sa.Text(), nullable=True)) + + +def downgrade() -> None: + raise RuntimeError("Restore a verified backup instead of a destructive downgrade") diff --git a/backend/app/models.py b/backend/app/models.py index d616efe..9c50e49 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -198,6 +198,7 @@ class ParentProfile(Base): parent_id: Mapped[str] = mapped_column(ForeignKey("users.id"), primary_key=True) conditions: Mapped[list[str]] = mapped_column(JSON, default=list) + concerns: Mapped[str | None] = mapped_column(Text, nullable=True) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/routes/profiles.py b/backend/app/routes/profiles.py index 3409753..39e0f76 100644 --- a/backend/app/routes/profiles.py +++ b/backend/app/routes/profiles.py @@ -23,13 +23,14 @@ async def get_profile( await ensure_report_access(session, user, parent_id) profile = await session.get(ParentProfile, parent_id) if profile is None: - return {"parentId": parent_id, "conditions": [], "updatedAt": None, "isCompleted": False} - return { - "parentId": profile.parent_id, - "conditions": profile.conditions, - "updatedAt": profile.updated_at, - "isCompleted": True, - } + return { + "parentId": parent_id, + "conditions": [], + "concerns": None, + "updatedAt": None, + "isCompleted": False, + } + return profile_view(profile) @router.put("/parents/{parentId}/profile", tags=["Profile"]) @@ -53,10 +54,18 @@ async def put_profile( else: profile.conditions = payload.conditions profile.updated_at = datetime.now(UTC) + # Older clients send only conditions; keep the stored note unless the field is sent. + if "concerns" in payload.model_fields_set: + profile.concerns = payload.concerns await session.commit() + return profile_view(profile) + + +def profile_view(profile: ParentProfile) -> dict: return { "parentId": profile.parent_id, "conditions": profile.conditions, + "concerns": profile.concerns, "updatedAt": profile.updated_at, "isCompleted": True, } diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 372db82..dd37bcd 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -124,14 +124,34 @@ class ConsentSubmit(ApiModel): agreed_items: list[str] +Condition = Literal[ + # Figma final design (2026-10-30) + "FORGETFULNESS", + "RESPIRATORY", + "RHINITIS", + # earlier list, still sent by the iOS app + "DIABETES", + "HYPERTENSION", + "DYSLIPIDEMIA", + "ASTHMA", + "OBESITY", +] + + class ProfilePut(ApiModel): - conditions: list[Literal["DIABETES", "HYPERTENSION", "DYSLIPIDEMIA", "ASTHMA", "OBESITY"]] + conditions: list[Condition] + concerns: str | None = Field(default=None, max_length=500) @field_validator("conditions") @classmethod def unique_conditions(cls, value: list[str]) -> list[str]: return list(dict.fromkeys(value)) + @field_validator("concerns") + @classmethod + def blank_concerns(cls, value: str | None) -> str | None: + return value.strip() or None if value is not None else None + class QuestionView(ApiModel): question_id: str diff --git a/backend/app/services/questions.py b/backend/app/services/questions.py index 5faafe1..88d52ca 100644 --- a/backend/app/services/questions.py +++ b/backend/app/services/questions.py @@ -7,6 +7,20 @@ from app.schemas import QuestionView QUESTION_POOL: dict[str, list[tuple[str, str]]] = { + # Open questions (docs/profile-question-report-design.md §5): yes/no answers leave no record. + "FORGETFULNESS": [ + ("forgetfulness-day", "오늘은 어떤 일을 하며 지내셨어요?"), + ("forgetfulness-med", "오늘 약은 언제 챙겨 드셨어요?"), + ("forgetfulness-recall", "어제 저녁에는 뭐 드셨어요?"), + ], + "RESPIRATORY": [ + ("respiratory-breath", "오늘 숨쉬기는 좀 어떠셨어요?"), + ("respiratory-cough", "요즘 기침이나 가래는 어떠세요?"), + ], + "RHINITIS": [ + ("rhinitis-nose", "요즘 코 상태는 어떠세요?"), + ("rhinitis-sleep", "코가 불편할 때 밤에는 어떻게 주무세요?"), + ], "DIABETES": [ ("diabetes-walk", "오늘 산책이나 가벼운 운동 하셨어요?"), ("diabetes-meal", "오늘 식사는 제때 챙겨 드셨어요?"), diff --git a/backend/tests/test_health_profile.py b/backend/tests/test_health_profile.py index 988755e..b91a729 100644 --- a/backend/tests/test_health_profile.py +++ b/backend/tests/test_health_profile.py @@ -38,3 +38,35 @@ def test_child_can_clear_selected_parent_profile(client: TestClient) -> None: rejected = client.put(path, headers=auth(outsider_token), json={"conditions": ["DIABETES"]}) assert rejected.status_code == 403 assert client.get(path, headers=auth(parent_token)).json()["conditions"] == [] + + +def test_final_design_conditions_and_concerns(client: TestClient) -> None: + _, _, parent_token, parent = onboard_family(client) + path = f"/v1/parents/{parent['id']}/profile" + saved = client.put( + path, + headers=auth(parent_token), + json={"conditions": ["FORGETFULNESS", "RHINITIS"], "concerns": " 밤에 자주 깨세요 "}, + ) + assert saved.status_code == 200, saved.text + assert saved.json()["conditions"] == ["FORGETFULNESS", "RHINITIS"] + assert saved.json()["concerns"] == "밤에 자주 깨세요" + + # The iOS app still sends only conditions; that must not wipe the note. + legacy = client.put(path, headers=auth(parent_token), json={"conditions": ["RESPIRATORY"]}) + assert legacy.status_code == 200, legacy.text + assert legacy.json()["concerns"] == "밤에 자주 깨세요" + + cleared = client.put(path, headers=auth(parent_token), json={"conditions": [], "concerns": " "}) + assert cleared.json()["concerns"] is None + + questions = client.put(path, headers=auth(parent_token), json={"conditions": ["RESPIRATORY"]}) + assert questions.status_code == 200 + daily = client.get(f"/v1/parents/{parent['id']}/daily-questions", headers=auth(parent_token)) + assert daily.status_code == 200 + assert {q["conditionCode"] for q in daily.json()["questions"]} == {"RESPIRATORY"} + + too_long = client.put( + path, headers=auth(parent_token), json={"conditions": [], "concerns": "가" * 501} + ) + assert too_long.status_code == 422 From 256f3cd00426cc0f1c8c8bbbc45af06ddc56335e Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Fri, 30 Oct 2026 15:58:19 +0900 Subject: [PATCH 12/23] docs: plan the move to a symmetric family model --- backend/docs/family-symmetric-plan.md | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 backend/docs/family-symmetric-plan.md diff --git a/backend/docs/family-symmetric-plan.md b/backend/docs/family-symmetric-plan.md new file mode 100644 index 0000000..8c76fff --- /dev/null +++ b/backend/docs/family-symmetric-plan.md @@ -0,0 +1,71 @@ +# 가족 대칭 구조(B안) 이행 계획 + +마지막 갱신: 2026-10-30 · 브랜치 `feat/family-symmetric`(`feat/android-port` 위) + +## 왜 + +피그마 「최종본」(노드 269:3887)과 `profile-question-report-design.md`(08-13)는 부모·자녀 구분 없이 +**가족 누구나 자기 건강 기록을 갖고, 서로 전화하고, 통화한 두 사람을 각각 분석**하는 구조다. +지금 서버는 계정 역할(PARENT/CHILD)에 묶여 있다. + +| 어디 | 지금 | 목표 | +|---|---|---| +| 초대 | 생성 = CHILD, 수락 = PARENT | 가족 누구나 생성, 가족이 없는 누구나 수락 | +| 통화 | 부모-자녀 짝끼리만(양방향은 됨) | 같은 가족 구성원 누구나 | +| 분석·리포트 | 부모만 | 통화한 두 사람 모두(S3) | +| 질환 프로필·오늘의 질문 | 부모만 | 누구나 자기 것 | +| 가입 정보 | 이름만 | 닉네임·성별·생년월일 | + +## 단계 + +### S1 가족 대칭 (작음) +- 한 사람은 한 가족에만 속한다(3대 가족도 한 묶음). 가족이 없으면 첫 초대 때 만든다. +- 초대 생성: 가족 구성원 누구나. 초대 수락: 가족이 없거나, 자기 혼자뿐인 가족에만 있는 사람 누구나 + (혼자뿐인 가족은 정리한다). 다른 사람이 있는 가족에 이미 속해 있으면 409. +- 구성원 목록: 만든 사람과 구성원을 같은 형식으로 준다. `canInvite` = 구성원 누구나. + 구성원마다 `acceptedAt`, 동의 상태를 준다. +- 통화: 같은 가족 구성원 둘이면 누구나. `calls.parent_id` 는 S3 전까지 **분석 대상**으로 쓴다 — + 두 사람 중 PARENT 역할이 정확히 하나면 그 사람(지금 iOS 동작 유지), 아니면 받는 사람. + `child_id` 는 다른 한 사람. 컬럼 이름은 호환을 위해 그대로 둔다. +- 접근 권한: `/parents/{parentId}/...` 의 parentId 를 「대상 사용자」로 읽는다. 본인 또는 같은 + 가족 구성원(S4 공유 범위 안)이면 허용. 역할 검사는 없앤다. 경로는 iOS 호환을 위해 유지. +- 역할 칸(`users.role`)은 응답 호환용으로만 남긴다. 새 안드로이드 가입은 역할을 보내지 않는다. + +### S2 사용자 정보·본인 질환 (작음) +- `users` 에 `nickname`(유일, 2~10자 한글·영문·숫자), `gender`(`MALE`/`FEMALE`), `birth_date` 추가(기존 + 사용자를 위해 nullable). `UserView` 에 같은 칸과 `signupCompleted`(닉네임 있음) 추가. +- `GET /v1/account/nickname-availability?nickname=` → `{available, reason}`. +- `PATCH /v1/account/profile` `{nickname, gender, birthDate}` → `UserView`. 닉네임 중복이면 409. +- 질환 프로필(`/parents/{id}/profile`)을 누구나 자기 것으로 쓴다. 같은 가족 구성원도 편집할 수 있다 + (설계 문서의 「가족 제안 → 본인 확인」은 S3 뒤로 미룬다). 저장 조건 = 대상 본인의 분석 동의. +- 이메일: Google·카카오 로그인에서 받은 값을 `users.email`(nullable)에 저장해 계정 정보에 보인다. 변경은 없다. + +### S3 두 사람 분석 (큼 — 팀·비용 확인 뒤) +- `CallParticipant(callId, userId, role, analysisStatus, speechSec)`, 참여자별 STT·추출·음향, 대상별 리포트. +- 오늘의 질문 대상 = 받는 사람의 프로필. 원본 폐기는 두 사람 분석이 모두 끝난 뒤. +- 분석 비용이 대략 두 배가 된다 → `operating-cost.html` 기준으로 다시 계산하고 팀 결정 뒤 착수. + +### S4 구성원 관리·공유 범위 (중간) +- `DELETE /v1/families/{familyId}/members/{memberId}`: 대기 중 초대는 구성원 누구나, 연결된 구성원은 + 가족을 만든 사람 또는 본인(나가기). +- 공유 범위: 각자 자기 기록을 어느 구성원에게 보일지 정한다. `family_shares(owner, viewer, report, timeline)`, + 기본값 모두 허용. `GET/PUT /v1/account/sharing`. S1 접근 권한이 이것을 본다. +- 마케팅 수신 동의: `users.marketing_opt_in` + `PATCH /v1/account/preferences`. + +### S5 카카오 로그인 (카카오 디벨로퍼스 앱 키가 생긴 뒤) +- `POST /v1/auth/kakao` — 안드로이드 SDK 가 받은 토큰을 서버가 카카오에 확인. Google 로그인과 같은 계정 연결 방식. + +### S6 PASS 본인인증 (대행사 계약 뒤) +- 계약 전까지 앱의 PASS 화면은 꺼 둔다. + +## 안드로이드 (Collog-Android `main`) +| 단계 | 내용 | 필요한 서버 | +|---|---|---| +| A3 설정 | 메인 4줄, 계정 정보, 가족 구성원 관리, 가족 공유, 질환 프로필 요약 | S1·S2·S4 | +| A4 가입 | 온보딩 3장 → 로그인 → 약관 → (PASS, 꺼 둠) → 닉네임·성별·생년월일·초대 코드 → 본인 질환 → 홈. 역할 선택 없음 | S1·S2 | +| A5 카카오 로그인 | 온보딩 「카카오로 계속하기」 | S5 | +| A6 통화 분석·리포트 「나」 | 통화 직후 분석 화면, 리포트·타임라인에 본인 포함 | S3 | + +## 병합 +`feat/android-port`(PR #6) 다음에 이 브랜치를 검토받는다. `main` 병합은 서버 담당이 한다(main push = 운영 배포). +마이그레이션은 `0013` 부터. From 51b12dc47a4dde5cd3201f7e41d3f7817931bae2 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Fri, 30 Oct 2026 15:59:10 +0900 Subject: [PATCH 13/23] docs: pin the API contract for the symmetric family model --- backend/docs/family-symmetric-plan.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/docs/family-symmetric-plan.md b/backend/docs/family-symmetric-plan.md index 8c76fff..30c4dda 100644 --- a/backend/docs/family-symmetric-plan.md +++ b/backend/docs/family-symmetric-plan.md @@ -58,6 +58,24 @@ ### S6 PASS 본인인증 (대행사 계약 뒤) - 계약 전까지 앱의 PASS 화면은 꺼 둔다. +## API 약속 (S1·S2·S4, 안드로이드 A3·A4 가 이것에 맞춘다) +JSON 은 기존처럼 camelCase, 경로 접두어 `/v1`. + +- `UserView`(로그인·`/account/*` 응답의 사용자)에 추가: `nickname: str|null`, `gender: "MALE"|"FEMALE"|null`, + `birthDate: "YYYY-MM-DD"|null`, `email: str|null`, `marketingOptIn: bool`, `signupCompleted: bool`(닉네임 있음). +- `GET /v1/account/nickname-availability?nickname=X` → `{"available": bool, "reason": "TAKEN"|"INVALID"|null}`. +- `PATCH /v1/account/profile` `{"nickname", "gender", "birthDate"}`(모두 필수) → `UserView`. 중복 409, 형식 오류 422. +- `PATCH /v1/account/preferences` `{"marketingOptIn": bool}` → `UserView`. +- `GET /v1/account/sharing` → `{"shares": [{"viewerUserId", "viewerName", "relation", "report": bool, "timeline": bool}]}` + (같은 가족의 연결된 구성원마다 한 줄, 기본값 true). `PUT /v1/account/sharing` 같은 모양의 `shares` → 같은 응답. +- `GET /v1/families/{familyId}/members` 각 구성원에 추가: `acceptedAt: datetime|null`, `consentGranted: bool`, + `nickname: str|null`, `birthDate: str|null`, `canRemove: bool`. `canInvite` = 가족 구성원 누구나 true. + 만든 사람도 다른 구성원과 같은 형식(`relation` 은 초대 때 적은 값, 만든 사람은 `"SELF"` 아님 — 만든 사람의 relation 은 비워 두고 `isCreator: true`). +- `DELETE /v1/families/{familyId}/members/{memberId}` → 204. 권한은 S4 규칙. +- `POST /v1/families/{familyId}/invitations`·`POST /v1/invitations/accept`·`POST /v1/calls` 는 모양 그대로, 역할 검사만 없앤다. + 가족이 아직 없는 사람의 첫 초대를 위해 `POST /v1/families` → `{"familyId"}`(이미 있으면 그 가족) 추가. +- `GET/PUT /v1/parents/{userId}/profile` 은 누구나 자기 것·같은 가족 것(질환 `conditions` + `concerns`). + ## 안드로이드 (Collog-Android `main`) | 단계 | 내용 | 필요한 서버 | |---|---|---| From bb73821f2cf4ffd100a09a1457bcff43d513c935 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Fri, 30 Oct 2026 16:14:19 +0900 Subject: [PATCH 14/23] feat: let any family member invite, accept and call S1 of docs/family-symmetric-plan.md. Server decisions no longer look at users.role; the column stays for responses and for picking the analysis subject until S3. - One family per user. Anyone in a family can create or resend an invitation. Anyone with no family, or alone in their own family, can accept (the solo family is removed); someone already in a family with other people gets 409. - POST /v1/families returns the caller's family or creates it. - Member list adds acceptedAt, consentGranted and isCreator, lists the creator in the same shape (empty relation), sets canInvite for everyone and shows invitations to every member. - Calls work between any two members of the same family. calls.parent_id is the analysis subject: the one PARENT if exactly one participant has that role (current iOS behaviour), otherwise the callee. - /parents/{id}/... reads id as the subject user: the subject and same-family members may read it; same-family members may also edit the profile once the subject has consented. - Google login no longer needs a role. Without one the account starts with no family; clients that send a role (iOS) keep the old flow. --- HANDOFF.md | 19 ++- backend/app/auth_router.py | 12 +- backend/app/routes/calls.py | 44 ++--- backend/app/routes/families.py | 123 ++++++++------ backend/app/routes/profiles.py | 16 +- backend/app/routes/questions.py | 4 +- backend/app/routes/reports.py | 8 +- backend/app/schemas.py | 3 +- backend/app/services/domain.py | 68 +++++--- backend/tests/test_families.py | 27 ++-- backend/tests/test_family_management.py | 25 ++- backend/tests/test_family_symmetric.py | 195 +++++++++++++++++++++++ backend/tests/test_google_login.py | 23 +++ backend/tests/test_parent_invitations.py | 15 +- 14 files changed, 445 insertions(+), 137 deletions(-) create mode 100644 backend/tests/test_family_symmetric.py diff --git a/HANDOFF.md b/HANDOFF.md index eb32703..c05b80f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,6 +1,6 @@ # 콜록(Collog) 개발 HANDOFF -마지막 갱신: 2026-10-24 (Asia/Seoul) +마지막 갱신: 2026-10-30 (Asia/Seoul) 이 문서는 콜록 개발의 단일 인수인계 기준이다. 구현, 계약, 검증 결과, 미완료 항목이 바뀌면 코드와 같은 커밋에서 반드시 이 문서를 갱신한다. 비밀키와 실제 건강정보는 기록하지 않는다. @@ -174,9 +174,12 @@ STT/LLM/음향 파이프라인이 끝까지 도는 것을 확인했다. 다만 Android 기기는 수신 기기로 치지 않아 Android만 가진 상대에게 거는 통화는 409(수신 기기 없음)이고, 리포트 알림도 Android 기기를 대상에서 뺀다. Android 기기는 `platform=ANDROID`로 FCM 토큰 하나를 `token`·`voipToken`·`pushToken`에 같이 등록한다. 키 JSON은 `.gitignore`가 막는다. -- **새 피그마와 현재 백엔드의 건강 주체 모델이 다르다.** 현재 API는 자녀 발신·부모 분석, - 부모 질환 코드 배열에 고정돼 있다. 부모/자녀 양방향 통화와 양 참여자 동시 분석, - 복용약·걱정·출처·본인 확인, Q/A 관찰 event와 리포트 v2는 설계만 완료됐다. +- **가족은 대칭 구조로 바뀌었지만 분석은 아직 한 사람만 한다.** `feat/family-symmetric` + (`backend/docs/family-symmetric-plan.md` S1)에서 초대·수락·통화·프로필·리포트 접근의 역할 검사를 + 없앴다. 한 사람은 한 가족에만 속하고, 같은 가족 구성원 둘이면 누구나 통화한다. `calls.parent_id`는 + S3 전까지 **분석 대상**이다(PARENT 역할이 정확히 하나면 그 사람, 아니면 받는 사람). 두 참여자 동시 + 분석(`CallParticipant`), 복용약·출처·본인 확인, Q/A 관찰 event와 리포트 v2는 설계만 있다. + `users.role`은 응답 호환용과 위 분석 대상 선택에만 쓴다. - **전체 STT segment 보관과 동의 문구가 불일치할 수 있다.** 성공 통화도 `transcripts.segments`에 전체 전사를 저장하지만 화면은 구조화 항목만 기록한다고 안내한다. 보관 기간과 최소 evidence 정책을 결정하고 코드/동의 문서를 함께 맞춰야 한다. @@ -344,6 +347,7 @@ APNs payload에는 `callId/callUUID/callerId/callerName/expiresAt`만 넣는다. | `backend/docs/acoustic-design.md` | 음향 4종 정의, 품질 gate, model, worker와 검증 기준 | | `backend/docs/voice-health-model-research.md` | HeAR/기침 detector 판정, 유사 서비스 비교, 되묻기·난청 한계와 검증 설계 | | `backend/docs/profile-question-report-design.md` | 피그마 기준 건강 프로필→질문→통화 자기보고→주·월간 리포트 계약과 현재 코드 차이 | +| `backend/docs/family-symmetric-plan.md` | 가족 대칭 구조(B안) 이행 단계 S1~S6과 안드로이드가 맞출 API 약속 | | `backend/docs/implementation-plan-v2.md` | 승인된 권장 기본값, Phase 0~7 구현 순서·완료 조건, 실제 질문이 필요한 외부 조건 | | `backend/docs/schema-management-design.md` | 로컬 schema guard와 배포 Alembic의 두 축, 판정 규칙, 가비아 서버 배포 제약 | | `backend/docs/service-proposal-outline.md` | 초기 서비스 기획안을 개조식으로 재구성하고 현재 제품·구현·검증 상태에 맞춰 정정한 문서 | @@ -366,6 +370,7 @@ APNs payload에는 `callId/callUUID/callerId/callerName/expiresAt`만 넣는다. | `backend/deploy/livekit-local.yaml` | Docker 없이 실행하는 단일 노드 LiveKit 설정. Egress 없음 | | `backend/docs/calibration-todo.md` | 보정되지 않은 상수와 측정 계획. 근거와 완료 기준 | | `backend/tests/test_api_flow.py` | 온보딩부터 분석·폐기·리포트까지 E2E API test | +| `backend/tests/test_family_symmetric.py` | 역할 없는 초대·수락(혼자뿐인 가족 정리, 409), 형제 통화, 분석 대상 선택, 프로필·리포트 접근 test | | `backend/tests/test_providers.py` | Deepgram/Gemini/APNs provider unit test | | `backend/tests/test_ai_pipeline.py` | prompt/repeat/acoustic/calendar-week deterministic test | | `backend/tests/test_tts.py` | ElevenLabs 요청 계약, cache/서명 URL, 장애 local 폴백 test | @@ -959,3 +964,9 @@ API를 바꿀 때에는 기존 camelCase 계약과 테스트를 유지하고, - 2026-10-26: 알려진 취약점이 있던 의존성을 잠금 파일에서 올렸다. pyjwt 2.13.0→2.15.1, aiohttp 3.14.3→3.14.5, urllib3 2.7.0→2.8.0, certifi→2026.10.23, multidict 6.7.1→6.9.1(yarl 1.25.1 따라옴). 올린 뒤 `pip-audit` 0건. +- 2026-10-30: 가족 대칭 구조 S1. 초대 생성·재발송은 가족 구성원 누구나, 수락은 가족이 없거나 혼자뿐인 + 가족에 있는 사람 누구나(그 가족은 정리), 다른 사람이 있는 가족에 이미 있으면 409. `POST /v1/families`가 + 내 가족을 주거나 만든다. 구성원 목록에 `acceptedAt`·`consentGranted`·`isCreator`를 더하고 만든 사람도 + 같은 모양(`relation` 빈 문자열)으로 주며 `canInvite`는 누구나 true, 초대 코드도 구성원 모두에게 보인다. + 통화는 같은 가족 둘이면 누구나, `/parents/{id}/...`는 본인 또는 같은 가족이면 허용한다. Google 로그인은 + 역할을 안 보내도 되고, 그때는 로그인 때 가족을 만들지 않는다(iOS처럼 역할을 보내면 예전대로). diff --git a/backend/app/auth_router.py b/backend/app/auth_router.py index 272b89d..861c017 100644 --- a/backend/app/auth_router.py +++ b/backend/app/auth_router.py @@ -66,8 +66,12 @@ async def token_response( ) -async def create_session(session: SessionDep, user: User, settings: Settings) -> TokenResponse: - if user.role == UserRole.CHILD.value and await family_of(session, user) is None: +async def create_session( + session: SessionDep, user: User, settings: Settings, *, role_sent: bool = True +) -> TokenResponse: + # 역할을 보내는 클라이언트(iOS)의 자녀는 로그인 직후 초대할 가족이 있어야 한다. + # 역할을 보내지 않는 가입은 첫 초대 때 `POST /families`로 가족을 만든다. + if role_sent and user.role == UserRole.CHILD.value and await family_of(session, user) is None: session.add(Family(created_by=user.id)) await session.flush() refresh_token = secrets.token_urlsafe(48) @@ -237,7 +241,7 @@ async def google_login( user = User( google_subject=identity.subject, phone=None, - role=payload.role, + role=payload.role or UserRole.CHILD.value, name=(payload.name or "").strip() or "사용자", ) session.add(user) @@ -250,7 +254,7 @@ async def google_login( raise HTTPException( status.HTTP_409_CONFLICT, "Google 로그인을 다시 시도해주세요" ) from None - return await create_session(session, user, settings) + return await create_session(session, user, settings, role_sent=payload.role is not None) @router.post("/otp/request", status_code=202) diff --git a/backend/app/routes/calls.py b/backend/app/routes/calls.py index 4479ae8..8bb3b80 100644 --- a/backend/app/routes/calls.py +++ b/backend/app/routes/calls.py @@ -14,8 +14,8 @@ from app.schemas import AudioConstraints, CallAccepted, CallAcceptRequest, CallCreate, CallCreated from app.security import CurrentUser, SessionDep from app.services.domain import ( - ensure_child_can_access_parent, - ensure_report_access, + ensure_same_family, + ensure_subject_access, latest_consent, participants_consented, ) @@ -35,37 +35,39 @@ async def create_call( callee = await session.get(User, payload.callee_id) if callee is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "가족 계정을 찾을 수 없습니다") - if user.id == callee.id or user.role == callee.role: - raise HTTPException( - status.HTTP_403_FORBIDDEN, "가족의 부모와 자녀 사이에 통화할 수 있습니다" - ) - parent, child = (user, callee) if user.role == UserRole.PARENT.value else (callee, user) - await ensure_child_can_access_parent(session, child, parent.id) - async with request.app.state.container.calls.reserve_participants([parent.id, child.id]): - return await create_reserved_call(request, user, callee, parent, child, session) + if user.id == callee.id: + raise HTTPException(status.HTTP_403_FORBIDDEN, "자기 자신에게는 전화할 수 없습니다") + await ensure_same_family(session, user.id, callee.id) + async with request.app.state.container.calls.reserve_participants([user.id, callee.id]): + return await create_reserved_call(request, user, callee, session) + + +def analysis_subject(caller: User, callee: User) -> tuple[User, User]: + """S3 전까지 한 통화의 분석 대상(`calls.parent_id`)과 상대(`calls.child_id`). + + 두 사람 중 PARENT 역할이 정확히 하나면 그 사람(지금 iOS 동작), 아니면 받는 사람이다. + """ + parents = [person for person in (caller, callee) if person.role == UserRole.PARENT.value] + subject = parents[0] if len(parents) == 1 else callee + return subject, callee if subject is caller else caller async def create_reserved_call( request: Request, user: User, callee: User, - parent: User, - child: User, session: SessionDep, ) -> dict: - participants = [parent.id, child.id] + participants = [user.id, callee.id] locked = list(await session.scalars( select(User.id).where(User.id.in_(participants)).order_by(User.id).with_for_update() )) if len(locked) != 2: raise HTTPException(status.HTTP_404_NOT_FOUND, "가족 계정을 찾을 수 없습니다") - await session.refresh(parent) - await session.refresh(child) - if parent.role != UserRole.PARENT.value or child.role != UserRole.CHILD.value: - raise HTTPException( - status.HTTP_409_CONFLICT, "사용자 역할이 변경되었습니다. 다시 시도해주세요" - ) - await ensure_child_can_access_parent(session, child, parent.id) + await session.refresh(user) + await session.refresh(callee) + await ensure_same_family(session, user.id, callee.id) + parent, child = analysis_subject(user, callee) busy = await session.scalar( select(CallRecord.id) .where( @@ -346,7 +348,7 @@ async def list_calls( from_: Annotated[date | None, Query(alias="from")] = None, to: date | None = None, ) -> dict: - await ensure_report_access(session, user, parent_id) + await ensure_subject_access(session, user, parent_id) statement = select(CallRecord).where(CallRecord.parent_id == parent_id) if from_: statement = statement.where( diff --git a/backend/app/routes/families.py b/backend/app/routes/families.py index 144baa4..e42dfb8 100644 --- a/backend/app/routes/families.py +++ b/backend/app/routes/families.py @@ -4,17 +4,19 @@ from datetime import UTC, datetime, timedelta from typing import Annotated -from fastapi import APIRouter, HTTPException, Path, Request, status -from sqlalchemy import or_, select, update +from fastapi import APIRouter, HTTPException, Path, Request, Response, status +from sqlalchemy import delete, or_, select, update from app.models import Family, FamilyMember, Invitation, User, UserRole from app.routes.shared import aware, settings_from from app.schemas import InvitationAccept, InvitationCreate -from app.security import CurrentUser, SessionDep, require_role +from app.security import CurrentUser, SessionDep from app.services.domain import ( + connected_family_ids, + consent_status, derived_member_status, - family_for_child, - has_consent, + family_for_user, + is_family_member, latest_invitation, ) @@ -42,6 +44,19 @@ async def get_families(user: CurrentUser, session: SessionDep) -> dict: return {"families": families} +@router.post("/families", tags=["Family"]) +async def create_family(response: Response, user: CurrentUser, session: SessionDep) -> dict: + """가족이 없는 사람의 첫 초대 전에 가족을 만든다. 이미 있으면 그 가족을 준다.""" + await session.scalar(select(User).where(User.id == user.id).with_for_update()) + family = await family_for_user(session, user.id) + if family is None: + family = Family(created_by=user.id) + session.add(family) + await session.commit() + response.status_code = status.HTTP_201_CREATED + return {"familyId": family.id} + + @router.post("/families/{familyId}/invitations", status_code=201, tags=["Family"]) async def create_invitation( family_id: Annotated[str, Path(alias="familyId")], @@ -49,9 +64,8 @@ async def create_invitation( user: CurrentUser, session: SessionDep, ) -> dict: - require_role(user, UserRole.CHILD) - family = await family_for_child(session, user.id) - if family is None or family.id != family_id: + family = await session.get(Family, family_id) + if family is None or not await is_family_member(session, family, user.id): raise HTTPException(status.HTTP_403_FORBIDDEN, "가족 접근 권한이 없습니다") member = FamilyMember( family_id=family.id, @@ -102,17 +116,11 @@ async def get_members( user: CurrentUser, session: SessionDep, ) -> dict: - # 구성원 목록은 그 가족에 속한 사람이면 볼 수 있다. 자녀는 가족을 만든 사람으로, - # 부모는 초대를 수락한 구성원으로 확인한다. + # 구성원 목록은 그 가족에 속한 사람(만든 사람 또는 초대를 수락한 구성원)이면 볼 수 있다. family = await session.get(Family, family_id) - membership = await session.scalar( - select(FamilyMember.id).where( - FamilyMember.family_id == family_id, - FamilyMember.user_id == user.id, - ) - ) - if family is None or (family.created_by != user.id and membership is None): + if family is None or not await is_family_member(session, family, user.id): raise HTTPException(status.HTTP_403_FORBIDDEN, "가족 접근 권한이 없습니다") + version = settings_from(request).consent_document_version members = list( await session.scalars(select(FamilyMember).where(FamilyMember.family_id == family_id)) ) @@ -120,9 +128,7 @@ async def get_members( for member in members: member_user = await session.get(User, member.user_id) if member.user_id else None invitation = await latest_invitation(session, member.id) - member_status = await derived_member_status( - session, member, invitation, settings_from(request).consent_document_version - ) + member_status = await derived_member_status(session, member, invitation, version) output.append( { "memberId": member.id, @@ -131,46 +137,41 @@ async def get_members( "relation": member.relation, "role": member_user.role if member_user else UserRole.PARENT.value, "status": member_status, - "canRegisterConditions": ( - member_status == "CONSENT_GRANTED" - and member_user is not None - and member_user.role == UserRole.PARENT.value - ), + "canRegisterConditions": member_status == "CONSENT_GRANTED", "invitedAt": aware(member.invited_at), "expiresAt": aware(invitation.expires_at) if invitation else None, - "invitation": ( - invitation_dict(invitation) - if invitation and family.created_by == user.id + "acceptedAt": ( + aware(invitation.accepted_at) + if member.user_id and invitation and invitation.accepted_at else None ), + "consentGranted": member_status == "CONSENT_GRANTED", + "isCreator": False, + # 누구나 초대할 수 있으므로 대기 중 초대 코드도 구성원 모두에게 보인다. + "invitation": invitation_dict(invitation) if invitation else None, } ) owner = await session.get(User, family.created_by) if owner: - owner_consent = await has_consent( - session, owner.id, settings_from(request).consent_document_version - ) + owner_status = await consent_status(session, owner.id, version) output.append( { "memberId": owner.id, "userId": owner.id, "name": owner.name, - "relation": owner.role, + "relation": "", "role": owner.role, - "status": ( - "ACTIVE" if owner.role == UserRole.CHILD.value - else "CONSENT_GRANTED" if owner_consent else "AWAITING_CONSENT" - ), - "canRegisterConditions": owner.role == UserRole.PARENT.value and owner_consent, + "status": owner_status, + "canRegisterConditions": owner_status == "CONSENT_GRANTED", "invitedAt": None, "expiresAt": None, + "acceptedAt": aware(family.created_at), + "consentGranted": owner_status == "CONSENT_GRANTED", + "isCreator": True, "invitation": None, } ) - return { - "members": output, - "canInvite": family.created_by == user.id and user.role == UserRole.CHILD.value, - } + return {"members": output, "canInvite": True} @router.post("/invitations/{invitationId}/resend", status_code=201, tags=["Family"]) @@ -179,15 +180,14 @@ async def resend_invitation( user: CurrentUser, session: SessionDep, ) -> dict: - require_role(user, UserRole.CHILD) old = await session.get(Invitation, invitation_id) if old is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "초대를 찾을 수 없습니다") member = await session.scalar( select(FamilyMember).where(FamilyMember.id == old.member_id).with_for_update() ) - family = await family_for_child(session, user.id) - if member is None or family is None or member.family_id != family.id: + family = await session.get(Family, member.family_id) if member else None + if member is None or family is None or not await is_family_member(session, family, user.id): raise HTTPException(status.HTTP_403_FORBIDDEN, "가족 접근 권한이 없습니다") if member.user_id is not None or old.accepted_at is not None: raise HTTPException(status.HTTP_409_CONFLICT, "이미 수락한 초대입니다") @@ -210,7 +210,6 @@ async def resend_invitation( async def accept_invitation( payload: InvitationAccept, request: Request, user: CurrentUser, session: SessionDep ) -> dict: - require_role(user, UserRole.PARENT) invitation = await session.scalar( select(Invitation) .where(Invitation.code == payload.code) @@ -227,16 +226,16 @@ async def accept_invitation( if member.user_id and member.user_id != user.id: raise HTTPException(status.HTTP_409_CONFLICT, "이미 다른 계정이 수락한 초대입니다") await session.refresh(invitation) + version = settings_from(request).consent_document_version if member.user_id == user.id and invitation.accepted_at is not None: return { "familyId": member.family_id, "memberId": member.id, - "status": await derived_member_status( - session, member, invitation, settings_from(request).consent_document_version - ), + "status": await derived_member_status(session, member, invitation, version), } if aware(invitation.expires_at) <= datetime.now(UTC): raise HTTPException(status.HTTP_410_GONE, "만료된 초대예요. 다시 초대를 요청해주세요") + family = await session.get(Family, member.family_id) existing = await session.scalar( select(FamilyMember.id).where( FamilyMember.family_id == member.family_id, @@ -244,8 +243,28 @@ async def accept_invitation( FamilyMember.id != member.id, ) ) - if existing is not None: + if family is None or family.created_by == user.id or existing is not None: raise HTTPException(status.HTTP_409_CONFLICT, "이미 가입한 가족입니다") + # 한 사람은 한 가족에만 속한다. 자기 혼자뿐인 가족은 정리하고, 다른 사람이 있으면 막는다. + current = list(await session.scalars( + select(Family).where( + Family.id.in_(connected_family_ids(user.id)), Family.id != member.family_id + ) + )) + for other in current: + rows = list(await session.scalars( + select(FamilyMember).where(FamilyMember.family_id == other.id).with_for_update() + )) + if {other.created_by, *(row.user_id for row in rows if row.user_id)} - {user.id}: + raise HTTPException( + status.HTTP_409_CONFLICT, + "이미 다른 가족에 속해 있어요. 지금 가족에서 나온 뒤 다시 시도해주세요", + ) + for other in current: + solo_members = select(FamilyMember.id).where(FamilyMember.family_id == other.id) + await session.execute(delete(Invitation).where(Invitation.member_id.in_(solo_members))) + await session.execute(delete(FamilyMember).where(FamilyMember.family_id == other.id)) + await session.execute(delete(Family).where(Family.id == other.id)) assigned = await session.execute( update(FamilyMember) .where(FamilyMember.id == member.id, FamilyMember.user_id.is_(None)) @@ -255,4 +274,8 @@ async def accept_invitation( raise HTTPException(status.HTTP_409_CONFLICT, "이미 수락한 초대입니다") invitation.accepted_at = datetime.now(UTC) await session.commit() - return {"familyId": member.family_id, "memberId": member.id, "status": "AWAITING_CONSENT"} + return { + "familyId": member.family_id, + "memberId": member.id, + "status": await consent_status(session, user.id, version), + } diff --git a/backend/app/routes/profiles.py b/backend/app/routes/profiles.py index 39e0f76..be34f5e 100644 --- a/backend/app/routes/profiles.py +++ b/backend/app/routes/profiles.py @@ -5,11 +5,11 @@ from fastapi import APIRouter, HTTPException, Path, Request, status -from app.models import ParentProfile, UserRole +from app.models import ParentProfile from app.routes.shared import settings_from from app.schemas import ProfilePut from app.security import CurrentUser, SessionDep -from app.services.domain import ensure_child_can_access_parent, ensure_report_access, has_consent +from app.services.domain import ensure_subject_access, has_consent router = APIRouter() @@ -20,7 +20,7 @@ async def get_profile( user: CurrentUser, session: SessionDep, ) -> dict: - await ensure_report_access(session, user, parent_id) + await ensure_subject_access(session, user, parent_id) profile = await session.get(ParentProfile, parent_id) if profile is None: return { @@ -41,12 +41,12 @@ async def put_profile( user: CurrentUser, session: SessionDep, ) -> dict: - if user.role == UserRole.CHILD.value: - await ensure_child_can_access_parent(session, user, parent_id) - elif user.id != parent_id: - raise HTTPException(status.HTTP_403_FORBIDDEN, "프로필 접근 권한이 없습니다") + # 누구나 자기 프로필을 갖고, 같은 가족 구성원도 고칠 수 있다. 저장은 대상 본인이 동의한 뒤에만. + await ensure_subject_access(session, user, parent_id) if not await has_consent(session, parent_id, settings_from(request).consent_document_version): - raise HTTPException(status.HTTP_409_CONFLICT, "부모님 동의가 완료되어야 등록할 수 있어요") + raise HTTPException( + status.HTTP_409_CONFLICT, "프로필 주인의 분석 동의가 완료되어야 등록할 수 있어요" + ) profile = await session.get(ParentProfile, parent_id) if profile is None: profile = ParentProfile(parent_id=parent_id, conditions=payload.conditions) diff --git a/backend/app/routes/questions.py b/backend/app/routes/questions.py index 29f9e14..7076632 100644 --- a/backend/app/routes/questions.py +++ b/backend/app/routes/questions.py @@ -10,7 +10,7 @@ from app.models import CallRecord, CallState, ParentProfile, QuestionTtsGrant, User from app.routes.shared import aware, settings_from from app.security import CurrentUser, SessionDep -from app.services.domain import ensure_report_access, has_consent, participants_consented +from app.services.domain import ensure_subject_access, has_consent, participants_consented from app.services.questions import daily_questions from app.services.storage import LocalStorage from app.services.tts import ElevenLabsDirectTtsGateway, QuestionTtsError @@ -53,7 +53,7 @@ async def get_daily_questions( user: CurrentUser, session: SessionDep, ) -> dict: - await ensure_report_access(session, user, parent_id) + await ensure_subject_access(session, user, parent_id) source, questions = await questions_for_parent(request, session, parent_id, user.id) return {"source": source, "questions": [item.model_dump(by_alias=True) for item in questions]} diff --git a/backend/app/routes/reports.py b/backend/app/routes/reports.py index c9453f4..c16ff0e 100644 --- a/backend/app/routes/reports.py +++ b/backend/app/routes/reports.py @@ -8,7 +8,7 @@ from app.models import Baseline, ChangeSignal from app.security import CurrentUser, SessionDep -from app.services.domain import ensure_report_access +from app.services.domain import ensure_subject_access from app.services.signals import baseline_to_dict, signal_to_dict router = APIRouter() @@ -22,7 +22,7 @@ async def get_baselines( session: SessionDep, kind: Literal["ANCHOR", "ROLLING"] | None = None, ) -> dict: - await ensure_report_access(session, user, parent_id) + await ensure_subject_access(session, user, parent_id) await request.app.state.container.signals.rebuild_baselines(session, parent_id) await session.commit() statement = select(Baseline).where(Baseline.parent_id == parent_id) @@ -39,7 +39,7 @@ async def get_signals( session: SessionDep, filter: Literal["ALL", "PROMOTED", "ACUTE"] = "ALL", ) -> dict: - await ensure_report_access(session, user, parent_id) + await ensure_subject_access(session, user, parent_id) statement = select(ChangeSignal).where(ChangeSignal.parent_id == parent_id) if filter == "PROMOTED": statement = statement.where(ChangeSignal.promoted.is_(True)) @@ -58,5 +58,5 @@ async def get_report( period: Literal["WEEKLY", "MONTHLY"], date_: Annotated[date | None, Query(alias="date")] = None, ) -> dict: - await ensure_report_access(session, user, parent_id) + await ensure_subject_access(session, user, parent_id) return await request.app.state.container.reports.get_or_issue(session, parent_id, period, date_) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index dd37bcd..921aa8b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -73,7 +73,8 @@ class AppleChallengeResponse(ApiModel): class GoogleLoginRequest(ApiModel): id_token: str = Field(min_length=1, max_length=16_384) challenge_id: str = Field(min_length=36, max_length=36) - role: UserRole = UserRole.CHILD + # 새 Android 가입은 역할을 보내지 않는다. 저장은 응답 호환용 CHILD. + role: UserRole | None = None name: str | None = Field(default=None, max_length=80) diff --git a/backend/app/services/domain.py b/backend/app/services/domain.py index 2630849..cc35a25 100644 --- a/backend/app/services/domain.py +++ b/backend/app/services/domain.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime from fastapi import HTTPException, status -from sqlalchemy import or_, select +from sqlalchemy import Select, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.consent import CONSENT_ITEMS, CONSENT_VERSION @@ -37,7 +37,36 @@ async def family_for_parent(session: AsyncSession, parent_id: str) -> Family | N ) +async def family_for_user(session: AsyncSession, user_id: str) -> Family | None: + """역할과 상관없이 사용자가 속한 가족. + + 한 사람은 한 가족에만 속한다. 예전 데이터에서 둘 다 있으면 수락한 가족을 먼저 본다. + """ + return await family_for_parent(session, user_id) or await family_for_child(session, user_id) + + +def connected_family_ids(user_id: str) -> Select[tuple[str]]: + """사용자가 만들었거나 초대를 수락해 들어간 가족의 ID.""" + return ( + select(Family.id) + .outerjoin(FamilyMember, FamilyMember.family_id == Family.id) + .where(or_(Family.created_by == user_id, FamilyMember.user_id == user_id)) + ) + + +async def is_family_member(session: AsyncSession, family: Family, user_id: str) -> bool: + if family.created_by == user_id: + return True + member = await session.scalar( + select(FamilyMember.id).where( + FamilyMember.family_id == family.id, FamilyMember.user_id == user_id + ).limit(1) + ) + return member is not None + + async def family_of(session: AsyncSession, user: User) -> Family | None: + """응답의 `familyId`. 부모 역할 계정의 빈 가족을 숨기는 것은 iOS 호환 때문이다.""" if user.role == "PARENT": joined = await family_for_parent(session, user.id) if joined is not None: @@ -94,15 +123,21 @@ async def latest_invitation(session: AsyncSession, member_id: str) -> Invitation ) +async def consent_status( + session: AsyncSession, user_id: str, version: str = CONSENT_VERSION +) -> str: + consent = await latest_consent(session, user_id) + if consent_is_current(consent, version): + return "CONSENT_GRANTED" if consent.decision == "GRANTED" else "CONSENT_DENIED" + return "AWAITING_CONSENT" + + async def derived_member_status( session: AsyncSession, member: FamilyMember, invitation: Invitation | None = None, version: str = CONSENT_VERSION, ) -> str: if member.user_id: - consent = await latest_consent(session, member.user_id) - if consent_is_current(consent, version): - return "CONSENT_GRANTED" if consent.decision == "GRANTED" else "CONSENT_DENIED" - return "AWAITING_CONSENT" + return await consent_status(session, member.user_id, version) invitation = invitation or await latest_invitation(session, member.id) now = datetime.now(UTC) if invitation and invitation.expires_at.replace(tzinfo=UTC) <= now: @@ -110,29 +145,22 @@ async def derived_member_status( return "AWAITING_CONSENT" -async def ensure_child_can_access_parent( - session: AsyncSession, child: User, parent_id: str -) -> None: - child_families = select(Family.id).outerjoin(FamilyMember).where( - or_(Family.created_by == child.id, FamilyMember.user_id == child.id) - ) +async def ensure_same_family(session: AsyncSession, user_id: str, other_id: str) -> None: shared_family = await session.scalar( select(Family.id) .outerjoin(FamilyMember) .where( - Family.id.in_(child_families), - or_(Family.created_by == parent_id, FamilyMember.user_id == parent_id), + Family.id.in_(connected_family_ids(user_id)), + or_(Family.created_by == other_id, FamilyMember.user_id == other_id), ) .limit(1) ) - if shared_family is None or child.id == parent_id: + if shared_family is None or user_id == other_id: raise HTTPException(status.HTTP_403_FORBIDDEN, "가족 접근 권한이 없습니다") -async def ensure_report_access(session: AsyncSession, user: User, parent_id: str) -> None: - if user.role == "PARENT" and user.id == parent_id: - return - if user.role == "CHILD": - await ensure_child_can_access_parent(session, user, parent_id) +async def ensure_subject_access(session: AsyncSession, user: User, subject_id: str) -> None: + """`/parents/{id}/...`의 id를 대상 사용자로 읽는다. 본인 또는 같은 가족 구성원만 허용한다.""" + if user.id == subject_id: return - raise HTTPException(status.HTTP_403_FORBIDDEN, "열람 권한이 없는 항목입니다") + await ensure_same_family(session, user.id, subject_id) diff --git a/backend/tests/test_families.py b/backend/tests/test_families.py index 38cb729..596d12e 100644 --- a/backend/tests/test_families.py +++ b/backend/tests/test_families.py @@ -3,13 +3,14 @@ from tests.conftest import auth, create_user -def test_parent_can_list_and_revisit_multiple_families(client: TestClient) -> None: +def test_parent_belongs_to_one_family_at_a_time(client: TestClient) -> None: first_token, first = create_user(client, "01011110001", "CHILD", "첫째") second_token, second = create_user(client, "01011110002", "CHILD", "둘째") _, stranger = create_user(client, "01011110003", "CHILD", "다른 가족") parent_token, _ = create_user(client, "01011110004", "PARENT", "부모") assert client.get("/v1/families", headers=auth(parent_token)).json() == {"families": []} + statuses = [] for child_token, child in [(first_token, first), (second_token, second)]: invitation = client.post( f"/v1/families/{child['familyId']}/invitations", @@ -22,20 +23,22 @@ def test_parent_can_list_and_revisit_multiple_families(client: TestClient) -> No headers=auth(parent_token), json={"code": invitation.json()["code"]}, ) - assert accepted.status_code == 200 + statuses.append(accepted.status_code) + # The second family already has someone else in it, so the parent cannot join it too. + assert statuses == [200, 409] response = client.get("/v1/families", headers=auth(parent_token)) assert response.status_code == 200 - assert response.json()["families"] == [ - {"familyId": first["familyId"], "name": "첫째의 가족"}, - {"familyId": second["familyId"], "name": "둘째의 가족"}, - ] - for child in [first, second]: - members = client.get( - f"/v1/families/{child['familyId']}/members", headers=auth(parent_token) - ) - assert members.status_code == 200 - assert any(member["userId"] == child["id"] for member in members.json()["members"]) + assert response.json()["families"] == [{"familyId": first["familyId"], "name": "첫째의 가족"}] + members = client.get(f"/v1/families/{first['familyId']}/members", headers=auth(parent_token)) + assert members.status_code == 200 + assert any(member["userId"] == first["id"] for member in members.json()["members"]) + assert ( + client.get( + f"/v1/families/{second['familyId']}/members", headers=auth(parent_token) + ).status_code + == 403 + ) forbidden = client.get( f"/v1/families/{stranger['familyId']}/members", headers=auth(parent_token) ) diff --git a/backend/tests/test_family_management.py b/backend/tests/test_family_management.py index 30f4430..fd886e8 100644 --- a/backend/tests/test_family_management.py +++ b/backend/tests/test_family_management.py @@ -17,23 +17,34 @@ def setup_family(client): return child_token, child, parent_token, parent, url, invitation.json() -def test_members_include_owner_and_keep_invitation_private(client): +def test_members_include_owner_in_the_same_shape(client): child_token, child, parent_token, parent, url, invitation = setup_family(client) owner_view = client.get(f"{url}/members", headers=auth(child_token)).json() assert owner_view["canInvite"] is True - assert owner_view["members"][0]["invitation"] == invitation - assert owner_view["members"][-1]["userId"] == child["id"] - assert owner_view["members"][-1]["role"] == "CHILD" + pending, owner = owner_view["members"] + assert pending["invitation"] == invitation + assert pending["acceptedAt"] is None + assert pending["consentGranted"] is False + assert pending["isCreator"] is False + assert owner["userId"] == child["id"] + assert owner["role"] == "CHILD" + assert owner["relation"] == "" + assert owner["isCreator"] is True + assert owner["acceptedAt"] is not None + assert set(owner) == set(pending) assert ( client.post( "/v1/invitations/accept", headers=auth(parent_token), json={"code": invitation["code"]} ).status_code == 200 ) + # Anyone in the family can invite, so every member sees the invitations too. parent_view = client.get(f"{url}/members", headers=auth(parent_token)).json() - assert parent_view["canInvite"] is False - assert all(row["invitation"] is None for row in parent_view["members"]) - assert parent_view["members"][0]["userId"] == parent["id"] + assert parent_view["canInvite"] is True + joined = parent_view["members"][0] + assert joined["userId"] == parent["id"] + assert joined["invitation"]["status"] == "ACCEPTED" + assert joined["acceptedAt"] is not None def test_resend_invalidates_previous_code_and_exposes_latest(client): diff --git a/backend/tests/test_family_symmetric.py b/backend/tests/test_family_symmetric.py new file mode 100644 index 0000000..0dccc34 --- /dev/null +++ b/backend/tests/test_family_symmetric.py @@ -0,0 +1,195 @@ +from fastapi.testclient import TestClient +from sqlalchemy import func, select + +from app.models import CallRecord, Family +from tests.conftest import auth, create_user +from tests.test_api_flow import onboard_family +from tests.test_auth import login + + +def grant(client: TestClient, token: str) -> None: + document = client.get("/v1/consents/document").json() + response = client.post( + "/v1/consents", + headers=auth(token), + json={ + "documentVersion": document["version"], + "decision": "GRANT", + "scrolledToEnd": True, + "agreedItems": document["requiredItems"], + }, + ) + assert response.status_code == 201, response.text + + +def invite(client: TestClient, token: str, family_id: str, relation: str = "MOTHER") -> dict: + response = client.post( + f"/v1/families/{family_id}/invitations", + headers=auth(token), + json={"name": "가족", "relation": relation}, + ) + assert response.status_code == 201, response.text + return response.json() + + +def accept(client: TestClient, token: str, code: str): + return client.post("/v1/invitations/accept", headers=auth(token), json={"code": code}) + + +def stored_call(client: TestClient, call_id: str) -> CallRecord: + async def load() -> CallRecord: + async with client.app.state.container.database.sessions() as session: + return await session.get(CallRecord, call_id) + + return client.portal.call(load) + + +def siblings(client: TestClient): + first_token, first = create_user(client, "01020000001", "CHILD", "첫째") + second_token, second = create_user(client, "01020000002", "CHILD", "둘째") + assert second["familyId"] is not None + invitation = invite(client, first_token, first["familyId"]) + joined = accept(client, second_token, invitation["code"]) + assert joined.status_code == 200, joined.text + assert joined.json()["familyId"] == first["familyId"] + for token in (first_token, second_token): + grant(client, token) + return first_token, first, second_token, second + + +def test_siblings_can_call_each_other_and_the_callee_is_the_subject(client: TestClient) -> None: + first_token, first, second_token, second = siblings(client) + created = client.post("/v1/calls", headers=auth(second_token), json={"calleeId": first["id"]}) + assert created.status_code == 201, created.text + body = created.json() + assert body["callerId"] == second["id"] + assert body["calleeId"] == first["id"] + assert body["recordingEnabled"] is True + assert body["rawCaptureRequired"] is False + call = stored_call(client, body["callId"]) + assert (call.parent_id, call.child_id) == (first["id"], second["id"]) + assert call.caller_id == second["id"] + accepted = client.post(f"/v1/calls/{body['callId']}/accept", headers=auth(first_token)) + assert accepted.status_code == 200 + assert accepted.json()["rawCaptureRequired"] is True + view = client.get(f"/v1/calls/{body['callId']}", headers=auth(second_token)).json() + assert (view["callerId"], view["calleeId"]) == (second["id"], first["id"]) + + +def test_parent_stays_the_subject_in_both_directions(client: TestClient) -> None: + child_token, child, parent_token, parent = onboard_family(client) + for token, callee in ((parent_token, child["id"]), (child_token, parent["id"])): + created = client.post("/v1/calls", headers=auth(token), json={"calleeId": callee}) + assert created.status_code == 201, created.text + call = stored_call(client, created.json()["callId"]) + assert (call.parent_id, call.child_id) == (parent["id"], child["id"]) + assert created.json()["calleeId"] == callee + ended = client.post(f"/v1/calls/{call.id}/end", headers=auth(token)) + assert ended.status_code == 200 + + +def test_accepting_dissolves_a_family_with_only_yourself(client: TestClient) -> None: + host_token, host = create_user(client, "01020000003", "CHILD", "초대한 사람") + guest_token, guest = create_user(client, "01020000004", "CHILD", "초대받은 사람") + pending = invite(client, guest_token, guest["familyId"]) + invitation = invite(client, host_token, host["familyId"]) + accepted = accept(client, guest_token, invitation["code"]) + assert accepted.status_code == 200, accepted.text + + async def family_ids() -> set[str]: + async with client.app.state.container.database.sessions() as session: + return set(await session.scalars(select(Family.id))) + + assert client.portal.call(family_ids) == {host["familyId"]} + old = client.get(f"/v1/families/{guest['familyId']}/members", headers=auth(guest_token)) + assert old.status_code == 403 + stranger_token, _ = create_user(client, "01020000005", "PARENT", "다른 사람") + assert accept(client, stranger_token, pending["code"]).status_code == 404 + assert login(client, "01020000004")["user"]["familyId"] == host["familyId"] + members = client.get(f"/v1/families/{host['familyId']}/members", headers=auth(host_token)) + assert {row["userId"] for row in members.json()["members"]} == {host["id"], guest["id"]} + + +def test_creator_of_a_populated_family_cannot_join_another(client: TestClient) -> None: + child_token, child, _, _ = onboard_family(client) + other_token, other = create_user(client, "01020000006", "CHILD", "다른 자녀") + invitation = invite(client, other_token, other["familyId"]) + rejected = accept(client, child_token, invitation["code"]) + assert rejected.status_code == 409 + own = invite(client, child_token, child["familyId"]) + assert accept(client, child_token, own["code"]).status_code == 409 + + +def test_any_member_can_invite_and_post_families_returns_or_creates(client: TestClient) -> None: + _, child, parent_token, _ = onboard_family(client) + invitation = invite(client, parent_token, child["familyId"], "FATHER") + assert invitation["status"] == "PENDING" + resent = client.post( + f"/v1/invitations/{invitation['invitationId']}/resend", headers=auth(parent_token) + ) + assert resent.status_code == 201 + existing = client.post("/v1/families", headers=auth(parent_token)) + assert existing.status_code == 200 + assert existing.json() == {"familyId": child["familyId"]} + + loner_token, loner = create_user(client, "01020000007", "PARENT", "혼자") + assert loner["familyId"] is None + created = client.post("/v1/families", headers=auth(loner_token)) + assert created.status_code == 201 + again = client.post("/v1/families", headers=auth(loner_token)) + assert again.status_code == 200 + assert again.json() == created.json() + assert invite(client, loner_token, created.json()["familyId"])["status"] == "PENDING" + outsider = client.post( + f"/v1/families/{child['familyId']}/invitations", + headers=auth(loner_token), + json={"name": "가족", "relation": "MOTHER"}, + ) + assert outsider.status_code == 403 + + +def test_everyone_owns_a_profile_that_family_members_can_edit(client: TestClient) -> None: + child_token, child, parent_token, parent = onboard_family(client) + own = f"/v1/parents/{child['id']}/profile" + assert client.get(own, headers=auth(child_token)).status_code == 200 + saved = client.put(own, headers=auth(child_token), json={"conditions": ["RHINITIS"]}) + assert saved.status_code == 200, saved.text + edited = client.put(own, headers=auth(parent_token), json={"conditions": ["ASTHMA"]}) + assert edited.status_code == 200 + assert client.get(own, headers=auth(child_token)).json()["conditions"] == ["ASTHMA"] + outsider_token, _ = create_user(client, "01020000008", "PARENT", "남") + for path in ("daily-questions", "reports?period=WEEKLY", "calls", "signals", "baseline"): + for subject in (child["id"], parent["id"]): + viewed = client.get(f"/v1/parents/{subject}/{path}", headers=auth(parent_token)) + assert viewed.status_code == 200, (path, viewed.text) + hidden = client.get(f"/v1/parents/{child['id']}/{path}", headers=auth(outsider_token)) + assert hidden.status_code == 403 + assert client.get(own, headers=auth(outsider_token)).status_code == 403 + + +def test_profile_needs_the_owners_consent(client: TestClient) -> None: + child_token, child = create_user(client, "01020000009", "CHILD", "자녀") + blocked = client.put( + f"/v1/parents/{child['id']}/profile", headers=auth(child_token), json={"conditions": []} + ) + assert blocked.status_code == 409 + + +def test_ios_login_with_a_role_keeps_working(client: TestClient) -> None: + child_token, child = create_user(client, "01020000010", "CHILD", "자녀") + parent_token, parent = create_user(client, "01020000011", "PARENT", "부모") + assert child["role"] == "CHILD" + assert child["familyId"] is not None + assert parent["role"] == "PARENT" + assert parent["familyId"] is None + invitation = invite(client, child_token, child["familyId"]) + assert accept(client, parent_token, invitation["code"]).status_code == 200 + assert login(client, "01020000011")["user"]["familyId"] == child["familyId"] + created = client.post("/v1/calls", headers=auth(child_token), json={"calleeId": parent["id"]}) + assert created.status_code == 201 + + async def count() -> int: + async with client.app.state.container.database.sessions() as session: + return await session.scalar(select(func.count(Family.id))) + + assert client.portal.call(count) == 1 diff --git a/backend/tests/test_google_login.py b/backend/tests/test_google_login.py index 7514313..3f6b71c 100644 --- a/backend/tests/test_google_login.py +++ b/backend/tests/test_google_login.py @@ -180,3 +180,26 @@ async def remaining() -> list[str | None]: return list(await session.scalars(select(User.google_subject))) assert client.portal.call(remaining) == [] + + +def test_signup_without_role_has_no_family_until_the_first_invite( + client: TestClient, sign_google +) -> None: + payload = google_payload(client, sign_google) + del payload["role"] + signed_in = client.post("/v1/auth/google", json=payload) + assert signed_in.status_code == 200, signed_in.text + user = signed_in.json()["user"] + assert user["role"] == "CHILD" + assert user["familyId"] is None + token = signed_in.json()["accessToken"] + created = client.post("/v1/families", headers=auth(token)) + assert created.status_code == 201 + invitation = client.post( + f"/v1/families/{created.json()['familyId']}/invitations", + headers=auth(token), + json={"name": "어머니", "relation": "MOTHER"}, + ) + assert invitation.status_code == 201 + again = client.post("/v1/auth/google", json=google_payload(client, sign_google)) + assert again.json()["user"]["familyId"] == created.json()["familyId"] diff --git a/backend/tests/test_parent_invitations.py b/backend/tests/test_parent_invitations.py index 6e67b3e..c921ea9 100644 --- a/backend/tests/test_parent_invitations.py +++ b/backend/tests/test_parent_invitations.py @@ -32,10 +32,17 @@ def test_parent_role_change_ignores_empty_owned_family(client: TestClient) -> No assert signed_in["user"]["familyId"] != original["familyId"] -def test_parent_uses_most_recently_accepted_family_after_signing_in(client: TestClient) -> None: +def test_parent_in_a_family_cannot_join_another(client: TestClient) -> None: _, old_child, parent_token, _ = onboard_family(client) child_token, child = create_user(client, "01099998888", "CHILD", "다른 자녀") - join_family(client, parent_token, child_token, child["familyId"]) + invitation = client.post( + f"/v1/families/{child['familyId']}/invitations", + headers=auth(child_token), json={"name": "부모", "relation": "MOTHER"}, + ) + rejected = client.post( + "/v1/invitations/accept", headers=auth(parent_token), + json={"code": invitation.json()["code"]}, + ) + assert rejected.status_code == 409 signed_in = login(client, "01033334444") - assert signed_in["user"]["familyId"] == child["familyId"] - assert signed_in["user"]["familyId"] != old_child["familyId"] + assert signed_in["user"]["familyId"] == old_child["familyId"] From 832a2c2fa36c4405f7d73276e1c6f30e4802eb69 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Fri, 30 Oct 2026 16:17:14 +0900 Subject: [PATCH 15/23] feat: add nickname, gender, birth date and email to accounts S2 of docs/family-symmetric-plan.md. - Migration 0013 adds users.nickname (unique), gender, birth_date and email, all nullable for existing accounts. - UserView gains the same fields plus signupCompleted (has a nickname). - GET /v1/account/nickname-availability returns {available, reason} with reason INVALID or TAKEN. Nicknames are 2-10 Hangul syllables, Latin letters or digits. - PATCH /v1/account/profile sets all three fields; a taken nickname is 409 and a malformed one 422. - Google login stores the verified email from the ID token. - The member list shows each person's nickname and birth date. --- HANDOFF.md | 6 ++ .../versions/0013_user_signup_fields.py | 20 +++++ backend/app/account_router.py | 44 +++++++--- backend/app/auth_router.py | 12 +-- backend/app/models.py | 7 ++ backend/app/routes/families.py | 4 + backend/app/schemas.py | 40 ++++++++- backend/app/services/google_auth.py | 8 +- backend/tests/test_account_profile.py | 81 +++++++++++++++++++ backend/tests/test_google_login.py | 22 ++++- 10 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 backend/alembic/versions/0013_user_signup_fields.py create mode 100644 backend/tests/test_account_profile.py diff --git a/HANDOFF.md b/HANDOFF.md index c05b80f..40337c0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -371,6 +371,7 @@ APNs payload에는 `callId/callUUID/callerId/callerName/expiresAt`만 넣는다. | `backend/docs/calibration-todo.md` | 보정되지 않은 상수와 측정 계획. 근거와 완료 기준 | | `backend/tests/test_api_flow.py` | 온보딩부터 분석·폐기·리포트까지 E2E API test | | `backend/tests/test_family_symmetric.py` | 역할 없는 초대·수락(혼자뿐인 가족 정리, 409), 형제 통화, 분석 대상 선택, 프로필·리포트 접근 test | +| `backend/tests/test_account_profile.py` | 닉네임 확인(INVALID·TAKEN), 가입 정보 저장·중복 409·형식 422, 구성원 목록의 닉네임·생년월일 test | | `backend/tests/test_providers.py` | Deepgram/Gemini/APNs provider unit test | | `backend/tests/test_ai_pipeline.py` | prompt/repeat/acoustic/calendar-week deterministic test | | `backend/tests/test_tts.py` | ElevenLabs 요청 계약, cache/서명 URL, 장애 local 폴백 test | @@ -970,3 +971,8 @@ API를 바꿀 때에는 기존 camelCase 계약과 테스트를 유지하고, 같은 모양(`relation` 빈 문자열)으로 주며 `canInvite`는 누구나 true, 초대 코드도 구성원 모두에게 보인다. 통화는 같은 가족 둘이면 누구나, `/parents/{id}/...`는 본인 또는 같은 가족이면 허용한다. Google 로그인은 역할을 안 보내도 되고, 그때는 로그인 때 가족을 만들지 않는다(iOS처럼 역할을 보내면 예전대로). +- 2026-10-30: 가족 대칭 구조 S2. `users`에 `nickname`(유일, 한글 음절·영문·숫자 2~10자)·`gender`·`birth_date`· + `email`을 더했다(migration 0013, 모두 nullable). `UserView`에 같은 칸과 `signupCompleted`(닉네임 있음)를 + 더하고 `GET /v1/account/nickname-availability`, `PATCH /v1/account/profile`(중복 409, 형식 422)을 추가했다. + Google 로그인은 Google이 확인한(`email_verified`) 이메일만 `users.email`에 저장한다. 구성원 목록에 + `nickname`·`birthDate`가 붙는다. 닉네임은 대소문자를 구분한다. diff --git a/backend/alembic/versions/0013_user_signup_fields.py b/backend/alembic/versions/0013_user_signup_fields.py new file mode 100644 index 0000000..1c95e9d --- /dev/null +++ b/backend/alembic/versions/0013_user_signup_fields.py @@ -0,0 +1,20 @@ +import sqlalchemy as sa +from alembic import op + +revision = "0013_user_signup_fields" +down_revision = "0012_profile_concerns" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("users") as batch: + batch.add_column(sa.Column("nickname", sa.String(10), nullable=True)) + batch.add_column(sa.Column("gender", sa.String(8), nullable=True)) + batch.add_column(sa.Column("birth_date", sa.Date(), nullable=True)) + batch.add_column(sa.Column("email", sa.String(320), nullable=True)) + batch.create_index("ix_users_nickname", ["nickname"], unique=True) + + +def downgrade() -> None: + raise RuntimeError("Restore a verified backup instead of a destructive downgrade") diff --git a/backend/app/account_router.py b/backend/app/account_router.py index b15ce91..e731e7f 100644 --- a/backend/app/account_router.py +++ b/backend/app/account_router.py @@ -2,12 +2,14 @@ import hashlib import hmac +import re from datetime import UTC, datetime, timedelta from botocore.exceptions import BotoCoreError, ClientError from fastapi import APIRouter, HTTPException, Request, Response from pydantic import Field from sqlalchemy import and_, delete, or_, select, update +from sqlalchemy.exc import IntegrityError from app.models import ( AcousticAnalysisRun, @@ -36,7 +38,7 @@ User, UserRole, ) -from app.schemas import ApiModel, UserView +from app.schemas import NICKNAME_PATTERN, AccountProfileUpdate, ApiModel, UserView from app.security import CurrentUser, SessionDep from app.services.apple_auth import AppleIdentityError, AppleServiceError from app.services.apple_oauth import apple_client_secret, revoke_apple_authorization @@ -94,18 +96,42 @@ async def update_role( family = Family(created_by=user.id) session.add(family) await session.flush() - result = UserView( - id=user.id, - role=user.role, - name=user.name, - phone=user.phone, - apple_user_id=user.apple_subject, - family_id=family.id if family else None, - ) + result = UserView.of(user, family.id if family else None) await session.commit() return result +@router.get("/nickname-availability") +async def nickname_availability(nickname: str, user: CurrentUser, session: SessionDep) -> dict: + if re.fullmatch(NICKNAME_PATTERN, nickname) is None: + return {"available": False, "reason": "INVALID"} + owner = await session.scalar(select(User.id).where(User.nickname == nickname)) + if owner is not None and owner != user.id: + return {"available": False, "reason": "TAKEN"} + return {"available": True, "reason": None} + + +@router.patch("/profile", response_model=UserView) +async def update_profile( + payload: AccountProfileUpdate, user: CurrentUser, session: SessionDep +) -> UserView: + taken = await session.scalar( + select(User.id).where(User.nickname == payload.nickname, User.id != user.id) + ) + if taken is not None: + raise HTTPException(409, "이미 사용 중인 닉네임이에요") + user.nickname = payload.nickname + user.gender = payload.gender + user.birth_date = payload.birth_date + try: + await session.commit() + except IntegrityError: + await session.rollback() + raise HTTPException(409, "이미 사용 중인 닉네임이에요") from None + family = await family_of(session, user) + return UserView.of(user, family.id if family else None) + + @router.delete("", status_code=204) async def delete_account( request: Request, user: CurrentUser, session: SessionDep, diff --git a/backend/app/auth_router.py b/backend/app/auth_router.py index 861c017..7c3e5e2 100644 --- a/backend/app/auth_router.py +++ b/backend/app/auth_router.py @@ -55,14 +55,7 @@ async def token_response( return TokenResponse( access_token=issue_token(user, settings, auth_session.id), refresh_token=refresh_token, - user=UserView( - id=user.id, - role=user.role, - name=user.name, - phone=user.phone, - apple_user_id=user.apple_subject, - family_id=family.id if family else None, - ), + user=UserView.of(user, family.id if family else None), ) @@ -243,6 +236,7 @@ async def google_login( phone=None, role=payload.role or UserRole.CHILD.value, name=(payload.name or "").strip() or "사용자", + email=identity.email, ) session.add(user) await session.flush() @@ -254,6 +248,8 @@ async def google_login( raise HTTPException( status.HTTP_409_CONFLICT, "Google 로그인을 다시 시도해주세요" ) from None + if identity.email: + user.email = identity.email return await create_session(session, user, settings, role_sent=payload.role is not None) diff --git a/backend/app/models.py b/backend/app/models.py index 9c50e49..f869e81 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -88,6 +88,13 @@ class User(Base): google_subject: Mapped[str | None] = mapped_column( String(255), unique=True, index=True, nullable=True ) + # 가입 정보. 기존 사용자를 위해 비울 수 있고, 닉네임이 있으면 가입을 마친 것으로 본다. + nickname: Mapped[str | None] = mapped_column( + String(10), unique=True, index=True, nullable=True + ) + gender: Mapped[str | None] = mapped_column(String(8), nullable=True) + birth_date: Mapped[date | None] = mapped_column(Date, nullable=True) + email: Mapped[str | None] = mapped_column(String(320), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/routes/families.py b/backend/app/routes/families.py index e42dfb8..5e44d27 100644 --- a/backend/app/routes/families.py +++ b/backend/app/routes/families.py @@ -146,6 +146,8 @@ async def get_members( else None ), "consentGranted": member_status == "CONSENT_GRANTED", + "nickname": member_user.nickname if member_user else None, + "birthDate": member_user.birth_date if member_user else None, "isCreator": False, # 누구나 초대할 수 있으므로 대기 중 초대 코드도 구성원 모두에게 보인다. "invitation": invitation_dict(invitation) if invitation else None, @@ -167,6 +169,8 @@ async def get_members( "expiresAt": None, "acceptedAt": aware(family.created_at), "consentGranted": owner_status == "CONSENT_GRANTED", + "nickname": owner.nickname, + "birthDate": owner.birth_date, "isCreator": True, "invitation": None, } diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 921aa8b..b4042d6 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -3,10 +3,14 @@ from datetime import date, datetime from typing import Annotated, Any, Literal from uuid import UUID +from zoneinfo import ZoneInfo from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_validator -from app.models import BaselineKind, CallState, Metric, TimeSlot, UserRole +from app.models import BaselineKind, CallState, Metric, TimeSlot, User, UserRole + +# 닉네임: 한글 음절·영문·숫자 2~10자. +NICKNAME_PATTERN = r"^[가-힣A-Za-z0-9]{2,10}$" def to_camel(value: str) -> str: @@ -91,6 +95,40 @@ class UserView(ApiModel): phone: str | None = None apple_user_id: str | None = None family_id: str | None = None + nickname: str | None = None + gender: Literal["MALE", "FEMALE"] | None = None + birth_date: date | None = None + email: str | None = None + signup_completed: bool = False + + @classmethod + def of(cls, user: User, family_id: str | None) -> UserView: + return cls( + id=user.id, + role=user.role, + name=user.name, + phone=user.phone, + apple_user_id=user.apple_subject, + family_id=family_id, + nickname=user.nickname, + gender=user.gender, + birth_date=user.birth_date, + email=user.email, + signup_completed=user.nickname is not None, + ) + + +class AccountProfileUpdate(ApiModel): + nickname: str = Field(pattern=NICKNAME_PATTERN) + gender: Literal["MALE", "FEMALE"] + birth_date: date + + @field_validator("birth_date") + @classmethod + def plausible_birth_date(cls, value: date) -> date: + if not date(1900, 1, 1) <= value <= datetime.now(ZoneInfo("Asia/Seoul")).date(): + raise ValueError("생년월일을 확인해주세요") + return value class TokenResponse(ApiModel): diff --git a/backend/app/services/google_auth.py b/backend/app/services/google_auth.py index bcadd72..db9b587 100644 --- a/backend/app/services/google_auth.py +++ b/backend/app/services/google_auth.py @@ -28,6 +28,7 @@ class GoogleServiceError(Exception): class GoogleIdentity: subject: str nonce: str + email: str | None = None class GoogleIdentityVerifier: @@ -116,7 +117,12 @@ async def verify(self, id_token: str) -> GoogleIdentity: raise GoogleIdentityError("Invalid Google subject") if not isinstance(nonce, str) or not nonce or len(nonce) > 255: raise GoogleIdentityError("Invalid Google nonce") - return GoogleIdentity(subject=subject, nonce=nonce) + # 계정 정보에 보여 줄 이메일. Google이 확인한 주소만 받는다. + email = claims.get("email") + verified = claims.get("email_verified") in (True, "true") + if not isinstance(email, str) or not verified or not 0 < len(email) <= 320: + email = None + return GoogleIdentity(subject=subject, nonce=nonce, email=email) # 깊게 중첩된 헤더 JSON은 PyJWT 안에서 RecursionError가 된다(PYSEC-2026-4142). except (jwt.PyJWTError, TypeError, ValueError, RecursionError) as exc: raise GoogleIdentityError("Invalid Google ID token") from exc diff --git a/backend/tests/test_account_profile.py b/backend/tests/test_account_profile.py new file mode 100644 index 0000000..b99672a --- /dev/null +++ b/backend/tests/test_account_profile.py @@ -0,0 +1,81 @@ +from fastapi.testclient import TestClient + +from tests.conftest import auth, create_user +from tests.test_api_flow import onboard_family + +PROFILE = {"nickname": "콜록이1", "gender": "FEMALE", "birthDate": "1958-03-14"} + + +def save(client: TestClient, token: str, body: dict | None = None): + return client.patch("/v1/account/profile", headers=auth(token), json=PROFILE | (body or {})) + + +def available(client: TestClient, token: str, nickname: str) -> dict: + response = client.get( + "/v1/account/nickname-availability", headers=auth(token), params={"nickname": nickname} + ) + assert response.status_code == 200, response.text + return response.json() + + +def test_nickname_availability_reports_invalid_and_taken(client: TestClient) -> None: + first_token, _ = create_user(client, "01030000001", "CHILD", "첫째") + second_token, _ = create_user(client, "01030000002", "CHILD", "둘째") + for nickname in ("a", "가" * 11, "닉 네임", "닉네임!", "ㄱㄴㄷ"): + assert available(client, first_token, nickname) == {"available": False, "reason": "INVALID"} + assert available(client, first_token, "콜록이1") == {"available": True, "reason": None} + assert save(client, first_token).status_code == 200 + assert available(client, second_token, "콜록이1") == {"available": False, "reason": "TAKEN"} + assert available(client, first_token, "콜록이1") == {"available": True, "reason": None} + anonymous = client.get("/v1/account/nickname-availability", params={"nickname": "abc"}) + assert anonymous.status_code == 401 + + +def test_profile_update_returns_the_user_and_rejects_duplicates(client: TestClient) -> None: + first_token, first = create_user(client, "01030000003", "CHILD", "첫째") + second_token, _ = create_user(client, "01030000004", "PARENT", "둘째") + for key in ("nickname", "gender", "birthDate", "email"): + assert first[key] is None + assert first["signupCompleted"] is False + + saved = save(client, first_token) + assert saved.status_code == 200, saved.text + user = saved.json() + assert [user[key] for key in PROFILE] == list(PROFILE.values()) + assert user["signupCompleted"] is True + assert user["familyId"] == first["familyId"] + assert user["role"] == "CHILD" + + assert save(client, second_token).status_code == 409 + again = save(client, first_token, {"gender": "MALE"}) + assert again.status_code == 200 + assert again.json()["gender"] == "MALE" + + +def test_profile_update_validates_every_field(client: TestClient) -> None: + token, _ = create_user(client, "01030000005", "CHILD", "사용자") + for change in ( + {"nickname": "a"}, + {"nickname": "닉네임!"}, + {"gender": "OTHER"}, + {"birthDate": "2999-01-01"}, + {"birthDate": "1850-01-01"}, + {"birthDate": "not-a-date"}, + ): + assert save(client, token, change).status_code == 422, change + for missing in PROFILE: + body = {key: value for key, value in PROFILE.items() if key != missing} + response = client.patch("/v1/account/profile", headers=auth(token), json=body) + assert response.status_code == 422, missing + + +def test_member_list_shows_nickname_and_birth_date(client: TestClient) -> None: + child_token, child, parent_token, parent = onboard_family(client) + assert save(client, parent_token).status_code == 200 + members = client.get( + f"/v1/families/{child['familyId']}/members", headers=auth(child_token) + ).json()["members"] + rows = {row["userId"]: row for row in members} + assert rows[parent["id"]]["nickname"] == PROFILE["nickname"] + assert rows[parent["id"]]["birthDate"] == PROFILE["birthDate"] + assert (rows[child["id"]]["nickname"], rows[child["id"]]["birthDate"]) == (None, None) diff --git a/backend/tests/test_google_login.py b/backend/tests/test_google_login.py index 3f6b71c..ce0130c 100644 --- a/backend/tests/test_google_login.py +++ b/backend/tests/test_google_login.py @@ -19,7 +19,7 @@ @pytest.fixture -def sign_google(client: TestClient) -> Iterator[Callable[[str, str], str]]: +def sign_google(client: TestClient) -> Iterator[Callable[..., str]]: key = rsa.generate_private_key(public_exponent=65537, key_size=2048) jwk = jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key(), as_dict=True) jwk.update(kid="test-google", alg="RS256", use="sig") @@ -30,7 +30,7 @@ def sign_google(client: TestClient) -> Iterator[Callable[[str, str], str]]: client.portal.call(container.google_identity.close) container.google_identity = GoogleIdentityVerifier(container.settings, client=http) - def sign(nonce: str, subject: str = "google-user") -> str: + def sign(nonce: str, subject: str = "google-user", **claims: object) -> str: now = datetime.now(UTC) return jwt.encode( { @@ -40,6 +40,7 @@ def sign(nonce: str, subject: str = "google-user") -> str: "aud": CLIENT_ID, "iat": now, "exp": now + timedelta(minutes=5), + **claims, }, key, algorithm="RS256", @@ -203,3 +204,20 @@ def test_signup_without_role_has_no_family_until_the_first_invite( assert invitation.status_code == 201 again = client.post("/v1/auth/google", json=google_payload(client, sign_google)) assert again.json()["user"]["familyId"] == created.json()["familyId"] + + +def test_verified_google_email_is_saved_on_the_account(client: TestClient, sign_google) -> None: + def signed_in(**claims: object) -> dict: + challenge = client.post("/v1/auth/google/challenge").json() + payload = { + "challengeId": challenge["challengeId"], + "idToken": sign_google(challenge["nonce"], "google-user", **claims), + } + response = client.post("/v1/auth/google", json=payload) + assert response.status_code == 200, response.text + return response.json()["user"] + + assert signed_in(email="unverified@example.com", email_verified=False)["email"] is None + assert signed_in(email="mom@example.com", email_verified=True)["email"] == "mom@example.com" + assert signed_in()["email"] == "mom@example.com" + assert signed_in(email="new@example.com", email_verified=True)["email"] == "new@example.com" From bfb279d5ec988864b893e14ed7e910e3fa605e16 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Fri, 30 Oct 2026 16:22:06 +0900 Subject: [PATCH 16/23] feat: add member removal, sharing scopes and marketing consent S4 of docs/family-symmetric-plan.md. - Migration 0014 adds family_shares (owner_id, viewer_id, report, timeline) and users.marketing_opt_in (default false). - GET/PUT /v1/account/sharing lists one row per connected member of the caller's family; with no row everything is shared. Reports, baselines and signals check `report`; the call list checks `timeline`. Profiles and daily questions stay open to the family. - DELETE /v1/families/{familyId}/members/{memberId}: any member can drop a pending invitation; a connected member can be removed by the family creator or leave on their own, which leaves them without a family and clears the sharing rows between them and the rest of the family. The creator cannot be removed (409). - The member list gains canRemove. - PATCH /v1/account/preferences sets marketingOptIn; UserView shows it. - Account deletion also removes the user's sharing rows. --- HANDOFF.md | 12 ++ .../alembic/versions/0014_family_sharing.py | 27 +++ backend/app/account_router.py | 84 ++++++++- backend/app/models.py | 26 ++- backend/app/routes/calls.py | 2 +- backend/app/routes/families.py | 45 ++++- backend/app/routes/reports.py | 6 +- backend/app/schemas.py | 16 ++ backend/app/services/domain.py | 29 ++- backend/tests/test_family_sharing.py | 170 ++++++++++++++++++ 10 files changed, 406 insertions(+), 11 deletions(-) create mode 100644 backend/alembic/versions/0014_family_sharing.py create mode 100644 backend/tests/test_family_sharing.py diff --git a/HANDOFF.md b/HANDOFF.md index 40337c0..b189f83 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -180,6 +180,10 @@ STT/LLM/음향 파이프라인이 끝까지 도는 것을 확인했다. 다만 S3 전까지 **분석 대상**이다(PARENT 역할이 정확히 하나면 그 사람, 아니면 받는 사람). 두 참여자 동시 분석(`CallParticipant`), 복용약·출처·본인 확인, Q/A 관찰 event와 리포트 v2는 설계만 있다. `users.role`은 응답 호환용과 위 분석 대상 선택에만 쓴다. +- **가족 대칭 구조에서 정하지 않은 것.** 가족을 만든 사람은 `DELETE /families/{id}/members/{id}`로 + 나가거나 내보내질 수 없다(409). 소유권 넘기기가 정해지면 바꾼다. 초대 관계(`relation`)는 아직 + `MOTHER`·`FATHER`뿐이라 형제·자녀를 초대할 때 고를 값이 없다. 만든 사람이 계정을 지우면 지금처럼 + 가족 전체가 사라진다. - **전체 STT segment 보관과 동의 문구가 불일치할 수 있다.** 성공 통화도 `transcripts.segments`에 전체 전사를 저장하지만 화면은 구조화 항목만 기록한다고 안내한다. 보관 기간과 최소 evidence 정책을 결정하고 코드/동의 문서를 함께 맞춰야 한다. @@ -372,6 +376,7 @@ APNs payload에는 `callId/callUUID/callerId/callerName/expiresAt`만 넣는다. | `backend/tests/test_api_flow.py` | 온보딩부터 분석·폐기·리포트까지 E2E API test | | `backend/tests/test_family_symmetric.py` | 역할 없는 초대·수락(혼자뿐인 가족 정리, 409), 형제 통화, 분석 대상 선택, 프로필·리포트 접근 test | | `backend/tests/test_account_profile.py` | 닉네임 확인(INVALID·TAKEN), 가입 정보 저장·중복 409·형식 422, 구성원 목록의 닉네임·생년월일 test | +| `backend/tests/test_family_sharing.py` | 공유 범위 기본값·끄면 리포트/타임라인 403, 구성원 내보내기 권한, 나가면 공유 초기화, 마케팅 동의, 계정 삭제 정리 test | | `backend/tests/test_providers.py` | Deepgram/Gemini/APNs provider unit test | | `backend/tests/test_ai_pipeline.py` | prompt/repeat/acoustic/calendar-week deterministic test | | `backend/tests/test_tts.py` | ElevenLabs 요청 계약, cache/서명 URL, 장애 local 폴백 test | @@ -976,3 +981,10 @@ API를 바꿀 때에는 기존 camelCase 계약과 테스트를 유지하고, 더하고 `GET /v1/account/nickname-availability`, `PATCH /v1/account/profile`(중복 409, 형식 422)을 추가했다. Google 로그인은 Google이 확인한(`email_verified`) 이메일만 `users.email`에 저장한다. 구성원 목록에 `nickname`·`birthDate`가 붙는다. 닉네임은 대소문자를 구분한다. +- 2026-10-30: 가족 대칭 구조 S4. `family_shares(owner_id, viewer_id, report, timeline)`와 + `users.marketing_opt_in`(migration 0014)을 더했다. `GET/PUT /v1/account/sharing`은 같은 가족의 연결된 + 구성원마다 한 줄이며 행이 없으면 모두 허용이다. 리포트·기준선·변화 신호는 `report`, 통화 목록은 + `timeline`을 본다(프로필·오늘의 질문은 같은 가족이면 그대로). `DELETE /v1/families/{familyId}/members/{memberId}`는 + 대기 중 초대는 구성원 누구나, 연결된 구성원은 만든 사람 또는 본인만 지우고, 그 두 사람 사이의 공유 행도 + 지운다. 구성원 목록에 `canRemove`, `PATCH /v1/account/preferences`(`marketingOptIn`)를 더했다. 계정 삭제는 + 그 사용자가 들어간 공유 행을 함께 지운다. migration 0013·0014는 SQLite로만 검증했다(맥에 Postgres 없음). diff --git a/backend/alembic/versions/0014_family_sharing.py b/backend/alembic/versions/0014_family_sharing.py new file mode 100644 index 0000000..8f2b12a --- /dev/null +++ b/backend/alembic/versions/0014_family_sharing.py @@ -0,0 +1,27 @@ +import sqlalchemy as sa +from alembic import op + +revision = "0014_family_sharing" +down_revision = "0013_user_signup_fields" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("marketing_opt_in", sa.Boolean(), server_default=sa.false(), nullable=False), + ) + op.create_table( + "family_shares", + sa.Column("owner_id", sa.String(36), sa.ForeignKey("users.id"), primary_key=True), + sa.Column("viewer_id", sa.String(36), sa.ForeignKey("users.id"), primary_key=True), + sa.Column("report", sa.Boolean(), nullable=False), + sa.Column("timeline", sa.Boolean(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_family_shares_viewer_id", "family_shares", ["viewer_id"]) + + +def downgrade() -> None: + raise RuntimeError("Restore a verified backup instead of a destructive downgrade") diff --git a/backend/app/account_router.py b/backend/app/account_router.py index e731e7f..90ad6b9 100644 --- a/backend/app/account_router.py +++ b/backend/app/account_router.py @@ -26,6 +26,7 @@ ExtractionEvidence, Family, FamilyMember, + FamilyShare, HealthExtraction, Invitation, OtpChallenge, @@ -38,11 +39,18 @@ User, UserRole, ) -from app.schemas import NICKNAME_PATTERN, AccountProfileUpdate, ApiModel, UserView +from app.schemas import ( + NICKNAME_PATTERN, + AccountProfileUpdate, + ApiModel, + PreferencesUpdate, + SharingUpdate, + UserView, +) from app.security import CurrentUser, SessionDep from app.services.apple_auth import AppleIdentityError, AppleServiceError from app.services.apple_oauth import apple_client_secret, revoke_apple_authorization -from app.services.domain import family_of +from app.services.domain import connected_family_ids, family_of from app.services.storage import StorageError router = APIRouter(prefix="/account", tags=["Account"]) @@ -132,6 +140,75 @@ async def update_profile( return UserView.of(user, family.id if family else None) +@router.patch("/preferences", response_model=UserView) +async def update_preferences( + payload: PreferencesUpdate, user: CurrentUser, session: SessionDep +) -> UserView: + user.marketing_opt_in = payload.marketing_opt_in + await session.commit() + family = await family_of(session, user) + return UserView.of(user, family.id if family else None) + + +async def sharing_view(session: SessionDep, user: User) -> dict: + """내 기록을 같은 가족의 연결된 구성원 각각에게 보일지. 행이 없으면 모두 보인다.""" + families = list(await session.scalars( + select(Family) + .where(Family.id.in_(connected_family_ids(user.id))) + .order_by(Family.created_at, Family.id) + )) + shares = [] + seen = {user.id} + for family in families: + members = list(await session.scalars( + select(FamilyMember) + .where(FamilyMember.family_id == family.id, FamilyMember.user_id.is_not(None)) + .order_by(FamilyMember.invited_at) + )) + # 구성원 목록과 같은 이름·관계를 쓴다. 만든 사람은 관계가 비어 있다. + people = [(row.user_id, row.name, row.relation) for row in members] + owner = await session.get(User, family.created_by) + if owner is not None: + people.append((owner.id, owner.name, "")) + for viewer_id, name, relation in people: + if viewer_id in seen: + continue + seen.add(viewer_id) + share = await session.get(FamilyShare, (user.id, viewer_id)) + shares.append({ + "viewerUserId": viewer_id, + "viewerName": name, + "relation": relation, + "report": share.report if share else True, + "timeline": share.timeline if share else True, + }) + return {"shares": shares} + + +@router.get("/sharing") +async def get_sharing(user: CurrentUser, session: SessionDep) -> dict: + return await sharing_view(session, user) + + +@router.put("/sharing") +async def update_sharing(payload: SharingUpdate, user: CurrentUser, session: SessionDep) -> dict: + await session.scalar(select(User).where(User.id == user.id).with_for_update()) + viewers = {row["viewerUserId"] for row in (await sharing_view(session, user))["shares"]} + updates = {item.viewer_user_id: item for item in payload.shares} + if set(updates) - viewers: + raise HTTPException(422, "같은 가족 구성원에게만 공유 범위를 정할 수 있어요") + for viewer_id, item in updates.items(): + share = await session.get(FamilyShare, (user.id, viewer_id)) + if share is None: + share = FamilyShare(owner_id=user.id, viewer_id=viewer_id) + session.add(share) + share.report = item.report + share.timeline = item.timeline + share.updated_at = datetime.now(UTC) + await session.commit() + return await sharing_view(session, user) + + @router.delete("", status_code=204) async def delete_account( request: Request, user: CurrentUser, session: SessionDep, @@ -217,6 +294,9 @@ async def delete_account( await session.execute(delete(FamilyMember).where(FamilyMember.id.in_(removed_member_ids))) await session.execute(delete(Family).where(Family.created_by == user.id)) await session.execute(delete(ParentProfile).where(ParentProfile.parent_id == user.id)) + await session.execute(delete(FamilyShare).where( + or_(FamilyShare.owner_id == user.id, FamilyShare.viewer_id == user.id) + )) for model in (ConsentRecord, QuestionTtsGrant, Device, RefreshSession): await session.execute(delete(model).where(model.user_id == user.id)) if user.phone: diff --git a/backend/app/models.py b/backend/app/models.py index f869e81..8179de5 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -5,7 +5,18 @@ from enum import StrEnum from typing import Any -from sqlalchemy import JSON, Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy import ( + JSON, + Boolean, + Date, + DateTime, + Float, + ForeignKey, + Integer, + String, + Text, + false, +) from sqlalchemy.orm import Mapped, mapped_column from app.database import Base @@ -95,6 +106,7 @@ class User(Base): gender: Mapped[str | None] = mapped_column(String(8), nullable=True) birth_date: Mapped[date | None] = mapped_column(Date, nullable=True) email: Mapped[str | None] = mapped_column(String(320), nullable=True) + marketing_opt_in: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) @@ -189,6 +201,18 @@ class Invitation(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) +class FamilyShare(Base): + """구성원 각자가 자기 기록을 다른 구성원에게 보일지. 행이 없으면 모두 보인다.""" + + __tablename__ = "family_shares" + + owner_id: Mapped[str] = mapped_column(ForeignKey("users.id"), primary_key=True) + viewer_id: Mapped[str] = mapped_column(ForeignKey("users.id"), primary_key=True, index=True) + report: Mapped[bool] = mapped_column(Boolean, default=True) + timeline: Mapped[bool] = mapped_column(Boolean, default=True) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + class ConsentRecord(Base): __tablename__ = "consent_records" diff --git a/backend/app/routes/calls.py b/backend/app/routes/calls.py index 8bb3b80..f0c2e65 100644 --- a/backend/app/routes/calls.py +++ b/backend/app/routes/calls.py @@ -348,7 +348,7 @@ async def list_calls( from_: Annotated[date | None, Query(alias="from")] = None, to: date | None = None, ) -> dict: - await ensure_subject_access(session, user, parent_id) + await ensure_subject_access(session, user, parent_id, "timeline") statement = select(CallRecord).where(CallRecord.parent_id == parent_id) if from_: statement = statement.where( diff --git a/backend/app/routes/families.py b/backend/app/routes/families.py index 5e44d27..12acca1 100644 --- a/backend/app/routes/families.py +++ b/backend/app/routes/families.py @@ -5,9 +5,9 @@ from typing import Annotated from fastapi import APIRouter, HTTPException, Path, Request, Response, status -from sqlalchemy import delete, or_, select, update +from sqlalchemy import and_, delete, or_, select, update -from app.models import Family, FamilyMember, Invitation, User, UserRole +from app.models import Family, FamilyMember, FamilyShare, Invitation, User, UserRole from app.routes.shared import aware, settings_from from app.schemas import InvitationAccept, InvitationCreate from app.security import CurrentUser, SessionDep @@ -16,6 +16,7 @@ consent_status, derived_member_status, family_for_user, + family_people, is_family_member, latest_invitation, ) @@ -148,6 +149,8 @@ async def get_members( "consentGranted": member_status == "CONSENT_GRANTED", "nickname": member_user.nickname if member_user else None, "birthDate": member_user.birth_date if member_user else None, + "canRemove": member.user_id is None + or user.id in {family.created_by, member.user_id}, "isCreator": False, # 누구나 초대할 수 있으므로 대기 중 초대 코드도 구성원 모두에게 보인다. "invitation": invitation_dict(invitation) if invitation else None, @@ -171,6 +174,7 @@ async def get_members( "consentGranted": owner_status == "CONSENT_GRANTED", "nickname": owner.nickname, "birthDate": owner.birth_date, + "canRemove": False, "isCreator": True, "invitation": None, } @@ -178,6 +182,43 @@ async def get_members( return {"members": output, "canInvite": True} +@router.delete("/families/{familyId}/members/{memberId}", status_code=204, tags=["Family"]) +async def remove_member( + family_id: Annotated[str, Path(alias="familyId")], + member_id: Annotated[str, Path(alias="memberId")], + user: CurrentUser, + session: SessionDep, +) -> Response: + """대기 중 초대는 구성원 누구나, 연결된 구성원은 가족을 만든 사람이나 본인(나가기)만 지운다.""" + family = await session.get(Family, family_id) + if family is None or not await is_family_member(session, family, user.id): + raise HTTPException(status.HTTP_403_FORBIDDEN, "가족 접근 권한이 없습니다") + member = await session.scalar( + select(FamilyMember) + .where(FamilyMember.id == member_id, FamilyMember.family_id == family_id) + .with_for_update() + ) + if member is None: + if member_id == family.created_by: + raise HTTPException(status.HTTP_409_CONFLICT, "가족을 만든 사람은 내보낼 수 없어요") + raise HTTPException(status.HTTP_404_NOT_FOUND, "가족 구성원을 찾을 수 없습니다") + if member.user_id is not None: + if user.id not in {family.created_by, member.user_id}: + raise HTTPException( + status.HTTP_403_FORBIDDEN, "가족을 만든 사람이나 본인만 내보낼 수 있어요" + ) + # 이 가족 안에서 정한 공유 범위를 지운다. 다시 들어오면 기본값(모두 공개)부터 시작한다. + others = list(await family_people(session, family) - {member.user_id}) + await session.execute(delete(FamilyShare).where(or_( + and_(FamilyShare.owner_id == member.user_id, FamilyShare.viewer_id.in_(others)), + and_(FamilyShare.viewer_id == member.user_id, FamilyShare.owner_id.in_(others)), + ))) + await session.execute(delete(Invitation).where(Invitation.member_id == member.id)) + await session.execute(delete(FamilyMember).where(FamilyMember.id == member.id)) + await session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @router.post("/invitations/{invitationId}/resend", status_code=201, tags=["Family"]) async def resend_invitation( invitation_id: Annotated[str, Path(alias="invitationId")], diff --git a/backend/app/routes/reports.py b/backend/app/routes/reports.py index c16ff0e..2ee501f 100644 --- a/backend/app/routes/reports.py +++ b/backend/app/routes/reports.py @@ -22,7 +22,7 @@ async def get_baselines( session: SessionDep, kind: Literal["ANCHOR", "ROLLING"] | None = None, ) -> dict: - await ensure_subject_access(session, user, parent_id) + await ensure_subject_access(session, user, parent_id, "report") await request.app.state.container.signals.rebuild_baselines(session, parent_id) await session.commit() statement = select(Baseline).where(Baseline.parent_id == parent_id) @@ -39,7 +39,7 @@ async def get_signals( session: SessionDep, filter: Literal["ALL", "PROMOTED", "ACUTE"] = "ALL", ) -> dict: - await ensure_subject_access(session, user, parent_id) + await ensure_subject_access(session, user, parent_id, "report") statement = select(ChangeSignal).where(ChangeSignal.parent_id == parent_id) if filter == "PROMOTED": statement = statement.where(ChangeSignal.promoted.is_(True)) @@ -58,5 +58,5 @@ async def get_report( period: Literal["WEEKLY", "MONTHLY"], date_: Annotated[date | None, Query(alias="date")] = None, ) -> dict: - await ensure_subject_access(session, user, parent_id) + await ensure_subject_access(session, user, parent_id, "report") return await request.app.state.container.reports.get_or_issue(session, parent_id, period, date_) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b4042d6..963e4d6 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -99,6 +99,7 @@ class UserView(ApiModel): gender: Literal["MALE", "FEMALE"] | None = None birth_date: date | None = None email: str | None = None + marketing_opt_in: bool = False signup_completed: bool = False @classmethod @@ -114,6 +115,7 @@ def of(cls, user: User, family_id: str | None) -> UserView: gender=user.gender, birth_date=user.birth_date, email=user.email, + marketing_opt_in=user.marketing_opt_in, signup_completed=user.nickname is not None, ) @@ -131,6 +133,20 @@ def plausible_birth_date(cls, value: date) -> date: return value +class PreferencesUpdate(ApiModel): + marketing_opt_in: bool + + +class ShareUpdate(ApiModel): + viewer_user_id: str + report: bool + timeline: bool + + +class SharingUpdate(ApiModel): + shares: list[ShareUpdate] + + class TokenResponse(ApiModel): access_token: str refresh_token: str diff --git a/backend/app/services/domain.py b/backend/app/services/domain.py index cc35a25..f103afe 100644 --- a/backend/app/services/domain.py +++ b/backend/app/services/domain.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import UTC, datetime +from typing import Literal from fastapi import HTTPException, status from sqlalchemy import Select, or_, select @@ -12,6 +13,7 @@ ConsentRecord, Family, FamilyMember, + FamilyShare, Invitation, User, ) @@ -65,6 +67,16 @@ async def is_family_member(session: AsyncSession, family: Family, user_id: str) return member is not None +async def family_people(session: AsyncSession, family: Family) -> set[str]: + """가족을 만든 사람과 초대를 수락한 구성원의 사용자 ID.""" + members = await session.scalars( + select(FamilyMember.user_id).where( + FamilyMember.family_id == family.id, FamilyMember.user_id.is_not(None) + ) + ) + return {family.created_by, *members} + + async def family_of(session: AsyncSession, user: User) -> Family | None: """응답의 `familyId`. 부모 역할 계정의 빈 가족을 숨기는 것은 iOS 호환 때문이다.""" if user.role == "PARENT": @@ -159,8 +171,21 @@ async def ensure_same_family(session: AsyncSession, user_id: str, other_id: str) raise HTTPException(status.HTTP_403_FORBIDDEN, "가족 접근 권한이 없습니다") -async def ensure_subject_access(session: AsyncSession, user: User, subject_id: str) -> None: - """`/parents/{id}/...`의 id를 대상 사용자로 읽는다. 본인 또는 같은 가족 구성원만 허용한다.""" +async def ensure_subject_access( + session: AsyncSession, + user: User, + subject_id: str, + scope: Literal["report", "timeline"] | None = None, +) -> None: + """`/parents/{id}/...`의 id를 대상 사용자로 읽는다. + + 본인 또는 같은 가족 구성원만 허용하고, `scope`가 있으면 대상이 그 구성원에게 공유를 껐는지도 + 본다. 공유 행이 없으면 허용한다. + """ if user.id == subject_id: return await ensure_same_family(session, user.id, subject_id) + if scope is not None: + share = await session.get(FamilyShare, (subject_id, user.id)) + if share is not None and not getattr(share, scope): + raise HTTPException(status.HTTP_403_FORBIDDEN, "열람 권한이 없는 항목입니다") diff --git a/backend/tests/test_family_sharing.py b/backend/tests/test_family_sharing.py new file mode 100644 index 0000000..a91434b --- /dev/null +++ b/backend/tests/test_family_sharing.py @@ -0,0 +1,170 @@ +from fastapi.testclient import TestClient +from sqlalchemy import func, select + +from app.models import FamilyShare +from tests.conftest import auth, create_user +from tests.test_api_flow import onboard_family +from tests.test_auth import login +from tests.test_family_symmetric import accept, invite + +REPORT_PATHS = ("reports?period=WEEKLY", "baseline", "signals") + + +def sharing(client: TestClient, token: str) -> list[dict]: + response = client.get("/v1/account/sharing", headers=auth(token)) + assert response.status_code == 200, response.text + return response.json()["shares"] + + +def put_sharing( + client: TestClient, token: str, viewer_id: str, report: bool, timeline: bool +) -> int: + response = client.put( + "/v1/account/sharing", + headers=auth(token), + json={"shares": [{"viewerUserId": viewer_id, "report": report, "timeline": timeline}]}, + ) + if response.status_code == 200: + assert response.json()["shares"] == sharing(client, token) + return response.status_code + + +def remove(client: TestClient, token: str, family_id: str, member_id: str) -> int: + url = f"/v1/families/{family_id}/members/{member_id}" + return client.delete(url, headers=auth(token)).status_code + + +def status_of(client: TestClient, token: str, subject_id: str, path: str) -> int: + return client.get(f"/v1/parents/{subject_id}/{path}", headers=auth(token)).status_code + + +def members(client: TestClient, token: str, family_id: str) -> dict[str | None, dict]: + response = client.get(f"/v1/families/{family_id}/members", headers=auth(token)) + assert response.status_code == 200, response.text + return {row["userId"] or row["memberId"]: row for row in response.json()["members"]} + + +def test_sharing_defaults_to_everything_for_each_connected_member(client: TestClient) -> None: + child_token, child, parent_token, parent = onboard_family(client) + invite(client, child_token, child["familyId"], "FATHER") + assert sharing(client, parent_token) == [ + { + "viewerUserId": child["id"], + "viewerName": "김철수", + "relation": "", + "report": True, + "timeline": True, + } + ] + assert sharing(client, child_token) == [ + { + "viewerUserId": parent["id"], + "viewerName": "어머니", + "relation": "MOTHER", + "report": True, + "timeline": True, + } + ] + loner_token, _ = create_user(client, "01040000001", "PARENT", "혼자") + assert sharing(client, loner_token) == [] + + +def test_revoking_report_or_timeline_blocks_only_that_part(client: TestClient) -> None: + child_token, child, parent_token, parent = onboard_family(client) + assert put_sharing(client, parent_token, child["id"], report=False, timeline=True) == 200 + assert sharing(client, parent_token)[0]["report"] is False + assert sharing(client, parent_token)[0]["timeline"] is True + for path in REPORT_PATHS: + assert status_of(client, child_token, parent["id"], path) == 403 + assert status_of(client, parent_token, parent["id"], path) == 200 + for path in ("calls", "profile", "daily-questions"): + assert status_of(client, child_token, parent["id"], path) == 200 + # The child still sees their own sharing untouched. + assert sharing(client, child_token)[0]["report"] is True + assert status_of(client, parent_token, child["id"], "reports?period=WEEKLY") == 200 + + assert put_sharing(client, parent_token, child["id"], report=True, timeline=False) == 200 + assert status_of(client, child_token, parent["id"], "calls") == 403 + for path in REPORT_PATHS: + assert status_of(client, child_token, parent["id"], path) == 200 + + +def test_sharing_only_accepts_members_of_the_same_family(client: TestClient) -> None: + _, _, parent_token, _ = onboard_family(client) + _, stranger = create_user(client, "01040000002", "CHILD", "남") + assert put_sharing(client, parent_token, stranger["id"], report=False, timeline=False) == 422 + + +def test_member_removal_permissions(client: TestClient) -> None: + child_token, child, parent_token, parent = onboard_family(client) + family_id = child["familyId"] + father_token, father = create_user(client, "01040000003", "PARENT", "아버지") + joined = accept(client, father_token, invite(client, child_token, family_id, "FATHER")["code"]) + assert joined.status_code == 200 + pending = invite(client, parent_token, family_id, "FATHER") + + creator_view = members(client, child_token, family_id) + assert creator_view[child["id"]]["canRemove"] is False + assert creator_view[parent["id"]]["canRemove"] is True + member_view = members(client, parent_token, family_id) + assert member_view[parent["id"]]["canRemove"] is True + assert member_view[father["id"]]["canRemove"] is False + assert member_view[child["id"]]["canRemove"] is False + pending_row = next(row for row in member_view.values() if row["userId"] is None) + assert pending_row["canRemove"] is True + + outsider_token, _ = create_user(client, "01040000004", "CHILD", "남") + assert remove(client, outsider_token, family_id, pending_row["memberId"]) == 403 + assert remove(client, parent_token, family_id, "missing") == 404 + assert remove(client, parent_token, family_id, child["id"]) == 409 + other = creator_view[father["id"]]["memberId"] + assert remove(client, parent_token, family_id, other) == 403 + + assert remove(client, parent_token, family_id, pending_row["memberId"]) == 204 + assert accept(client, outsider_token, pending["code"]).status_code == 404 + + assert remove(client, child_token, family_id, other) == 204 + removed = client.get(f"/v1/families/{family_id}/members", headers=auth(father_token)) + assert removed.status_code == 403 + assert login(client, "01040000003")["user"]["familyId"] is None + call = client.post("/v1/calls", headers=auth(father_token), json={"calleeId": child["id"]}) + assert call.status_code == 403 + + assert remove(client, parent_token, family_id, member_view[parent["id"]]["memberId"]) == 204 + assert set(members(client, child_token, family_id)) == {child["id"]} + + +def test_leaving_resets_sharing_between_the_two_people(client: TestClient) -> None: + child_token, child, parent_token, parent = onboard_family(client) + assert put_sharing(client, parent_token, child["id"], report=False, timeline=False) == 200 + member_id = members(client, child_token, child["familyId"])[parent["id"]]["memberId"] + assert remove(client, parent_token, child["familyId"], member_id) == 204 + assert sharing(client, parent_token) == [] + rejoined = accept(client, parent_token, invite(client, child_token, child["familyId"])["code"]) + assert rejoined.status_code == 200 + assert sharing(client, parent_token)[0]["report"] is True + assert status_of(client, child_token, parent["id"], "reports?period=WEEKLY") == 200 + + +def test_marketing_preference_and_account_deletion_clear_sharing(client: TestClient) -> None: + child_token, child, parent_token, parent = onboard_family(client) + assert parent["marketingOptIn"] is False + updated = client.patch( + "/v1/account/preferences", headers=auth(parent_token), json={"marketingOptIn": True} + ) + assert updated.status_code == 200, updated.text + assert updated.json()["marketingOptIn"] is True + assert updated.json()["familyId"] == child["familyId"] + assert login(client, "01033334444")["user"]["marketingOptIn"] is True + empty = client.patch("/v1/account/preferences", headers=auth(parent_token), json={}) + assert empty.status_code == 422 + + assert put_sharing(client, parent_token, child["id"], report=False, timeline=True) == 200 + assert put_sharing(client, child_token, parent["id"], report=True, timeline=False) == 200 + assert client.delete("/v1/account", headers=auth(parent_token)).status_code == 204 + + async def remaining() -> int: + async with client.app.state.container.database.sessions() as session: + return await session.scalar(select(func.count()).select_from(FamilyShare)) + + assert client.portal.call(remaining) == 0 From 7755557e2db89fc1548b22a24ddaaecfd0416058 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Fri, 30 Oct 2026 16:22:52 +0900 Subject: [PATCH 17/23] docs: record the contract details filled in while implementing S1, S2 and S4 --- backend/docs/family-symmetric-plan.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/docs/family-symmetric-plan.md b/backend/docs/family-symmetric-plan.md index 30c4dda..7d846ca 100644 --- a/backend/docs/family-symmetric-plan.md +++ b/backend/docs/family-symmetric-plan.md @@ -76,6 +76,22 @@ JSON 은 기존처럼 camelCase, 경로 접두어 `/v1`. 가족이 아직 없는 사람의 첫 초대를 위해 `POST /v1/families` → `{"familyId"}`(이미 있으면 그 가족) 추가. - `GET/PUT /v1/parents/{userId}/profile` 은 누구나 자기 것·같은 가족 것(질환 `conditions` + `concerns`). +### 구현에서 정한 세부(2026-10-30, S1·S2·S4 반영) +위 약속이 비워 둔 곳을 이렇게 채웠다. 안드로이드는 이 값에 맞춘다. + +- `POST /v1/families`: 새로 만들면 201, 이미 있으면 200. 둘 다 `{"familyId"}`. +- 로그인: Google 로그인에 `role`을 안 보내면 가족을 만들지 않는다(`familyId: null`, 저장 역할은 `CHILD`). + Google 로그인에 `role`을 보내거나 OTP·Apple 로그인(역할 기본값 `CHILD`)이면 예전처럼 자녀 계정에 가족이 바로 생긴다. +- 구성원 목록: 만든 사람의 `relation`은 빈 문자열 `""`(iOS·안드로이드 모델이 non-null 문자열이라 null 대신), + `acceptedAt`은 가족을 만든 시각, `canRemove: false`. 대기 중 초대는 `acceptedAt: null`. 초대 코드(`invitation`)는 + 구성원 모두에게 보인다. 순서는 예전처럼 초대받은 구성원들 다음에 만든 사람. +- `POST /v1/invitations/accept` 의 `status` 는 수락한 사람의 동의 상태(`CONSENT_GRANTED` 등)다. +- 닉네임: `^[가-힣A-Za-z0-9]{2,10}$`(한글은 완성형 음절만, 공백 불가), 대소문자 구분. 자기 닉네임을 다시 확인하면 + `available: true`. `birthDate`는 1900-01-01 ~ 오늘(KST). +- `PUT /v1/account/sharing`: 보낸 줄만 바뀐다. 같은 가족의 연결된 구성원이 아닌 `viewerUserId`가 있으면 422. +- `DELETE .../members/{memberId}`: 만든 사람(`memberId` = 만든 사람의 userId) 은 409, 다른 가족·없는 id 는 404, + 권한 없음 403. 나간 사람과 남은 구성원 사이의 공유 행은 지워진다(다시 들어오면 모두 공개부터). + ## 안드로이드 (Collog-Android `main`) | 단계 | 내용 | 필요한 서버 | |---|---|---| From 450c28ea8bee1280b8aa42742431744362a77c51 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Fri, 30 Oct 2026 16:26:05 +0900 Subject: [PATCH 18/23] feat: accept spouse, child, sibling and other relations on invitations --- backend/app/schemas.py | 3 ++- backend/tests/test_family_symmetric.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 963e4d6..91f6936 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -165,7 +165,8 @@ class DeviceCreate(ApiModel): class InvitationCreate(ApiModel): name: str = Field(min_length=1, max_length=80) - relation: Literal["MOTHER", "FATHER"] + # Display label only. Anyone can invite anyone in the symmetric family model. + relation: Literal["MOTHER", "FATHER", "SPOUSE", "CHILD", "SIBLING", "GRANDPARENT", "OTHER"] class InvitationAccept(ApiModel): diff --git a/backend/tests/test_family_symmetric.py b/backend/tests/test_family_symmetric.py index 0dccc34..5ec9fd4 100644 --- a/backend/tests/test_family_symmetric.py +++ b/backend/tests/test_family_symmetric.py @@ -193,3 +193,15 @@ async def count() -> int: return await session.scalar(select(func.count(Family.id))) assert client.portal.call(count) == 1 + + +def test_invitations_accept_any_family_relation(client: TestClient) -> None: + _, child, parent_token, _ = onboard_family(client) + for relation in ("SPOUSE", "CHILD", "SIBLING", "GRANDPARENT", "OTHER"): + assert invite(client, parent_token, child["familyId"], relation)["status"] == "PENDING" + unknown = client.post( + f"/v1/families/{child['familyId']}/invitations", + headers=auth(parent_token), + json={"name": "가족", "relation": "COUSIN"}, + ) + assert unknown.status_code == 422 From f10dd066678cf446d80bd2ca754bd3b78fd17d61 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Sat, 31 Oct 2026 11:17:31 +0900 Subject: [PATCH 19/23] fix(family): show nicknames to family instead of login names The family creator showed up to others by their Google/Apple account name. Members list, family list, incoming call push and LiveKit names now prefer the sign-up nickname and fall back to the account name, so accounts without a nickname (iOS) look the same as before. --- backend/app/models.py | 5 ++++ backend/app/routes/calls.py | 8 +++--- backend/app/routes/families.py | 8 +++--- backend/tests/test_family_symmetric.py | 37 ++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/backend/app/models.py b/backend/app/models.py index 8179de5..b1a4fb9 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -109,6 +109,11 @@ class User(Base): marketing_opt_in: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + @property + def display_name(self) -> str: + """다른 가족에게 보이는 이름. 가입 때 정한 닉네임이 있으면 로그인 계정 이름 대신 쓴다.""" + return self.nickname or self.name + class OtpChallenge(Base): __tablename__ = "otp_challenges" diff --git a/backend/app/routes/calls.py b/backend/app/routes/calls.py index f0c2e65..748767b 100644 --- a/backend/app/routes/calls.py +++ b/backend/app/routes/calls.py @@ -131,7 +131,7 @@ async def create_reserved_call( await livekit.create_room(call.room_name) except LiveKitError as exc: raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc - token = livekit.participant_token(call.room_name, user.id, user.name) + token = livekit.participant_token(call.room_name, user.id, user.display_name) devices = list(await session.scalars( select(Device) .where( @@ -152,7 +152,7 @@ async def create_reserved_call( IncomingCallPush( call_id=call.id, caller_id=user.id, - caller_name=user.name, + caller_name=user.display_name, callee_id=callee.id, expires_at=datetime.now(UTC) + timedelta(seconds=settings_from(request).incoming_call_ttl_seconds), @@ -211,7 +211,7 @@ async def accept_call( recording_enabled=call.recording_enabled, livekit_url=settings_from(request).livekit_url, room_name=call.room_name, - access_token=livekit.participant_token(call.room_name, user.id, user.name), + access_token=livekit.participant_token(call.room_name, user.id, user.display_name), raw_capture_required=call.recording_enabled and user.id == call.parent_id, audio_constraints=AudioConstraints(), ).model_dump(by_alias=True) @@ -244,7 +244,7 @@ async def accept_call( raise HTTPException(status.HTTP_409_CONFLICT, "이미 종료되었거나 응답한 통화입니다") settings = settings_from(request) livekit = request.app.state.container.livekit - token = livekit.participant_token(call.room_name, user.id, user.name) + token = livekit.participant_token(call.room_name, user.id, user.display_name) if call.recording_enabled and not settings.allow_raw_only_analysis: for _, kind, filename in ( (call.parent_id, AssetKind.WEBRTC_EGRESS_PARENT, "parent.ogg"), diff --git a/backend/app/routes/families.py b/backend/app/routes/families.py index 12acca1..9c723a7 100644 --- a/backend/app/routes/families.py +++ b/backend/app/routes/families.py @@ -36,12 +36,14 @@ async def get_families(user: CurrentUser, session: SessionDep) -> dict: ).exists() owned = owned & populated rows = await session.execute( - select(Family, User.name) + select(Family, User) .join(User, User.id == Family.created_by) .where(or_(owned, Family.id.in_(membership))) .order_by(Family.created_at, Family.id) ) - families = [{"familyId": family.id, "name": f"{name}의 가족"} for family, name in rows] + families = [ + {"familyId": family.id, "name": f"{owner.display_name}의 가족"} for family, owner in rows + ] return {"families": families} @@ -163,7 +165,7 @@ async def get_members( { "memberId": owner.id, "userId": owner.id, - "name": owner.name, + "name": owner.display_name, "relation": "", "role": owner.role, "status": owner_status, diff --git a/backend/tests/test_family_symmetric.py b/backend/tests/test_family_symmetric.py index 5ec9fd4..b40c6f7 100644 --- a/backend/tests/test_family_symmetric.py +++ b/backend/tests/test_family_symmetric.py @@ -2,6 +2,7 @@ from sqlalchemy import func, select from app.models import CallRecord, Family +from app.services.notifications import IncomingCallPush, VoipPushGateway from tests.conftest import auth, create_user from tests.test_api_flow import onboard_family from tests.test_auth import login @@ -205,3 +206,39 @@ def test_invitations_accept_any_family_relation(client: TestClient) -> None: json={"name": "가족", "relation": "COUSIN"}, ) assert unknown.status_code == 422 + + +def test_family_sees_the_nickname_instead_of_the_login_name(client: TestClient) -> None: + first_token, first, second_token, second = siblings(client) + profile = client.patch( + "/v1/account/profile", + headers=auth(first_token), + json={"nickname": "첫째별명", "gender": "FEMALE", "birthDate": "1960-03-15"}, + ) + assert profile.status_code == 200, profile.text + + members = client.get( + f"/v1/families/{first['familyId']}/members", headers=auth(second_token) + ).json()["members"] + assert [m["name"] for m in members if m["isCreator"]] == ["첫째별명"] + families = client.get("/v1/families", headers=auth(second_token)).json()["families"] + assert [f["name"] for f in families] == ["첫째별명의 가족"] + + class Recorder(VoipPushGateway): + def __init__(self) -> None: + self.sent: list[IncomingCallPush] = [] + + async def send_incoming_call(self, token: str, push: IncomingCallPush) -> None: + self.sent.append(push) + + device = client.post("/v1/devices", headers=auth(second_token), json={ + "platform": "ANDROID", "token": "fcm-token", + "voipToken": "fcm-token", "pushToken": "fcm-token", + }) + assert device.status_code == 201, device.text + client.app.state.container.android_voip_push = Recorder() + created = client.post("/v1/calls", headers=auth(first_token), json={"calleeId": second["id"]}) + assert created.status_code == 201, created.text + assert [push.caller_name for push in client.app.state.container.android_voip_push.sent] == [ + "첫째별명" + ] From aebdadf2a61524ece95ba68e49f7ace67ac780f4 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Sat, 31 Oct 2026 11:18:38 +0900 Subject: [PATCH 20/23] fix(family): use the nickname in the sharing scope list too --- backend/app/account_router.py | 2 +- backend/tests/test_family_symmetric.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/app/account_router.py b/backend/app/account_router.py index 90ad6b9..9d52b9d 100644 --- a/backend/app/account_router.py +++ b/backend/app/account_router.py @@ -169,7 +169,7 @@ async def sharing_view(session: SessionDep, user: User) -> dict: people = [(row.user_id, row.name, row.relation) for row in members] owner = await session.get(User, family.created_by) if owner is not None: - people.append((owner.id, owner.name, "")) + people.append((owner.id, owner.display_name, "")) for viewer_id, name, relation in people: if viewer_id in seen: continue diff --git a/backend/tests/test_family_symmetric.py b/backend/tests/test_family_symmetric.py index b40c6f7..87346eb 100644 --- a/backend/tests/test_family_symmetric.py +++ b/backend/tests/test_family_symmetric.py @@ -223,6 +223,8 @@ def test_family_sees_the_nickname_instead_of_the_login_name(client: TestClient) assert [m["name"] for m in members if m["isCreator"]] == ["첫째별명"] families = client.get("/v1/families", headers=auth(second_token)).json()["families"] assert [f["name"] for f in families] == ["첫째별명의 가족"] + shares = client.get("/v1/account/sharing", headers=auth(second_token)).json()["shares"] + assert [s["viewerName"] for s in shares] == ["첫째별명"] class Recorder(VoipPushGateway): def __init__(self) -> None: From 62bd1e3c547d4999d597a031530c13795f4f6411 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Tue, 3 Nov 2026 18:42:17 +0900 Subject: [PATCH 21/23] feat(family): hand the family over when its creator leaves The creator could not leave (409) and deleting their account deleted the whole family, cutting off everyone else. Now the creator can leave by removing themselves, and both leaving and account deletion pass the family to the member who was invited first; a family with nobody left is deleted with its pending invitations, as before. --- backend/app/account_router.py | 9 +++++- backend/app/routes/families.py | 33 ++++++++++++--------- backend/app/services/domain.py | 42 ++++++++++++++++++++++++++- backend/docs/family-symmetric-plan.md | 8 +++-- backend/tests/test_account.py | 3 +- backend/tests/test_family_sharing.py | 40 +++++++++++++++++++++++-- 6 files changed, 115 insertions(+), 20 deletions(-) diff --git a/backend/app/account_router.py b/backend/app/account_router.py index 9d52b9d..7d1376a 100644 --- a/backend/app/account_router.py +++ b/backend/app/account_router.py @@ -50,7 +50,7 @@ from app.security import CurrentUser, SessionDep from app.services.apple_auth import AppleIdentityError, AppleServiceError from app.services.apple_oauth import apple_client_secret, revoke_apple_authorization -from app.services.domain import connected_family_ids, family_of +from app.services.domain import connected_family_ids, family_of, hand_over_family from app.services.storage import StorageError router = APIRouter(prefix="/account", tags=["Account"]) @@ -284,6 +284,13 @@ async def delete_account( # Reports and baselines can contain summaries derived from the deleted calls. for model in (Report, Baseline): await session.execute(delete(model).where(model.parent_id.in_(parent_ids))) + # 만든 가족에 다른 구성원이 있으면 가족은 남기고 주인만 넘긴다. + # 남은 사람이 없는 가족만 아래에서 지운다. + for family in list(await session.scalars( + select(Family).where(Family.created_by == user.id) + )): + await hand_over_family(session, family) + await session.flush() owned_family_ids = select(Family.id).where(Family.created_by == user.id) removed_member_ids = select(FamilyMember.id).where( or_(FamilyMember.user_id == user.id, FamilyMember.family_id.in_(owned_family_ids)) diff --git a/backend/app/routes/families.py b/backend/app/routes/families.py index 9c723a7..bb341b6 100644 --- a/backend/app/routes/families.py +++ b/backend/app/routes/families.py @@ -5,18 +5,20 @@ from typing import Annotated from fastapi import APIRouter, HTTPException, Path, Request, Response, status -from sqlalchemy import and_, delete, or_, select, update +from sqlalchemy import delete, or_, select, update -from app.models import Family, FamilyMember, FamilyShare, Invitation, User, UserRole +from app.models import Family, FamilyMember, Invitation, User, UserRole from app.routes.shared import aware, settings_from from app.schemas import InvitationAccept, InvitationCreate from app.security import CurrentUser, SessionDep from app.services.domain import ( + clear_family_shares, connected_family_ids, consent_status, + delete_family, derived_member_status, family_for_user, - family_people, + hand_over_family, is_family_member, latest_invitation, ) @@ -176,7 +178,8 @@ async def get_members( "consentGranted": owner_status == "CONSENT_GRANTED", "nickname": owner.nickname, "birthDate": owner.birth_date, - "canRemove": False, + # 만든 사람은 스스로 나갈 수만 있다 + "canRemove": owner.id == user.id, "isCreator": True, "invitation": None, } @@ -191,7 +194,10 @@ async def remove_member( user: CurrentUser, session: SessionDep, ) -> Response: - """대기 중 초대는 구성원 누구나, 연결된 구성원은 가족을 만든 사람이나 본인(나가기)만 지운다.""" + """대기 중 초대는 구성원 누구나, 연결된 구성원은 가족을 만든 사람이나 본인(나가기)만 지운다. + + 만든 사람이 나가면 가장 먼저 들어온 구성원이 새 주인이 되고, 남은 구성원이 없으면 가족을 지운다. + """ family = await session.get(Family, family_id) if family is None or not await is_family_member(session, family, user.id): raise HTTPException(status.HTTP_403_FORBIDDEN, "가족 접근 권한이 없습니다") @@ -201,20 +207,21 @@ async def remove_member( .with_for_update() ) if member is None: - if member_id == family.created_by: + if member_id != family.created_by: + raise HTTPException(status.HTTP_404_NOT_FOUND, "가족 구성원을 찾을 수 없습니다") + if user.id != family.created_by: raise HTTPException(status.HTTP_409_CONFLICT, "가족을 만든 사람은 내보낼 수 없어요") - raise HTTPException(status.HTTP_404_NOT_FOUND, "가족 구성원을 찾을 수 없습니다") + await clear_family_shares(session, family, user.id) + if not await hand_over_family(session, family): + await delete_family(session, family) + await session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) if member.user_id is not None: if user.id not in {family.created_by, member.user_id}: raise HTTPException( status.HTTP_403_FORBIDDEN, "가족을 만든 사람이나 본인만 내보낼 수 있어요" ) - # 이 가족 안에서 정한 공유 범위를 지운다. 다시 들어오면 기본값(모두 공개)부터 시작한다. - others = list(await family_people(session, family) - {member.user_id}) - await session.execute(delete(FamilyShare).where(or_( - and_(FamilyShare.owner_id == member.user_id, FamilyShare.viewer_id.in_(others)), - and_(FamilyShare.viewer_id == member.user_id, FamilyShare.owner_id.in_(others)), - ))) + await clear_family_shares(session, family, member.user_id) await session.execute(delete(Invitation).where(Invitation.member_id == member.id)) await session.execute(delete(FamilyMember).where(FamilyMember.id == member.id)) await session.commit() diff --git a/backend/app/services/domain.py b/backend/app/services/domain.py index f103afe..3b8880b 100644 --- a/backend/app/services/domain.py +++ b/backend/app/services/domain.py @@ -4,7 +4,7 @@ from typing import Literal from fastapi import HTTPException, status -from sqlalchemy import Select, or_, select +from sqlalchemy import Select, and_, delete, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.consent import CONSENT_ITEMS, CONSENT_VERSION @@ -77,6 +77,46 @@ async def family_people(session: AsyncSession, family: Family) -> set[str]: return {family.created_by, *members} +async def clear_family_shares(session: AsyncSession, family: Family, user_id: str) -> None: + """가족을 떠나는 사람과 남은 구성원 사이의 공유 범위를 지운다. + + 다시 들어오면 기본값(모두 공개)부터 시작한다. + """ + others = list(await family_people(session, family) - {user_id}) + await session.execute(delete(FamilyShare).where(or_( + and_(FamilyShare.owner_id == user_id, FamilyShare.viewer_id.in_(others)), + and_(FamilyShare.viewer_id == user_id, FamilyShare.owner_id.in_(others)), + ))) + + +async def hand_over_family(session: AsyncSession, family: Family) -> bool: + """가족을 만든 사람이 떠날 때 가장 먼저 초대받아 들어온 구성원을 새 주인으로 한다. + + 넘길 사람이 없으면 False. + """ + heir = await session.scalar( + select(FamilyMember) + .where(FamilyMember.family_id == family.id, FamilyMember.user_id.is_not(None)) + .order_by(FamilyMember.invited_at, FamilyMember.id) + .limit(1) + ) + if heir is None: + return False + # 만든 사람은 구성원 줄 없이 Family.created_by 로만 연결된다 + await session.execute(delete(Invitation).where(Invitation.member_id == heir.id)) + await session.execute(delete(FamilyMember).where(FamilyMember.id == heir.id)) + family.created_by = heir.user_id + return True + + +async def delete_family(session: AsyncSession, family: Family) -> None: + """남은 구성원이 없는 가족을 대기 중 초대와 함께 지운다.""" + member_ids = select(FamilyMember.id).where(FamilyMember.family_id == family.id) + await session.execute(delete(Invitation).where(Invitation.member_id.in_(member_ids))) + await session.execute(delete(FamilyMember).where(FamilyMember.family_id == family.id)) + await session.delete(family) + + async def family_of(session: AsyncSession, user: User) -> Family | None: """응답의 `familyId`. 부모 역할 계정의 빈 가족을 숨기는 것은 iOS 호환 때문이다.""" if user.role == "PARENT": diff --git a/backend/docs/family-symmetric-plan.md b/backend/docs/family-symmetric-plan.md index 7d846ca..66c849c 100644 --- a/backend/docs/family-symmetric-plan.md +++ b/backend/docs/family-symmetric-plan.md @@ -89,8 +89,12 @@ JSON 은 기존처럼 camelCase, 경로 접두어 `/v1`. - 닉네임: `^[가-힣A-Za-z0-9]{2,10}$`(한글은 완성형 음절만, 공백 불가), 대소문자 구분. 자기 닉네임을 다시 확인하면 `available: true`. `birthDate`는 1900-01-01 ~ 오늘(KST). - `PUT /v1/account/sharing`: 보낸 줄만 바뀐다. 같은 가족의 연결된 구성원이 아닌 `viewerUserId`가 있으면 422. -- `DELETE .../members/{memberId}`: 만든 사람(`memberId` = 만든 사람의 userId) 은 409, 다른 가족·없는 id 는 404, - 권한 없음 403. 나간 사람과 남은 구성원 사이의 공유 행은 지워진다(다시 들어오면 모두 공개부터). +- `DELETE .../members/{memberId}`: 다른 가족·없는 id 는 404, 권한 없음 403. 나간 사람과 남은 구성원 사이의 + 공유 행은 지워진다(다시 들어오면 모두 공개부터). +- 만든 사람(`memberId` = 만든 사람의 userId): 다른 사람이 내보내려 하면 409. **본인이 지우면 나가기**(2026-11-03) — + 가장 먼저 초대받아 들어온 연결된 구성원이 새 주인(`isCreator`)이 되고, 그 사람의 구성원 줄·초대는 지운다. + 넘길 사람이 없으면 대기 중 초대와 함께 가족을 지운다. 구성원 목록에서 만든 사람 본인 줄의 `canRemove` 는 true. +- 계정 삭제도 같다: 만든 가족에 연결된 구성원이 있으면 가족은 남고 주인만 넘어간다. 남은 사람이 없는 가족만 지운다. ## 안드로이드 (Collog-Android `main`) | 단계 | 내용 | 필요한 서버 | diff --git a/backend/tests/test_account.py b/backend/tests/test_account.py index 039cd7e..8bfa2d5 100644 --- a/backend/tests/test_account.py +++ b/backend/tests/test_account.py @@ -165,7 +165,8 @@ async def check() -> None: CallRecord, AudioAsset, Transcript, Report, Device, FamilyMember, Invitation, ): assert await session.scalar(select(func.count()).select_from(model)) == 0 - assert await session.scalar(select(func.count(Family.id))) == int(delete_parent) + # 누가 지워지든 남은 사람이 가족을 이어받아 가족은 남는다 + assert await session.scalar(select(func.count(Family.id))) == 1 for model in (RefreshSession, ConsentRecord): assert await session.scalar( select(func.count()).select_from(model) diff --git a/backend/tests/test_family_sharing.py b/backend/tests/test_family_sharing.py index a91434b..85319eb 100644 --- a/backend/tests/test_family_sharing.py +++ b/backend/tests/test_family_sharing.py @@ -1,7 +1,7 @@ from fastapi.testclient import TestClient from sqlalchemy import func, select -from app.models import FamilyShare +from app.models import Family, FamilyShare from tests.conftest import auth, create_user from tests.test_api_flow import onboard_family from tests.test_auth import login @@ -104,7 +104,8 @@ def test_member_removal_permissions(client: TestClient) -> None: pending = invite(client, parent_token, family_id, "FATHER") creator_view = members(client, child_token, family_id) - assert creator_view[child["id"]]["canRemove"] is False + # 만든 사람은 스스로 나갈 수만 있다 + assert creator_view[child["id"]]["canRemove"] is True assert creator_view[parent["id"]]["canRemove"] is True member_view = members(client, parent_token, family_id) assert member_view[parent["id"]]["canRemove"] is True @@ -168,3 +169,38 @@ async def remaining() -> int: return await session.scalar(select(func.count()).select_from(FamilyShare)) assert client.portal.call(remaining) == 0 + + +def test_creator_leaving_hands_the_family_to_the_first_member(client: TestClient) -> None: + child_token, child, parent_token, parent = onboard_family(client) + family_id = child["familyId"] + father_token, father = create_user(client, "01040000005", "PARENT", "아버지") + joined = accept(client, father_token, invite(client, child_token, family_id, "FATHER")["code"]) + assert joined.status_code == 200 + assert put_sharing(client, parent_token, child["id"], report=False, timeline=False) == 200 + + assert remove(client, child_token, family_id, child["id"]) == 204 + view = members(client, father_token, family_id) + assert set(view) == {parent["id"], father["id"]} + assert view[parent["id"]]["isCreator"] is True + assert login(client, "01033334444")["user"]["familyId"] == family_id + left = client.get(f"/v1/families/{family_id}/members", headers=auth(child_token)) + assert left.status_code == 403 + assert [s["viewerUserId"] for s in sharing(client, parent_token)] == [father["id"]] + call = client.post("/v1/calls", headers=auth(father_token), json={"calleeId": parent["id"]}) + assert call.status_code == 201, call.text + + +def test_creator_leaving_alone_deletes_the_family(client: TestClient) -> None: + token, user = create_user(client, "01040000006", "CHILD", "혼자") + pending = invite(client, token, user["familyId"]) + assert remove(client, token, user["familyId"], user["id"]) == 204 + joiner_token, _ = create_user(client, "01040000007", "PARENT", "늦게") + assert accept(client, joiner_token, pending["code"]).status_code == 404 + + async def families() -> int: + async with client.app.state.container.database.sessions() as session: + return await session.scalar(select(func.count()).select_from(Family)) + + assert client.portal.call(families) == 0 + From 632de102107668ad8a60a7e7f5c5ed9ce2219058 Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Wed, 4 Nov 2026 11:49:40 +0900 Subject: [PATCH 22/23] feat(auth): add Kakao login with OpenID Connect ID tokens Mirror the Google flow: the app asks for a single-use nonce, signs in with the Kakao SDK, and the server verifies the ID token against Kakao's JWKS (issuer, audience = KAKAO_APP_KEY, nonce). New accounts are role-free and get a family on their first invitation. The shared challenge and OIDC verification code now serves both providers. --- backend/alembic/versions/0015_kakao_login.py | 27 ++++ backend/app/auth_router.py | 143 +++++++++++++++---- backend/app/config.py | 2 + backend/app/container.py | 2 + backend/app/main.py | 1 + backend/app/models.py | 12 ++ backend/app/schemas.py | 8 ++ backend/app/services/google_auth.py | 57 ++++++-- backend/app/services/kakao_auth.py | 44 ++++++ backend/deploy/production.env.example | 2 + backend/docker-compose.yml | 1 + backend/docs/family-symmetric-plan.md | 10 +- backend/tests/test_kakao_login.py | 117 +++++++++++++++ 13 files changed, 382 insertions(+), 44 deletions(-) create mode 100644 backend/alembic/versions/0015_kakao_login.py create mode 100644 backend/app/services/kakao_auth.py create mode 100644 backend/tests/test_kakao_login.py diff --git a/backend/alembic/versions/0015_kakao_login.py b/backend/alembic/versions/0015_kakao_login.py new file mode 100644 index 0000000..a377cf9 --- /dev/null +++ b/backend/alembic/versions/0015_kakao_login.py @@ -0,0 +1,27 @@ +import sqlalchemy as sa +from alembic import op + +revision = "0015_kakao_login" +down_revision = "0014_family_sharing" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("users") as batch: + batch.add_column(sa.Column("kakao_subject", sa.String(255), nullable=True)) + batch.create_index("ix_users_kakao_subject", ["kakao_subject"], unique=True) + op.create_table( + "kakao_login_challenges", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("nonce_hash", sa.String(64), unique=True, nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index( + "ix_kakao_login_challenges_expires_at", "kakao_login_challenges", ["expires_at"] + ) + + +def downgrade() -> None: + raise RuntimeError("Restore a verified backup instead of a destructive downgrade") diff --git a/backend/app/auth_router.py b/backend/app/auth_router.py index 7c3e5e2..4eacabc 100644 --- a/backend/app/auth_router.py +++ b/backend/app/auth_router.py @@ -15,6 +15,7 @@ Device, Family, GoogleLoginChallenge, + KakaoLoginChallenge, OtpChallenge, RefreshSession, User, @@ -25,6 +26,7 @@ AppleLoginRequest, GoogleChallengeResponse, GoogleLoginRequest, + KakaoLoginRequest, OtpRequest, OtpVerify, RefreshRequest, @@ -34,7 +36,12 @@ from app.security import SessionDep, issue_token, otp_hash from app.services.apple_auth import AppleIdentityError, AppleServiceError from app.services.domain import family_of -from app.services.google_auth import GoogleIdentityError, GoogleServiceError +from app.services.google_auth import ( + GoogleIdentity, + GoogleIdentityError, + GoogleServiceError, + OidcIdentityVerifier, +) from app.services.sms import SmsDeliveryError, send_otp router = APIRouter(prefix="/auth", tags=["Auth"]) @@ -170,16 +177,24 @@ def require_google_login(settings: Settings) -> None: ) -@router.post("/google/challenge", response_model=GoogleChallengeResponse) -async def google_challenge(request: Request, session: SessionDep) -> GoogleChallengeResponse: - settings: Settings = request.app.state.container.settings - require_google_login(settings) +def require_kakao_login(settings: Settings) -> None: + if not settings.kakao_app_key: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, "카카오 로그인을 사용할 수 없습니다" + ) + + +LoginChallenge = type[GoogleLoginChallenge] | type[KakaoLoginChallenge] + + +async def issue_login_challenge( + session: SessionDep, model: LoginChallenge, settings: Settings +) -> GoogleChallengeResponse: + """Google·카카오 로그인 준비: ID 토큰에 넣을 일회용 nonce 를 발급한다.""" now = datetime.now(UTC) - await session.execute( - delete(GoogleLoginChallenge).where(GoogleLoginChallenge.expires_at < now) - ) + await session.execute(delete(model).where(model.expires_at < now)) nonce = secrets.token_urlsafe(32) - challenge = GoogleLoginChallenge( + challenge = model( nonce_hash=hashlib.sha256(nonce.encode()).hexdigest(), expires_at=now + timedelta(seconds=settings.apple_challenge_ttl_seconds), ) @@ -190,43 +205,68 @@ async def google_challenge(request: Request, session: SessionDep) -> GoogleChall ) -@router.post("/google", response_model=TokenResponse) -async def google_login( - payload: GoogleLoginRequest, request: Request, session: SessionDep -) -> TokenResponse: - settings: Settings = request.app.state.container.settings - require_google_login(settings) - challenge = await session.get(GoogleLoginChallenge, payload.challenge_id) +async def verified_login_identity( + session: SessionDep, + model: LoginChallenge, + challenge_id: str, + id_token: str, + verifier: OidcIdentityVerifier, + label: str, +) -> GoogleIdentity: + """준비 요청과 ID 토큰을 확인하고, 준비 요청은 한 번만 쓰게 한다.""" + challenge = await session.get(model, challenge_id) if challenge is None or challenge.consumed_at is not None: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Google 로그인을 다시 시작해주세요") + raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"{label} 로그인을 다시 시작해주세요") if challenge.expires_at.replace(tzinfo=UTC) <= datetime.now(UTC): - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Google 로그인 요청이 만료되었습니다") + raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"{label} 로그인 요청이 만료되었습니다") try: - identity = await request.app.state.container.google_identity.verify(payload.id_token) + identity = await verifier.verify(id_token) except GoogleIdentityError as exc: raise HTTPException( - status.HTTP_401_UNAUTHORIZED, "Google 로그인 정보를 확인할 수 없습니다" + status.HTTP_401_UNAUTHORIZED, f"{label} 로그인 정보를 확인할 수 없습니다" ) from exc except GoogleServiceError as exc: raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, "Google 로그인 확인을 잠시 후 다시 시도해주세요" + status.HTTP_503_SERVICE_UNAVAILABLE, f"{label} 로그인 확인을 잠시 후 다시 시도해주세요" ) from exc nonce_hash = hashlib.sha256(identity.nonce.encode()).hexdigest() if not hmac.compare_digest(challenge.nonce_hash, nonce_hash): - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Google 로그인 요청이 일치하지 않습니다") + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, f"{label} 로그인 요청이 일치하지 않습니다" + ) consumed = await session.scalar( - update(GoogleLoginChallenge) + update(model) .where( - GoogleLoginChallenge.id == payload.challenge_id, - GoogleLoginChallenge.consumed_at.is_(None), - GoogleLoginChallenge.expires_at > datetime.now(UTC), + model.id == challenge_id, + model.consumed_at.is_(None), + model.expires_at > datetime.now(UTC), ) .values(consumed_at=datetime.now(UTC)) .execution_options(synchronize_session=False) - .returning(GoogleLoginChallenge.id) + .returning(model.id) ) if consumed is None: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "이미 사용한 Google 로그인 요청입니다") + raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"이미 사용한 {label} 로그인 요청입니다") + return identity + + +@router.post("/google/challenge", response_model=GoogleChallengeResponse) +async def google_challenge(request: Request, session: SessionDep) -> GoogleChallengeResponse: + settings: Settings = request.app.state.container.settings + require_google_login(settings) + return await issue_login_challenge(session, GoogleLoginChallenge, settings) + + +@router.post("/google", response_model=TokenResponse) +async def google_login( + payload: GoogleLoginRequest, request: Request, session: SessionDep +) -> TokenResponse: + settings: Settings = request.app.state.container.settings + require_google_login(settings) + identity = await verified_login_identity( + session, GoogleLoginChallenge, payload.challenge_id, payload.id_token, + request.app.state.container.google_identity, "Google", + ) user = await session.scalar(select(User).where(User.google_subject == identity.subject)) if user is None: try: @@ -253,6 +293,53 @@ async def google_login( return await create_session(session, user, settings, role_sent=payload.role is not None) +@router.post("/kakao/challenge", response_model=GoogleChallengeResponse) +async def kakao_challenge(request: Request, session: SessionDep) -> GoogleChallengeResponse: + settings: Settings = request.app.state.container.settings + require_kakao_login(settings) + return await issue_login_challenge(session, KakaoLoginChallenge, settings) + + +@router.post("/kakao", response_model=TokenResponse) +async def kakao_login( + payload: KakaoLoginRequest, request: Request, session: SessionDep +) -> TokenResponse: + """카카오 SDK 로그인(OpenID Connect) ID 토큰으로 로그인한다. + + 역할 없이 가입하므로 가족은 첫 초대 때 생긴다. + """ + settings: Settings = request.app.state.container.settings + require_kakao_login(settings) + identity = await verified_login_identity( + session, KakaoLoginChallenge, payload.challenge_id, payload.id_token, + request.app.state.container.kakao_identity, "카카오", + ) + user = await session.scalar(select(User).where(User.kakao_subject == identity.subject)) + if user is None: + try: + async with session.begin_nested(): + user = User( + kakao_subject=identity.subject, + phone=None, + role=UserRole.CHILD.value, + name=(payload.name or "").strip() or identity.name or "사용자", + email=identity.email, + ) + session.add(user) + await session.flush() + except IntegrityError: + user = await session.scalar( + select(User).where(User.kakao_subject == identity.subject) + ) + if user is None: + raise HTTPException( + status.HTTP_409_CONFLICT, "카카오 로그인을 다시 시도해주세요" + ) from None + if identity.email: + user.email = identity.email + return await create_session(session, user, settings, role_sent=False) + + @router.post("/otp/request", status_code=202) async def request_otp( payload: OtpRequest, request: Request, session: SessionDep diff --git a/backend/app/config.py b/backend/app/config.py index 7fad36d..6483d32 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,6 +41,8 @@ class Settings(BaseSettings): # 비워두면 Google 로그인 엔드포인트는 503을 반환한다. nonce 유효 시간은 # apple_challenge_ttl_seconds를 같이 쓴다. google_client_id: str = "" + # 카카오 ID 토큰의 aud(안드로이드 SDK 로그인이면 네이티브 앱 키). 비우면 카카오 로그인이 꺼진다. + kakao_app_key: str = "" schema_auto_reset: bool = False diff --git a/backend/app/container.py b/backend/app/container.py index f8c1402..19539cd 100644 --- a/backend/app/container.py +++ b/backend/app/container.py @@ -9,6 +9,7 @@ from app.services.fcm import create_android_push_gateways from app.services.gemini import create_extraction_gateway from app.services.google_auth import GoogleIdentityVerifier +from app.services.kakao_auth import KakaoIdentityVerifier from app.services.livekit import create_livekit_gateway from app.services.notifications import create_report_push_gateway, create_voip_push_gateway from app.services.pipeline import ProcessingPipeline @@ -24,6 +25,7 @@ def __init__(self, settings: Settings) -> None: self.settings = settings self.apple_identity = AppleIdentityVerifier(settings) self.google_identity = GoogleIdentityVerifier(settings) + self.kakao_identity = KakaoIdentityVerifier(settings) self.database = Database(settings.database_url) self.storage = create_storage(settings) self.question_tts = create_question_tts_gateway(settings, self.storage) diff --git a/backend/app/main.py b/backend/app/main.py index c21854f..c05c0fa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -70,6 +70,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await container.android_voip_push.close() await container.apple_identity.close() await container.google_identity.close() + await container.kakao_identity.close() await container.database.close() app = FastAPI( diff --git a/backend/app/models.py b/backend/app/models.py index b1a4fb9..7d9224b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -99,6 +99,9 @@ class User(Base): google_subject: Mapped[str | None] = mapped_column( String(255), unique=True, index=True, nullable=True ) + kakao_subject: Mapped[str | None] = mapped_column( + String(255), unique=True, index=True, nullable=True + ) # 가입 정보. 기존 사용자를 위해 비울 수 있고, 닉네임이 있으면 가입을 마친 것으로 본다. nickname: Mapped[str | None] = mapped_column( String(10), unique=True, index=True, nullable=True @@ -147,6 +150,15 @@ class GoogleLoginChallenge(Base): consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) +class KakaoLoginChallenge(Base): + __tablename__ = "kakao_login_challenges" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + nonce_hash: Mapped[str] = mapped_column(String(64), unique=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + class Device(Base): __tablename__ = "devices" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 91f6936..e6b0b80 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -82,7 +82,15 @@ class GoogleLoginRequest(ApiModel): name: str | None = Field(default=None, max_length=80) +class KakaoLoginRequest(ApiModel): + id_token: str = Field(min_length=1, max_length=16_384) + challenge_id: str = Field(min_length=36, max_length=36) + name: str | None = Field(default=None, max_length=80) + + class GoogleChallengeResponse(ApiModel): + """Google·카카오 로그인 준비 응답(모양이 같다).""" + challenge_id: str nonce: str expires_in: int diff --git a/backend/app/services/google_auth.py b/backend/app/services/google_auth.py index db9b587..f759702 100644 --- a/backend/app/services/google_auth.py +++ b/backend/app/services/google_auth.py @@ -17,11 +17,11 @@ class GoogleIdentityError(Exception): - pass + """ID 토큰이 올바르지 않다(401). 카카오 ID 토큰 검증도 같이 쓴다.""" class GoogleServiceError(Exception): - pass + """서명 공개키를 받을 수 없다(503). 카카오 ID 토큰 검증도 같이 쓴다.""" @dataclass(frozen=True) @@ -29,22 +29,27 @@ class GoogleIdentity: subject: str nonce: str email: str | None = None + name: str | None = None -class GoogleIdentityVerifier: - """Android Credential Manager가 발급받은 Google ID 토큰을 검증한다. +class OidcIdentityVerifier: + """OpenID Connect ID 토큰(RS256)을 발급자의 공개키로 검증한다. AppleIdentityVerifier와 같은 키 캐시·실패 backoff 규칙을 따른다. + 발급자마다 [keys_url]·[issuers]만 다르다. """ + keys_url: str + issuers: tuple[str, ...] + def __init__( self, - settings: Settings, + audience: str, *, client: httpx.AsyncClient | None = None, clock: Callable[[], float] = time.monotonic, ) -> None: - self._audience = settings.google_client_id + self._audience = audience self._client = client or httpx.AsyncClient(timeout=10) self._owns_client = client is None self._clock = clock @@ -68,7 +73,7 @@ async def _key(self, kid: str) -> jwt.PyJWK: raise GoogleIdentityError("Unknown Google signing key") self._retry_at = now + 60 try: - response = await self._client.get(GOOGLE_KEYS_URL) + response = await self._client.get(self.keys_url) response.raise_for_status() document = response.json() if not isinstance(document, dict) or not isinstance(document.get("keys"), list): @@ -107,7 +112,7 @@ async def verify(self, id_token: str) -> GoogleIdentity: key.key, algorithms=["RS256"], audience=self._audience, - issuer=GOOGLE_ISSUERS, + issuer=self.issuers, leeway=CLOCK_SKEW_SECONDS, options={"require": ["exp", "iat", "sub", "nonce", "iss", "aud"]}, ) @@ -117,12 +122,36 @@ async def verify(self, id_token: str) -> GoogleIdentity: raise GoogleIdentityError("Invalid Google subject") if not isinstance(nonce, str) or not nonce or len(nonce) > 255: raise GoogleIdentityError("Invalid Google nonce") - # 계정 정보에 보여 줄 이메일. Google이 확인한 주소만 받는다. - email = claims.get("email") - verified = claims.get("email_verified") in (True, "true") - if not isinstance(email, str) or not verified or not 0 < len(email) <= 320: - email = None - return GoogleIdentity(subject=subject, nonce=nonce, email=email) + return GoogleIdentity( + subject=subject, nonce=nonce, email=self._email(claims), name=self._name(claims) + ) # 깊게 중첩된 헤더 JSON은 PyJWT 안에서 RecursionError가 된다(PYSEC-2026-4142). except (jwt.PyJWTError, TypeError, ValueError, RecursionError) as exc: raise GoogleIdentityError("Invalid Google ID token") from exc + + def _email(self, claims: dict) -> str | None: + """계정 정보에 보여 줄 이메일. 발급자가 확인한 주소만 받는다.""" + email = claims.get("email") + verified = claims.get("email_verified") in (True, "true") + if not isinstance(email, str) or not verified or not 0 < len(email) <= 320: + return None + return email + + def _name(self, claims: dict) -> str | None: + return None + + +class GoogleIdentityVerifier(OidcIdentityVerifier): + """Android Credential Manager가 발급받은 Google ID 토큰을 검증한다.""" + + keys_url = GOOGLE_KEYS_URL + issuers = GOOGLE_ISSUERS + + def __init__( + self, + settings: Settings, + *, + client: httpx.AsyncClient | None = None, + clock: Callable[[], float] = time.monotonic, + ) -> None: + super().__init__(settings.google_client_id, client=client, clock=clock) diff --git a/backend/app/services/kakao_auth.py b/backend/app/services/kakao_auth.py new file mode 100644 index 0000000..108c137 --- /dev/null +++ b/backend/app/services/kakao_auth.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import time +from collections.abc import Callable + +import httpx + +from app.config import Settings +from app.services.google_auth import OidcIdentityVerifier + +KAKAO_KEYS_URL = "https://kauth.kakao.com/.well-known/jwks.json" +KAKAO_ISSUERS = ("https://kauth.kakao.com",) + + +class KakaoIdentityVerifier(OidcIdentityVerifier): + """카카오 SDK 로그인(OpenID Connect)이 발급한 ID 토큰을 검증한다. + + 오류는 Google 과 같은 GoogleIdentityError(401)·GoogleServiceError(503)를 던진다. + """ + + keys_url = KAKAO_KEYS_URL + issuers = KAKAO_ISSUERS + + def __init__( + self, + settings: Settings, + *, + client: httpx.AsyncClient | None = None, + clock: Callable[[], float] = time.monotonic, + ) -> None: + super().__init__(settings.kakao_app_key, client=client, clock=clock) + + def _email(self, claims: dict) -> str | None: + # 카카오는 이용자가 이메일 제공에 동의했을 때만 email 을 넣는다 + email = claims.get("email") + if not isinstance(email, str) or not 0 < len(email) <= 320: + return None + return email + + def _name(self, claims: dict) -> str | None: + nickname = claims.get("nickname") + if not isinstance(nickname, str) or not nickname.strip(): + return None + return nickname.strip()[:80] diff --git a/backend/deploy/production.env.example b/backend/deploy/production.env.example index efdffc5..e936e15 100644 --- a/backend/deploy/production.env.example +++ b/backend/deploy/production.env.example @@ -24,6 +24,8 @@ APPLE_TEAM_ID=3X4434LYHD APPLE_KEY_ID= APPLE_KEY_FILE= GOOGLE_CLIENT_ID= +# Kakao native app key (the ID token audience). Empty disables Kakao login. +KAKAO_APP_KEY= # Absolute host path to the Firebase service account JSON. Empty disables Android push. FCM_KEY_FILE= DEEPGRAM_API_KEY= diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 004dee4..76498a2 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -31,6 +31,7 @@ x-backend-environment: &backend-environment APPLE_KEY_ID: ${APPLE_KEY_ID:-} APPLE_PRIVATE_KEY_PATH: /run/secrets/apple-signin.p8 GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + KAKAO_APP_KEY: ${KAKAO_APP_KEY:-} FCM_SERVICE_ACCOUNT_PATH: ${FCM_KEY_FILE:+/run/secrets/fcm-service-account.json} APNS_VOIP_ENABLED: "true" APNS_ENVIRONMENT: ${APNS_ENVIRONMENT:?Set APNS_ENVIRONMENT} diff --git a/backend/docs/family-symmetric-plan.md b/backend/docs/family-symmetric-plan.md index 66c849c..5e88c70 100644 --- a/backend/docs/family-symmetric-plan.md +++ b/backend/docs/family-symmetric-plan.md @@ -52,8 +52,14 @@ 기본값 모두 허용. `GET/PUT /v1/account/sharing`. S1 접근 권한이 이것을 본다. - 마케팅 수신 동의: `users.marketing_opt_in` + `PATCH /v1/account/preferences`. -### S5 카카오 로그인 (카카오 디벨로퍼스 앱 키가 생긴 뒤) -- `POST /v1/auth/kakao` — 안드로이드 SDK 가 받은 토큰을 서버가 카카오에 확인. Google 로그인과 같은 계정 연결 방식. +### S5 카카오 로그인 (2026-11-04 구현, 브랜치 `feat/kakao-login`) +- Google 과 같은 방식: `POST /v1/auth/kakao/challenge` → `{challengeId, nonce, expiresIn}`, 앱이 카카오 SDK 로그인에 + nonce 를 넣어 받은 OpenID Connect ID 토큰을 `POST /v1/auth/kakao {idToken, challengeId, name?}` 로 보낸다. +- 서버 검증: iss `https://kauth.kakao.com`, RS256, 공개키 `https://kauth.kakao.com/.well-known/jwks.json`, + aud = `KAKAO_APP_KEY`(네이티브 앱 키), nonce 일치, 준비 요청은 한 번만. 비우면 503. +- 계정은 `users.kakao_subject` 로 찾고 없으면 역할 없이(저장은 CHILD) 만든다 — 가족은 첫 초대 때. 이름 기본값은 + ID 토큰 `nickname`, 이메일은 `email`(이용자가 제공에 동의했을 때만 들어온다). 마이그레이션 `0015_kakao_login`. +- 카카오 콘솔: 카카오 로그인과 OpenID Connect 를 켜야 ID 토큰이 나온다. 계정 삭제 때 카카오 연결 끊기(unlink)는 하지 않는다(Google 도 같음). ### S6 PASS 본인인증 (대행사 계약 뒤) - 계약 전까지 앱의 PASS 화면은 꺼 둔다. diff --git a/backend/tests/test_kakao_login.py b/backend/tests/test_kakao_login.py new file mode 100644 index 0000000..ef2dca1 --- /dev/null +++ b/backend/tests/test_kakao_login.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator +from datetime import UTC, datetime, timedelta + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi.testclient import TestClient + +from app.services.kakao_auth import KakaoIdentityVerifier +from tests.conftest import auth + +APP_KEY = "test-kakao-native-app-key" + + +@pytest.fixture +def sign_kakao(client: TestClient) -> Iterator[Callable[..., str]]: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + jwk = jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key(), as_dict=True) + jwk.update(kid="test-kakao", alg="RS256", use="sig") + transport = httpx.MockTransport(lambda request: httpx.Response(200, json={"keys": [jwk]})) + http = httpx.AsyncClient(transport=transport) + container = client.app.state.container + container.settings.kakao_app_key = APP_KEY + client.portal.call(container.kakao_identity.close) + container.kakao_identity = KakaoIdentityVerifier(container.settings, client=http) + + def sign(nonce: str, subject: str = "kakao-user", **claims: object) -> str: + now = datetime.now(UTC) + return jwt.encode( + { + "sub": subject, + "nonce": nonce, + "iss": "https://kauth.kakao.com", + "aud": APP_KEY, + "iat": now, + "exp": now + timedelta(minutes=5), + **claims, + }, + key, + algorithm="RS256", + headers={"kid": "test-kakao"}, + ) + + yield sign + client.portal.call(http.aclose) + + +def kakao_payload(client: TestClient, sign: Callable[..., str], **claims: object) -> dict: + response = client.post("/v1/auth/kakao/challenge") + assert response.status_code == 200, response.text + challenge = response.json() + return {"challengeId": challenge["challengeId"], "idToken": sign(challenge["nonce"], **claims)} + + +def test_kakao_login_creates_an_account_without_a_family(client: TestClient, sign_kakao) -> None: + payload = kakao_payload(client, sign_kakao, nickname="카카오친구", email="friend@kakao.com") + response = client.post("/v1/auth/kakao", json=payload) + assert response.status_code == 200, response.text + user = response.json()["user"] + assert user["name"] == "카카오친구" + assert user["email"] == "friend@kakao.com" + assert user["familyId"] is None + assert user["signupCompleted"] is False + + again = client.post("/v1/auth/kakao", json=kakao_payload(client, sign_kakao)) + assert again.status_code == 200, again.text + assert again.json()["user"]["id"] == user["id"] + + +def test_kakao_challenge_is_single_use_and_bound_to_the_nonce( + client: TestClient, sign_kakao +) -> None: + payload = kakao_payload(client, sign_kakao) + assert client.post("/v1/auth/kakao", json=payload).status_code == 200 + assert client.post("/v1/auth/kakao", json=payload).status_code == 401 + + challenge = client.post("/v1/auth/kakao/challenge").json() + wrong = {"challengeId": challenge["challengeId"], "idToken": sign_kakao("other-nonce")} + assert client.post("/v1/auth/kakao", json=wrong).status_code == 401 + + +def test_kakao_token_for_another_app_is_rejected(client: TestClient, sign_kakao) -> None: + challenge = client.post("/v1/auth/kakao/challenge").json() + other_app = sign_kakao(challenge["nonce"], aud="someone-elses-app") + response = client.post( + "/v1/auth/kakao", json={"challengeId": challenge["challengeId"], "idToken": other_app} + ) + assert response.status_code == 401 + + +def test_google_challenge_is_not_accepted_for_kakao(client: TestClient, sign_kakao) -> None: + client.app.state.container.settings.google_client_id = "test-web-client" + google = client.post("/v1/auth/google/challenge").json() + payload = {"challengeId": google["challengeId"], "idToken": sign_kakao(google["nonce"])} + assert client.post("/v1/auth/kakao", json=payload).status_code == 401 + + +def test_unconfigured_kakao_login_is_unavailable(client: TestClient) -> None: + assert client.post("/v1/auth/kakao/challenge").status_code == 503 + payload = {"challengeId": "0" * 36, "idToken": "token"} + assert client.post("/v1/auth/kakao", json=payload).status_code == 503 + + +def test_kakao_account_can_sign_up_and_be_deleted(client: TestClient, sign_kakao) -> None: + login = client.post("/v1/auth/kakao", json=kakao_payload(client, sign_kakao)).json() + token = login["accessToken"] + profile = client.patch( + "/v1/account/profile", + headers=auth(token), + json={"nickname": "카카오가족", "gender": "MALE", "birthDate": "1990-01-01"}, + ) + assert profile.status_code == 200, profile.text + assert profile.json()["signupCompleted"] is True + assert client.delete("/v1/account", headers=auth(token)).status_code == 204 From 7435b531f04903a8629e2dc8af772813f217092e Mon Sep 17 00:00:00 2001 From: Kwag YongJin Date: Thu, 5 Nov 2026 13:51:24 +0900 Subject: [PATCH 23/23] chore(deploy): default the Google web client ID for Android sign-in The web client ID is public (it is the audience of Android ID tokens), so the production compose file now falls back to the Collog Firebase web client when backend.env does not set GOOGLE_CLIENT_ID. Android sign-in works on the existing production env without a host change. --- backend/deploy/production.env.example | 1 + backend/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/deploy/production.env.example b/backend/deploy/production.env.example index e936e15..8cfb46b 100644 --- a/backend/deploy/production.env.example +++ b/backend/deploy/production.env.example @@ -23,6 +23,7 @@ APPLE_CLIENT_ID=com.dohyeoplim.collog-ios APPLE_TEAM_ID=3X4434LYHD APPLE_KEY_ID= APPLE_KEY_FILE= +# Google web client ID for Android sign-in. Empty uses the Collog Firebase web client. GOOGLE_CLIENT_ID= # Kakao native app key (the ID token audience). Empty disables Kakao login. KAKAO_APP_KEY= diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 76498a2..2214c64 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -30,7 +30,7 @@ x-backend-environment: &backend-environment APPLE_TEAM_ID: ${APPLE_TEAM_ID:-} APPLE_KEY_ID: ${APPLE_KEY_ID:-} APPLE_PRIVATE_KEY_PATH: /run/secrets/apple-signin.p8 - GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-716006474161-cocqrkcm916la6tfbcqtfjclih6urq21.apps.googleusercontent.com} KAKAO_APP_KEY: ${KAKAO_APP_KEY:-} FCM_SERVICE_ACCOUNT_PATH: ${FCM_KEY_FILE:+/run/secrets/fcm-service-account.json} APNS_VOIP_ENABLED: "true"