diff --git a/.gitignore b/.gitignore index a9ae62d..3de4e0b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ node_modules/ .DS_Store Thumbs.db -current_pr.md \ No newline at end of file +current_pr.md +next_pr.md diff --git a/MANUAL_TEST_INVITES.md b/MANUAL_TEST_INVITES.md new file mode 100644 index 0000000..fc6a41b --- /dev/null +++ b/MANUAL_TEST_INVITES.md @@ -0,0 +1,75 @@ +# Invite System - Manual Testing Guide + +## Prerequisites +- `make dev` running +- Seeded users: `admin@example.com` / `admin123`, `user@example.com` / `user123` + +--- + +## 1. Admin: Create Invites + +1. Login as **admin@example.com** +2. Go to `/admin/invites` +3. ✅ Dropdown shows list of users (not a number input) +4. Select `user@example.com` → Click **Create Invite** +5. ✅ New invite appears with code like `apple-banana-42`, status "ready" + +## 2. Admin: Revoke Invite + +1. On `/admin/invites`, click **Revoke** on a ready invite +2. ✅ Status changes to "revoked" + +## 3. Admin: Filter Invites + +1. Use status dropdown to filter by "ready", "spent", "revoked" +2. ✅ Table updates accordingly + +--- + +## 4. Signup with Invite Code + +1. **Logout** → Go to `/signup` +2. Enter a valid invite code → Click **Continue** +3. ✅ Form shows "Create account" with email/password fields +4. Fill in new email + password → Submit +5. ✅ Redirected to home, logged in + +## 5. Signup via Direct Link + +1. Copy a ready invite code (e.g., `apple-banana-42`) +2. Go to `/signup/apple-banana-42` +3. ✅ Redirects to `/signup?code=...` with code pre-validated +4. Complete registration +5. ✅ Works same as manual entry + +## 6. Signup Error Cases + +1. **Invalid code**: Enter `fake-code-99` → ✅ Shows "not found" error +2. **Spent invite**: Use an already-used code → ✅ Shows error +3. **Revoked invite**: Use a revoked code → ✅ Shows error + +--- + +## 7. Regular User: My Invites + +1. Login as the **newly created user** (or `user@example.com` if they have invites) +2. Go to `/invites` +3. ✅ Shows list of invites assigned to this user +4. ✅ Spent invites show who used them + +## 8. Profile: Godfather Display + +1. Login as user created via invite +2. Go to `/profile` +3. ✅ "Invited By" shows the godfather's email (read-only) + +--- + +## Quick Smoke Test (2 min) + +1. Admin login → `/admin/invites` → Create invite for `user@example.com` +2. Copy invite code +3. Logout → `/signup` → Enter code → Register new user +4. Check `/profile` shows godfather +5. Admin: verify invite status is "spent" with used_by email + diff --git a/backend/invite_utils.py b/backend/invite_utils.py new file mode 100644 index 0000000..af19d7a --- /dev/null +++ b/backend/invite_utils.py @@ -0,0 +1,60 @@ +"""Utilities for invite code generation and validation.""" +import random +from pathlib import Path + +# Load BIP39 words from file +_WORDS_FILE = Path(__file__).parent / "words.txt" +with open(_WORDS_FILE) as f: + BIP39_WORDS = [line.strip() for line in f if line.strip()] + +assert len(BIP39_WORDS) == 2048, f"Expected 2048 BIP39 words, got {len(BIP39_WORDS)}" + + +def generate_invite_identifier() -> str: + """ + Generate a unique invite identifier. + + Format: word1-word2-NN where: + - word1, word2 are random BIP39 words + - NN is a two-digit number (00-99) + + Returns lowercase identifier. + """ + word1 = random.choice(BIP39_WORDS) + word2 = random.choice(BIP39_WORDS) + number = random.randint(0, 99) + return f"{word1}-{word2}-{number:02d}" + + +def normalize_identifier(identifier: str) -> str: + """ + Normalize an invite identifier for comparison/lookup. + + - Converts to lowercase + - Strips whitespace + """ + return identifier.strip().lower() + + +def is_valid_identifier_format(identifier: str) -> bool: + """ + Check if an identifier has valid format (word-word-NN). + + Does NOT check if words are valid BIP39 words. + """ + parts = identifier.split("-") + if len(parts) != 3: + return False + + word1, word2, number = parts + + # Check words are non-empty + if not word1 or not word2: + return False + + # Check number is two digits + if len(number) != 2 or not number.isdigit(): + return False + + return True + diff --git a/backend/main.py b/backend/main.py index 64c5706..b5e18f5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,10 +1,10 @@ from contextlib import asynccontextmanager -from datetime import datetime +from datetime import datetime, UTC from typing import Callable, Generic, TypeVar from fastapi import FastAPI, Depends, HTTPException, Response, status, Query from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr from sqlalchemy import select, func, desc from sqlalchemy.ext.asyncio import AsyncSession @@ -23,8 +23,9 @@ from auth import ( build_user_response, ) from database import engine, get_db, Base -from models import Counter, User, SumRecord, CounterRecord, Permission, Role, ROLE_REGULAR +from models import Counter, User, SumRecord, CounterRecord, Permission, Role, ROLE_REGULAR, Invite, InviteStatus from validation import validate_profile_fields +from invite_utils import generate_invite_identifier, normalize_identifier R = TypeVar("R", bound=BaseModel) @@ -98,13 +99,88 @@ async def get_default_role(db: AsyncSession) -> Role | None: return result.scalar_one_or_none() +# Invite check endpoint (public) +class InviteCheckResponse(BaseModel): + """Response for invite check endpoint.""" + valid: bool + status: str | None = None + error: str | None = None + + +@app.get("/api/invites/{identifier}/check", response_model=InviteCheckResponse) +async def check_invite( + identifier: str, + db: AsyncSession = Depends(get_db), +): + """Check if an invite is valid and can be used for signup.""" + normalized = normalize_identifier(identifier) + + result = await db.execute( + select(Invite).where(Invite.identifier == normalized) + ) + invite = result.scalar_one_or_none() + + if not invite: + return InviteCheckResponse(valid=False, error="Invite not found") + + if invite.status == InviteStatus.SPENT: + return InviteCheckResponse( + valid=False, + status=invite.status.value, + error="This invite has already been used" + ) + + if invite.status == InviteStatus.REVOKED: + return InviteCheckResponse( + valid=False, + status=invite.status.value, + error="This invite has been revoked" + ) + + return InviteCheckResponse(valid=True, status=invite.status.value) + + # Auth endpoints +class RegisterWithInvite(BaseModel): + """Request model for registration with invite.""" + email: EmailStr + password: str + invite_identifier: str + + @app.post("/api/auth/register", response_model=UserResponse) async def register( - user_data: UserCreate, + user_data: RegisterWithInvite, response: Response, db: AsyncSession = Depends(get_db), ): + """Register a new user using an invite code.""" + # Validate invite + normalized_identifier = normalize_identifier(user_data.invite_identifier) + result = await db.execute( + select(Invite).where(Invite.identifier == normalized_identifier) + ) + invite = result.scalar_one_or_none() + + if not invite: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid invite code", + ) + + if invite.status == InviteStatus.SPENT: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This invite has already been used", + ) + + if invite.status == InviteStatus.REVOKED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This invite has been revoked", + ) + + # Check email not already taken existing_user = await get_user_by_email(db, user_data.email) if existing_user: raise HTTPException( @@ -112,17 +188,26 @@ async def register( detail="Email already registered", ) + # Create user with godfather user = User( email=user_data.email, hashed_password=get_password_hash(user_data.password), + godfather_id=invite.godfather_id, ) - # Assign default role if it exists + # Assign default role default_role = await get_default_role(db) if default_role: user.roles.append(default_role) db.add(user) + await db.flush() # Get user ID + + # Mark invite as spent + invite.status = InviteStatus.SPENT + invite.used_by_id = user.id + invite.spent_at = datetime.now(UTC) + await db.commit() await db.refresh(user) @@ -333,6 +418,7 @@ class ProfileResponse(BaseModel): telegram: str | None signal: str | None nostr_npub: str | None + godfather_email: str | None = None class ProfileUpdate(BaseModel): @@ -358,13 +444,22 @@ async def require_regular_user( @app.get("/api/profile", response_model=ProfileResponse) async def get_profile( current_user: User = Depends(require_regular_user), + db: AsyncSession = Depends(get_db), ): - """Get the current user's profile (contact details).""" + """Get the current user's profile (contact details and godfather).""" + godfather_email = None + if current_user.godfather_id: + result = await db.execute( + select(User.email).where(User.id == current_user.godfather_id) + ) + godfather_email = result.scalar_one_or_none() + return ProfileResponse( contact_email=current_user.contact_email, telegram=current_user.telegram, signal=current_user.signal, nostr_npub=current_user.nostr_npub, + godfather_email=godfather_email, ) @@ -398,9 +493,274 @@ async def update_profile( await db.commit() await db.refresh(current_user) + # Get godfather email if set + godfather_email = None + if current_user.godfather_id: + gf_result = await db.execute( + select(User.email).where(User.id == current_user.godfather_id) + ) + godfather_email = gf_result.scalar_one_or_none() + return ProfileResponse( contact_email=current_user.contact_email, telegram=current_user.telegram, signal=current_user.signal, nostr_npub=current_user.nostr_npub, + godfather_email=godfather_email, + ) + + +# Invite endpoints +class InviteCreate(BaseModel): + """Request model for creating an invite.""" + godfather_id: int + + +class InviteResponse(BaseModel): + """Response model for invite data.""" + id: int + identifier: str + godfather_id: int + godfather_email: str + status: str + used_by_id: int | None + used_by_email: str | None + created_at: datetime + spent_at: datetime | None + revoked_at: datetime | None + + +@app.post("/api/admin/invites", response_model=InviteResponse) +async def create_invite( + data: InviteCreate, + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), +): + """Create a new invite for a specified godfather user.""" + # Validate godfather exists + result = await db.execute(select(User).where(User.id == data.godfather_id)) + godfather = result.scalar_one_or_none() + if not godfather: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Godfather user not found", + ) + + # Generate unique identifier + identifier = generate_invite_identifier() + + # Create invite + invite = Invite( + identifier=identifier, + godfather_id=godfather.id, + status=InviteStatus.READY, + ) + db.add(invite) + await db.commit() + await db.refresh(invite) + + return InviteResponse( + id=invite.id, + identifier=invite.identifier, + godfather_id=invite.godfather_id, + godfather_email=godfather.email, + status=invite.status.value, + used_by_id=invite.used_by_id, + used_by_email=None, + created_at=invite.created_at, + spent_at=invite.spent_at, + revoked_at=invite.revoked_at, + ) + + +class UserInviteResponse(BaseModel): + """Response model for a user's invite (simpler than admin view).""" + id: int + identifier: str + status: str + used_by_email: str | None + created_at: datetime + spent_at: datetime | None + + +@app.get("/api/invites", response_model=list[UserInviteResponse]) +async def get_my_invites( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_permission(Permission.VIEW_OWN_INVITES)), +): + """Get all invites owned by the current user.""" + result = await db.execute( + select(Invite) + .where(Invite.godfather_id == current_user.id) + .order_by(desc(Invite.created_at)) + ) + invites = result.scalars().all() + + responses = [] + for invite in invites: + used_by_email = None + if invite.used_by_id: + # Fetch the user who used this invite + user_result = await db.execute( + select(User.email).where(User.id == invite.used_by_id) + ) + used_by_email = user_result.scalar_one_or_none() + + responses.append(UserInviteResponse( + id=invite.id, + identifier=invite.identifier, + status=invite.status.value, + used_by_email=used_by_email, + created_at=invite.created_at, + spent_at=invite.spent_at, + )) + + return responses + + +# Admin Invite Management +PaginatedInviteRecords = PaginatedResponse[InviteResponse] + + +class AdminUserResponse(BaseModel): + """Minimal user info for admin dropdowns.""" + id: int + email: str + + +@app.get("/api/admin/users", response_model=list[AdminUserResponse]) +async def list_users_for_admin( + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), +): + """List all users for admin dropdowns (invite creation, etc.).""" + result = await db.execute(select(User.id, User.email).order_by(User.email)) + users = result.all() + return [AdminUserResponse(id=u.id, email=u.email) for u in users] + + +@app.get("/api/admin/invites", response_model=PaginatedInviteRecords) +async def list_all_invites( + page: int = Query(1, ge=1), + per_page: int = Query(10, ge=1, le=100), + status_filter: str | None = Query(None, alias="status", description="Filter by status: ready, spent, revoked"), + godfather_id: int | None = Query(None, description="Filter by godfather user ID"), + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), +): + """List all invites with optional filtering and pagination.""" + # Build query + query = select(Invite) + count_query = select(func.count(Invite.id)) + + # Apply filters + if status_filter: + try: + status_enum = InviteStatus(status_filter) + query = query.where(Invite.status == status_enum) + count_query = count_query.where(Invite.status == status_enum) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid status: {status_filter}. Must be ready, spent, or revoked", + ) + + if godfather_id: + query = query.where(Invite.godfather_id == godfather_id) + count_query = count_query.where(Invite.godfather_id == godfather_id) + + # Get total count + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + total_pages = (total + per_page - 1) // per_page if total > 0 else 1 + + # Get paginated invites + offset = (page - 1) * per_page + query = query.order_by(desc(Invite.created_at)).offset(offset).limit(per_page) + result = await db.execute(query) + invites = result.scalars().all() + + # Build responses with user emails + records = [] + for invite in invites: + # Get godfather email + gf_result = await db.execute( + select(User.email).where(User.id == invite.godfather_id) + ) + godfather_email = gf_result.scalar_one() + + # Get used_by email if applicable + used_by_email = None + if invite.used_by_id: + ub_result = await db.execute( + select(User.email).where(User.id == invite.used_by_id) + ) + used_by_email = ub_result.scalar_one_or_none() + + records.append(InviteResponse( + id=invite.id, + identifier=invite.identifier, + godfather_id=invite.godfather_id, + godfather_email=godfather_email, + status=invite.status.value, + used_by_id=invite.used_by_id, + used_by_email=used_by_email, + created_at=invite.created_at, + spent_at=invite.spent_at, + revoked_at=invite.revoked_at, + )) + + return PaginatedInviteRecords( + records=records, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages, + ) + + +@app.post("/api/admin/invites/{invite_id}/revoke", response_model=InviteResponse) +async def revoke_invite( + invite_id: int, + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), +): + """Revoke an invite. Only READY invites can be revoked.""" + result = await db.execute(select(Invite).where(Invite.id == invite_id)) + invite = result.scalar_one_or_none() + + if not invite: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invite not found", + ) + + if invite.status != InviteStatus.READY: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot revoke invite with status '{invite.status.value}'. Only READY invites can be revoked.", + ) + + invite.status = InviteStatus.REVOKED + invite.revoked_at = datetime.now(UTC) + await db.commit() + await db.refresh(invite) + + # Get godfather email + gf_result = await db.execute( + select(User.email).where(User.id == invite.godfather_id) + ) + godfather_email = gf_result.scalar_one() + + return InviteResponse( + id=invite.id, + identifier=invite.identifier, + godfather_id=invite.godfather_id, + godfather_email=godfather_email, + status=invite.status.value, + used_by_id=invite.used_by_id, + used_by_email=None, + created_at=invite.created_at, + spent_at=invite.spent_at, + revoked_at=invite.revoked_at, ) diff --git a/backend/models.py b/backend/models.py index e569adb..25a4154 100644 --- a/backend/models.py +++ b/backend/models.py @@ -23,6 +23,17 @@ class Permission(str, PyEnum): # Audit permissions VIEW_AUDIT = "view_audit" + + # Invite permissions + MANAGE_INVITES = "manage_invites" + VIEW_OWN_INVITES = "view_own_invites" + + +class InviteStatus(str, PyEnum): + """Status of an invite.""" + READY = "ready" + SPENT = "spent" + REVOKED = "revoked" # Role name constants @@ -32,17 +43,19 @@ ROLE_REGULAR = "regular" # Role definitions with their permissions ROLE_DEFINITIONS: dict[str, RoleConfig] = { ROLE_ADMIN: { - "description": "Administrator with audit access", + "description": "Administrator with audit and invite management access", "permissions": [ Permission.VIEW_AUDIT, + Permission.MANAGE_INVITES, ], }, ROLE_REGULAR: { - "description": "Regular user with counter and sum access", + "description": "Regular user with counter, sum, and invite access", "permissions": [ Permission.VIEW_COUNTER, Permission.INCREMENT_COUNTER, Permission.USE_SUM, + Permission.VIEW_OWN_INVITES, ], }, } @@ -107,6 +120,16 @@ class User(Base): signal: Mapped[str | None] = mapped_column(String(64), nullable=True) nostr_npub: Mapped[str | None] = mapped_column(String(63), nullable=True) + # Godfather (who invited this user) - null for seeded/admin users + godfather_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id"), nullable=True + ) + godfather: Mapped["User | None"] = relationship( + "User", + remote_side="User.id", + foreign_keys=[godfather_id], + ) + # Relationship to roles roles: Mapped[list[Role]] = relationship( "Role", @@ -165,3 +188,40 @@ class CounterRecord(Base): created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(UTC) ) + + +class Invite(Base): + __tablename__ = "invites" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + identifier: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + status: Mapped[InviteStatus] = mapped_column( + Enum(InviteStatus), nullable=False, default=InviteStatus.READY + ) + + # Godfather - the user who owns this invite + godfather_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + godfather: Mapped[User] = relationship( + "User", + foreign_keys=[godfather_id], + lazy="selectin", + ) + + # User who used this invite (null until spent) + used_by_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id"), nullable=True + ) + used_by: Mapped[User | None] = relationship( + "User", + foreign_keys=[used_by_id], + lazy="selectin", + ) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC) + ) + spent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/tests/helpers.py b/backend/tests/helpers.py index 8c11693..5ee20c5 100644 --- a/backend/tests/helpers.py +++ b/backend/tests/helpers.py @@ -1,7 +1,51 @@ import uuid +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from models import User, Invite, InviteStatus, ROLE_ADMIN +from invite_utils import generate_invite_identifier + def unique_email(prefix: str = "test") -> str: """Generate a unique email for tests sharing the same database.""" return f"{prefix}-{uuid.uuid4().hex[:8]}@example.com" + +async def create_invite_for_registration(db: AsyncSession, godfather_email: str) -> str: + """ + Create an invite that can be used for registration. + Returns the invite identifier. + """ + # Find godfather + result = await db.execute(select(User).where(User.email == godfather_email)) + godfather = result.scalar_one_or_none() + + if not godfather: + # Create a godfather user (admin can create invites) + from auth import get_password_hash + from models import Role + + result = await db.execute(select(Role).where(Role.name == ROLE_ADMIN)) + admin_role = result.scalar_one_or_none() + + godfather = User( + email=godfather_email, + hashed_password=get_password_hash("password123"), + roles=[admin_role] if admin_role else [], + ) + db.add(godfather) + await db.flush() + + # Create invite + identifier = generate_invite_identifier() + invite = Invite( + identifier=identifier, + godfather_id=godfather.id, + status=InviteStatus.READY, + ) + db.add(invite) + await db.commit() + + return identifier + diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 7cbfd56..e5576be 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,16 +1,31 @@ +"""Tests for authentication endpoints. + +Note: Registration now requires an invite code. Tests that need to register +users will create invites first via the helper function. +""" import pytest from auth import COOKIE_NAME -from tests.helpers import unique_email +from tests.helpers import unique_email, create_invite_for_registration -# Registration tests +# Registration tests (with invite) @pytest.mark.asyncio -async def test_register_success(client): +async def test_register_success(client_factory): + """Can register with valid invite code.""" email = unique_email("register") - response = await client.post( + + # Create invite + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("godfather")) + + response = await client_factory.post( "/api/auth/register", - json={"email": email, "password": "password123"}, + json={ + "email": email, + "password": "password123", + "invite_identifier": invite_code, + }, ) assert response.status_code == 200 data = response.json() @@ -25,62 +40,110 @@ async def test_register_success(client): @pytest.mark.asyncio -async def test_register_duplicate_email(client): +async def test_register_duplicate_email(client_factory): + """Cannot register with already-used email.""" email = unique_email("duplicate") - await client.post( + + # Create two invites + async with client_factory.get_db_session() as db: + invite1 = await create_invite_for_registration(db, unique_email("gf1")) + invite2 = await create_invite_for_registration(db, unique_email("gf2")) + + # First registration + await client_factory.post( "/api/auth/register", - json={"email": email, "password": "password123"}, + json={ + "email": email, + "password": "password123", + "invite_identifier": invite1, + }, ) - response = await client.post( + + # Second registration with same email + response = await client_factory.post( "/api/auth/register", - json={"email": email, "password": "differentpass"}, + json={ + "email": email, + "password": "differentpass", + "invite_identifier": invite2, + }, ) assert response.status_code == 400 assert response.json()["detail"] == "Email already registered" @pytest.mark.asyncio -async def test_register_invalid_email(client): - response = await client.post( +async def test_register_invalid_email(client_factory): + """Cannot register with invalid email format.""" + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + + response = await client_factory.post( "/api/auth/register", - json={"email": "notanemail", "password": "password123"}, + json={ + "email": "notanemail", + "password": "password123", + "invite_identifier": invite_code, + }, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_register_missing_password(client): + """Cannot register without password.""" response = await client.post( "/api/auth/register", - json={"email": unique_email()}, + json={"email": unique_email(), "invite_identifier": "some-code-00"}, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_register_missing_email(client): + """Cannot register without email.""" response = await client.post( "/api/auth/register", - json={"password": "password123"}, + json={"password": "password123", "invite_identifier": "some-code-00"}, + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_register_missing_invite(client): + """Cannot register without invite code.""" + response = await client.post( + "/api/auth/register", + json={"email": unique_email(), "password": "password123"}, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_register_empty_body(client): + """Cannot register with empty body.""" response = await client.post("/api/auth/register", json={}) assert response.status_code == 422 # Login tests @pytest.mark.asyncio -async def test_login_success(client): +async def test_login_success(client_factory): + """Can login with valid credentials.""" email = unique_email("login") - await client.post( + + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + + await client_factory.post( "/api/auth/register", - json={"email": email, "password": "password123"}, + json={ + "email": email, + "password": "password123", + "invite_identifier": invite_code, + }, ) - response = await client.post( + response = await client_factory.post( "/api/auth/login", json={"email": email, "password": "password123"}, ) @@ -93,13 +156,22 @@ async def test_login_success(client): @pytest.mark.asyncio -async def test_login_wrong_password(client): +async def test_login_wrong_password(client_factory): + """Cannot login with wrong password.""" email = unique_email("wrongpass") - await client.post( + + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + + await client_factory.post( "/api/auth/register", - json={"email": email, "password": "correctpassword"}, + json={ + "email": email, + "password": "correctpassword", + "invite_identifier": invite_code, + }, ) - response = await client.post( + response = await client_factory.post( "/api/auth/login", json={"email": email, "password": "wrongpassword"}, ) @@ -109,6 +181,7 @@ async def test_login_wrong_password(client): @pytest.mark.asyncio async def test_login_nonexistent_user(client): + """Cannot login with non-existent user.""" response = await client.post( "/api/auth/login", json={"email": unique_email("nonexistent"), "password": "password123"}, @@ -119,6 +192,7 @@ async def test_login_nonexistent_user(client): @pytest.mark.asyncio async def test_login_invalid_email_format(client): + """Cannot login with invalid email format.""" response = await client.post( "/api/auth/login", json={"email": "invalidemail", "password": "password123"}, @@ -128,6 +202,7 @@ async def test_login_invalid_email_format(client): @pytest.mark.asyncio async def test_login_missing_fields(client): + """Cannot login with missing fields.""" response = await client.post("/api/auth/login", json={}) assert response.status_code == 422 @@ -135,16 +210,22 @@ async def test_login_missing_fields(client): # Get current user tests @pytest.mark.asyncio async def test_get_me_success(client_factory): + """Can get current user info when authenticated.""" email = unique_email("me") - # Register and get cookies + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + reg_response = await client_factory.post( "/api/auth/register", - json={"email": email, "password": "password123"}, + json={ + "email": email, + "password": "password123", + "invite_identifier": invite_code, + }, ) cookies = dict(reg_response.cookies) - # Use authenticated client async with client_factory.create(cookies=cookies) as authed: response = await authed.get("/api/auth/me") @@ -158,12 +239,14 @@ async def test_get_me_success(client_factory): @pytest.mark.asyncio async def test_get_me_no_cookie(client): + """Cannot get current user without auth cookie.""" response = await client.get("/api/auth/me") assert response.status_code == 401 @pytest.mark.asyncio async def test_get_me_invalid_cookie(client_factory): + """Cannot get current user with invalid cookie.""" async with client_factory.create(cookies={COOKIE_NAME: "invalidtoken123"}) as authed: response = await authed.get("/api/auth/me") assert response.status_code == 401 @@ -172,6 +255,7 @@ async def test_get_me_invalid_cookie(client_factory): @pytest.mark.asyncio async def test_get_me_expired_token(client_factory): + """Cannot get current user with expired token.""" bad_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImV4cCI6MH0.invalid" async with client_factory.create(cookies={COOKIE_NAME: bad_token}) as authed: response = await authed.get("/api/auth/me") @@ -181,11 +265,19 @@ async def test_get_me_expired_token(client_factory): # Cookie validation tests @pytest.mark.asyncio async def test_cookie_from_register_works_for_me(client_factory): + """Auth cookie from registration works for subsequent requests.""" email = unique_email("tokentest") + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + reg_response = await client_factory.post( "/api/auth/register", - json={"email": email, "password": "password123"}, + json={ + "email": email, + "password": "password123", + "invite_identifier": invite_code, + }, ) cookies = dict(reg_response.cookies) @@ -198,11 +290,19 @@ async def test_cookie_from_register_works_for_me(client_factory): @pytest.mark.asyncio async def test_cookie_from_login_works_for_me(client_factory): + """Auth cookie from login works for subsequent requests.""" email = unique_email("logintoken") + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + await client_factory.post( "/api/auth/register", - json={"email": email, "password": "password123"}, + json={ + "email": email, + "password": "password123", + "invite_identifier": invite_code, + }, ) login_response = await client_factory.post( "/api/auth/login", @@ -220,16 +320,29 @@ async def test_cookie_from_login_works_for_me(client_factory): # Multiple users tests @pytest.mark.asyncio async def test_multiple_users_isolated(client_factory): + """Multiple users have isolated sessions.""" email1 = unique_email("user1") email2 = unique_email("user2") + async with client_factory.get_db_session() as db: + invite1 = await create_invite_for_registration(db, unique_email("gf1")) + invite2 = await create_invite_for_registration(db, unique_email("gf2")) + resp1 = await client_factory.post( "/api/auth/register", - json={"email": email1, "password": "password1"}, + json={ + "email": email1, + "password": "password1", + "invite_identifier": invite1, + }, ) resp2 = await client_factory.post( "/api/auth/register", - json={"email": email2, "password": "password2"}, + json={ + "email": email2, + "password": "password2", + "invite_identifier": invite2, + }, ) cookies1 = dict(resp1.cookies) @@ -248,13 +361,22 @@ async def test_multiple_users_isolated(client_factory): # Password tests @pytest.mark.asyncio -async def test_password_is_hashed(client): +async def test_password_is_hashed(client_factory): + """Passwords are properly hashed (can login with correct password).""" email = unique_email("hashtest") - await client.post( + + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + + await client_factory.post( "/api/auth/register", - json={"email": email, "password": "mySecurePassword123"}, + json={ + "email": email, + "password": "mySecurePassword123", + "invite_identifier": invite_code, + }, ) - response = await client.post( + response = await client_factory.post( "/api/auth/login", json={"email": email, "password": "mySecurePassword123"}, ) @@ -262,13 +384,22 @@ async def test_password_is_hashed(client): @pytest.mark.asyncio -async def test_case_sensitive_password(client): +async def test_case_sensitive_password(client_factory): + """Passwords are case-sensitive.""" email = unique_email("casetest") - await client.post( + + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + + await client_factory.post( "/api/auth/register", - json={"email": email, "password": "Password123"}, + json={ + "email": email, + "password": "Password123", + "invite_identifier": invite_code, + }, ) - response = await client.post( + response = await client_factory.post( "/api/auth/login", json={"email": email, "password": "password123"}, ) @@ -278,11 +409,19 @@ async def test_case_sensitive_password(client): # Logout tests @pytest.mark.asyncio async def test_logout_success(client_factory): + """Can logout successfully.""" email = unique_email("logout") + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + reg_response = await client_factory.post( "/api/auth/register", - json={"email": email, "password": "password123"}, + json={ + "email": email, + "password": "password123", + "invite_identifier": invite_code, + }, ) cookies = dict(reg_response.cookies) diff --git a/backend/tests/test_counter.py b/backend/tests/test_counter.py index 080c7b9..b9c4cba 100644 --- a/backend/tests/test_counter.py +++ b/backend/tests/test_counter.py @@ -1,7 +1,11 @@ +"""Tests for counter endpoints. + +Note: Registration now requires an invite code. +""" import pytest from auth import COOKIE_NAME -from tests.helpers import unique_email +from tests.helpers import unique_email, create_invite_for_registration # Protected endpoint tests - without auth @@ -34,9 +38,16 @@ async def test_increment_counter_invalid_cookie(client_factory): # Authenticated counter tests @pytest.mark.asyncio async def test_get_counter_authenticated(client_factory): + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + reg = await client_factory.post( "/api/auth/register", - json={"email": unique_email(), "password": "testpass123"}, + json={ + "email": unique_email(), + "password": "testpass123", + "invite_identifier": invite_code, + }, ) cookies = dict(reg.cookies) @@ -49,9 +60,16 @@ async def test_get_counter_authenticated(client_factory): @pytest.mark.asyncio async def test_increment_counter(client_factory): + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + reg = await client_factory.post( "/api/auth/register", - json={"email": unique_email(), "password": "testpass123"}, + json={ + "email": unique_email(), + "password": "testpass123", + "invite_identifier": invite_code, + }, ) cookies = dict(reg.cookies) @@ -68,9 +86,16 @@ async def test_increment_counter(client_factory): @pytest.mark.asyncio async def test_increment_counter_multiple(client_factory): + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + reg = await client_factory.post( "/api/auth/register", - json={"email": unique_email(), "password": "testpass123"}, + json={ + "email": unique_email(), + "password": "testpass123", + "invite_identifier": invite_code, + }, ) cookies = dict(reg.cookies) @@ -89,9 +114,16 @@ async def test_increment_counter_multiple(client_factory): @pytest.mark.asyncio async def test_get_counter_after_increment(client_factory): + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) + reg = await client_factory.post( "/api/auth/register", - json={"email": unique_email(), "password": "testpass123"}, + json={ + "email": unique_email(), + "password": "testpass123", + "invite_identifier": invite_code, + }, ) cookies = dict(reg.cookies) @@ -109,10 +141,19 @@ async def test_get_counter_after_increment(client_factory): # Counter is shared between users @pytest.mark.asyncio async def test_counter_shared_between_users(client_factory): + # Create invites for two users + async with client_factory.get_db_session() as db: + invite1 = await create_invite_for_registration(db, unique_email("gf1")) + invite2 = await create_invite_for_registration(db, unique_email("gf2")) + # Create first user reg1 = await client_factory.post( "/api/auth/register", - json={"email": unique_email("share1"), "password": "testpass123"}, + json={ + "email": unique_email("share1"), + "password": "testpass123", + "invite_identifier": invite1, + }, ) cookies1 = dict(reg1.cookies) @@ -127,7 +168,11 @@ async def test_counter_shared_between_users(client_factory): # Create second user - should see the increments reg2 = await client_factory.post( "/api/auth/register", - json={"email": unique_email("share2"), "password": "testpass123"}, + json={ + "email": unique_email("share2"), + "password": "testpass123", + "invite_identifier": invite2, + }, ) cookies2 = dict(reg2.cookies) diff --git a/backend/tests/test_invites.py b/backend/tests/test_invites.py new file mode 100644 index 0000000..8a07a37 --- /dev/null +++ b/backend/tests/test_invites.py @@ -0,0 +1,1013 @@ +"""Tests for invite functionality.""" +import pytest +from sqlalchemy import select + +from invite_utils import ( + generate_invite_identifier, + normalize_identifier, + is_valid_identifier_format, + BIP39_WORDS, +) +from models import Invite, InviteStatus, User, ROLE_REGULAR +from tests.helpers import unique_email +from tests.conftest import create_user_with_roles + + +# ============================================================================ +# Invite Utils Tests +# ============================================================================ + +def test_bip39_words_loaded(): + """BIP39 word list should have exactly 2048 words.""" + assert len(BIP39_WORDS) == 2048 + + +def test_generate_invite_identifier_format(): + """Generated identifier should have word-word-NN format.""" + identifier = generate_invite_identifier() + assert is_valid_identifier_format(identifier) + + parts = identifier.split("-") + assert len(parts) == 3 + assert parts[0] in BIP39_WORDS + assert parts[1] in BIP39_WORDS + assert len(parts[2]) == 2 + assert parts[2].isdigit() + + +def test_generate_invite_identifier_lowercase(): + """Generated identifier should be lowercase.""" + for _ in range(10): + identifier = generate_invite_identifier() + assert identifier == identifier.lower() + + +def test_generate_invite_identifier_randomness(): + """Generated identifiers should be different (with high probability).""" + identifiers = {generate_invite_identifier() for _ in range(100)} + # With ~419M possibilities, 100 samples should all be unique + assert len(identifiers) == 100 + + +def test_normalize_identifier_lowercase(): + """normalize_identifier should convert to lowercase.""" + assert normalize_identifier("APPLE-BANANA-42") == "apple-banana-42" + assert normalize_identifier("Apple-Banana-42") == "apple-banana-42" + + +def test_normalize_identifier_strips_whitespace(): + """normalize_identifier should strip whitespace.""" + assert normalize_identifier(" apple-banana-42 ") == "apple-banana-42" + assert normalize_identifier("\tapple-banana-42\n") == "apple-banana-42" + + +def test_is_valid_identifier_format_valid(): + """Valid formats should pass.""" + assert is_valid_identifier_format("apple-banana-42") is True + assert is_valid_identifier_format("zoo-abandon-00") is True + assert is_valid_identifier_format("word-another-99") is True + + +def test_is_valid_identifier_format_invalid(): + """Invalid formats should fail.""" + # Wrong number of parts + assert is_valid_identifier_format("apple-banana") is False + assert is_valid_identifier_format("apple-banana-42-extra") is False + assert is_valid_identifier_format("applebanan42") is False + + # Empty parts + assert is_valid_identifier_format("-banana-42") is False + assert is_valid_identifier_format("apple--42") is False + + # Invalid number format + assert is_valid_identifier_format("apple-banana-4") is False # Single digit + assert is_valid_identifier_format("apple-banana-420") is False # Three digits + assert is_valid_identifier_format("apple-banana-ab") is False # Not digits + + +# ============================================================================ +# Invite Model Tests +# ============================================================================ + +@pytest.mark.asyncio +async def test_create_invite(client_factory): + """Can create an invite with godfather.""" + async with client_factory.get_db_session() as db: + # Create godfather user + godfather = await create_user_with_roles( + db, unique_email("godfather"), "password123", [ROLE_REGULAR] + ) + + # Create invite + invite = Invite( + identifier="test-invite-01", + godfather_id=godfather.id, + status=InviteStatus.READY, + ) + db.add(invite) + await db.commit() + await db.refresh(invite) + + assert invite.id is not None + assert invite.identifier == "test-invite-01" + assert invite.godfather_id == godfather.id + assert invite.status == InviteStatus.READY + assert invite.used_by_id is None + assert invite.created_at is not None + assert invite.spent_at is None + assert invite.revoked_at is None + + +@pytest.mark.asyncio +async def test_invite_godfather_relationship(client_factory): + """Invite should have godfather relationship loaded.""" + async with client_factory.get_db_session() as db: + godfather = await create_user_with_roles( + db, unique_email("godfather"), "password123", [ROLE_REGULAR] + ) + + invite = Invite( + identifier="rel-test-01", + godfather_id=godfather.id, + ) + db.add(invite) + await db.commit() + + # Query invite fresh + result = await db.execute( + select(Invite).where(Invite.identifier == "rel-test-01") + ) + loaded_invite = result.scalar_one() + + assert loaded_invite.godfather is not None + assert loaded_invite.godfather.email == godfather.email + + +@pytest.mark.asyncio +async def test_invite_unique_identifier(client_factory): + """Invite identifier must be unique.""" + from sqlalchemy.exc import IntegrityError + + async with client_factory.get_db_session() as db: + godfather = await create_user_with_roles( + db, unique_email("godfather"), "password123", [ROLE_REGULAR] + ) + + invite1 = Invite( + identifier="unique-test-01", + godfather_id=godfather.id, + ) + db.add(invite1) + await db.commit() + + invite2 = Invite( + identifier="unique-test-01", # Same identifier + godfather_id=godfather.id, + ) + db.add(invite2) + + with pytest.raises(IntegrityError): + await db.commit() + + +@pytest.mark.asyncio +async def test_invite_status_transitions(client_factory): + """Invite status can be changed.""" + from datetime import datetime, UTC + + async with client_factory.get_db_session() as db: + godfather = await create_user_with_roles( + db, unique_email("godfather"), "password123", [ROLE_REGULAR] + ) + user = await create_user_with_roles( + db, unique_email("invitee"), "password123", [ROLE_REGULAR] + ) + + invite = Invite( + identifier="status-test-01", + godfather_id=godfather.id, + status=InviteStatus.READY, + ) + db.add(invite) + await db.commit() + + # Transition to SPENT + invite.status = InviteStatus.SPENT + invite.used_by_id = user.id + invite.spent_at = datetime.now(UTC) + await db.commit() + await db.refresh(invite) + + assert invite.status == InviteStatus.SPENT + assert invite.used_by_id == user.id + assert invite.spent_at is not None + + +@pytest.mark.asyncio +async def test_invite_revoke(client_factory): + """Invite can be revoked.""" + from datetime import datetime, UTC + + async with client_factory.get_db_session() as db: + godfather = await create_user_with_roles( + db, unique_email("godfather"), "password123", [ROLE_REGULAR] + ) + + invite = Invite( + identifier="revoke-test-01", + godfather_id=godfather.id, + status=InviteStatus.READY, + ) + db.add(invite) + await db.commit() + + # Revoke + invite.status = InviteStatus.REVOKED + invite.revoked_at = datetime.now(UTC) + await db.commit() + await db.refresh(invite) + + assert invite.status == InviteStatus.REVOKED + assert invite.revoked_at is not None + assert invite.used_by_id is None # Not used + + +# ============================================================================ +# User Godfather Tests +# ============================================================================ + +@pytest.mark.asyncio +async def test_user_godfather_relationship(client_factory): + """User can have a godfather.""" + async with client_factory.get_db_session() as db: + godfather = await create_user_with_roles( + db, unique_email("godfather"), "password123", [ROLE_REGULAR] + ) + + # Create user with godfather + user = User( + email=unique_email("godchild"), + hashed_password="hashed", + godfather_id=godfather.id, + ) + db.add(user) + await db.commit() + + # Query user fresh + result = await db.execute( + select(User).where(User.id == user.id) + ) + loaded_user = result.scalar_one() + + assert loaded_user.godfather_id == godfather.id + assert loaded_user.godfather is not None + assert loaded_user.godfather.email == godfather.email + + +@pytest.mark.asyncio +async def test_user_without_godfather(client_factory): + """User can exist without godfather (for seeded/admin users).""" + async with client_factory.get_db_session() as db: + user = await create_user_with_roles( + db, unique_email("noparent"), "password123", [ROLE_REGULAR] + ) + + assert user.godfather_id is None + assert user.godfather is None + + +# ============================================================================ +# Admin Create Invite API Tests (Phase 2) +# ============================================================================ + +@pytest.mark.asyncio +async def test_admin_can_create_invite(client_factory, admin_user, regular_user): + """Admin can create an invite for a regular user.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Get regular user ID + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + response = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["godfather_id"] == godfather.id + assert data["godfather_email"] == regular_user["email"] + assert data["status"] == "ready" + assert data["used_by_id"] is None + assert data["used_by_email"] is None + assert data["spent_at"] is None + assert data["revoked_at"] is None + assert "-" in data["identifier"] # word-word-NN format + + +@pytest.mark.asyncio +async def test_admin_can_create_invite_for_self(client_factory, admin_user): + """Admin can create an invite for themselves.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Get admin user ID + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == admin_user["email"]) + ) + admin = result.scalar_one() + + response = await client.post( + "/api/admin/invites", + json={"godfather_id": admin.id}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["godfather_id"] == admin.id + assert data["godfather_email"] == admin_user["email"] + + +@pytest.mark.asyncio +async def test_regular_user_cannot_create_invite(client_factory, regular_user): + """Regular user cannot create invites (403).""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/admin/invites", + json={"godfather_id": 1}, + ) + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_unauthenticated_cannot_create_invite(client_factory): + """Unauthenticated user cannot create invites (401).""" + async with client_factory.create() as client: + response = await client.post( + "/api/admin/invites", + json={"godfather_id": 1}, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_create_invite_invalid_godfather(client_factory, admin_user): + """Creating invite with non-existent godfather returns 400.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.post( + "/api/admin/invites", + json={"godfather_id": 99999}, + ) + + assert response.status_code == 400 + assert "not found" in response.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_created_invite_persisted_in_db(client_factory, admin_user, regular_user): + """Created invite is persisted in database.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + response = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + + data = response.json() + invite_id = data["id"] + + # Query from DB + async with client_factory.get_db_session() as db: + result = await db.execute( + select(Invite).where(Invite.id == invite_id) + ) + invite = result.scalar_one() + + assert invite.identifier == data["identifier"] + assert invite.godfather_id == godfather.id + assert invite.status == InviteStatus.READY + + +# ============================================================================ +# Invite Check API Tests (Phase 3) +# ============================================================================ + +@pytest.mark.asyncio +async def test_check_invite_valid(client_factory, admin_user, regular_user): + """Check endpoint returns valid=True for READY invite.""" + # Create invite + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + identifier = create_resp.json()["identifier"] + + # Check invite (no auth needed) + async with client_factory.create() as client: + response = await client.get(f"/api/invites/{identifier}/check") + + assert response.status_code == 200 + data = response.json() + assert data["valid"] is True + assert data["status"] == "ready" + assert data["error"] is None + + +@pytest.mark.asyncio +async def test_check_invite_not_found(client_factory): + """Check endpoint returns valid=False for unknown invite.""" + async with client_factory.create() as client: + response = await client.get("/api/invites/fake-invite-99/check") + + assert response.status_code == 200 + data = response.json() + assert data["valid"] is False + assert "not found" in data["error"].lower() + + +@pytest.mark.asyncio +async def test_check_invite_case_insensitive(client_factory, admin_user, regular_user): + """Check endpoint handles case-insensitive identifiers.""" + # Create invite + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + identifier = create_resp.json()["identifier"] + + # Check with uppercase + async with client_factory.create() as client: + response = await client.get(f"/api/invites/{identifier.upper()}/check") + + assert response.status_code == 200 + assert response.json()["valid"] is True + + +# ============================================================================ +# Register with Invite Tests (Phase 3) +# ============================================================================ + +@pytest.mark.asyncio +async def test_register_with_valid_invite(client_factory, admin_user, regular_user): + """Can register with valid invite code.""" + # Create invite + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + godfather_id = godfather.id + + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather_id}, + ) + identifier = create_resp.json()["identifier"] + + # Register with invite + new_email = unique_email("newuser") + async with client_factory.create() as client: + response = await client.post( + "/api/auth/register", + json={ + "email": new_email, + "password": "password123", + "invite_identifier": identifier, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["email"] == new_email + assert "regular" in data["roles"] + + +@pytest.mark.asyncio +async def test_register_marks_invite_spent(client_factory, admin_user, regular_user): + """Registering marks the invite as SPENT.""" + # Create invite + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + invite_data = create_resp.json() + identifier = invite_data["identifier"] + invite_id = invite_data["id"] + + # Register + async with client_factory.create() as client: + await client.post( + "/api/auth/register", + json={ + "email": unique_email("spenttest"), + "password": "password123", + "invite_identifier": identifier, + }, + ) + + # Check invite status + async with client_factory.get_db_session() as db: + result = await db.execute( + select(Invite).where(Invite.id == invite_id) + ) + invite = result.scalar_one() + + assert invite.status == InviteStatus.SPENT + assert invite.used_by_id is not None + assert invite.spent_at is not None + + +@pytest.mark.asyncio +async def test_register_sets_godfather(client_factory, admin_user, regular_user): + """New user has correct godfather_id.""" + # Create invite + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + godfather_id = godfather.id + + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather_id}, + ) + identifier = create_resp.json()["identifier"] + + # Register + new_email = unique_email("godchildtest") + async with client_factory.create() as client: + await client.post( + "/api/auth/register", + json={ + "email": new_email, + "password": "password123", + "invite_identifier": identifier, + }, + ) + + # Check user's godfather + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == new_email) + ) + new_user = result.scalar_one() + + assert new_user.godfather_id == godfather_id + + +@pytest.mark.asyncio +async def test_register_with_invalid_invite(client_factory): + """Cannot register with non-existent invite.""" + async with client_factory.create() as client: + response = await client.post( + "/api/auth/register", + json={ + "email": unique_email("invalid"), + "password": "password123", + "invite_identifier": "fake-invite-99", + }, + ) + + assert response.status_code == 400 + assert "invalid" in response.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_register_with_spent_invite(client_factory, admin_user, regular_user): + """Cannot register with already-spent invite.""" + # Create and use invite + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + identifier = create_resp.json()["identifier"] + + # First registration + async with client_factory.create() as client: + await client.post( + "/api/auth/register", + json={ + "email": unique_email("first"), + "password": "password123", + "invite_identifier": identifier, + }, + ) + + # Second registration with same invite + async with client_factory.create() as client: + response = await client.post( + "/api/auth/register", + json={ + "email": unique_email("second"), + "password": "password123", + "invite_identifier": identifier, + }, + ) + + assert response.status_code == 400 + assert "already been used" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_register_with_revoked_invite(client_factory, admin_user, regular_user): + """Cannot register with revoked invite.""" + from datetime import datetime, UTC + + # Create invite + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + invite_data = create_resp.json() + identifier = invite_data["identifier"] + invite_id = invite_data["id"] + + # Revoke invite directly in DB + async with client_factory.get_db_session() as db: + result = await db.execute( + select(Invite).where(Invite.id == invite_id) + ) + invite = result.scalar_one() + invite.status = InviteStatus.REVOKED + invite.revoked_at = datetime.now(UTC) + await db.commit() + + # Try to register + async with client_factory.create() as client: + response = await client.post( + "/api/auth/register", + json={ + "email": unique_email("revoked"), + "password": "password123", + "invite_identifier": identifier, + }, + ) + + assert response.status_code == 400 + assert "revoked" in response.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_register_duplicate_email(client_factory, admin_user, regular_user): + """Cannot register with already-used email.""" + # Create invite + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + identifier = create_resp.json()["identifier"] + + # Try to register with existing email + async with client_factory.create() as client: + response = await client.post( + "/api/auth/register", + json={ + "email": regular_user["email"], # Already exists + "password": "password123", + "invite_identifier": identifier, + }, + ) + + assert response.status_code == 400 + assert "already registered" in response.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_register_sets_auth_cookie(client_factory, admin_user, regular_user): + """Registration sets auth cookie.""" + # Create invite + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + identifier = create_resp.json()["identifier"] + + # Register + async with client_factory.create() as client: + response = await client.post( + "/api/auth/register", + json={ + "email": unique_email("cookietest"), + "password": "password123", + "invite_identifier": identifier, + }, + ) + + assert "auth_token" in response.cookies + + +# ============================================================================ +# User Invites API Tests (Phase 4) +# ============================================================================ + +@pytest.mark.asyncio +async def test_regular_user_can_list_invites(client_factory, admin_user, regular_user): + """Regular user can list their own invites.""" + # Create invites for the regular user + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) + await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) + + # List invites as regular user + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/invites") + + assert response.status_code == 200 + invites = response.json() + assert len(invites) == 2 + for invite in invites: + assert "identifier" in invite + assert invite["status"] == "ready" + + +@pytest.mark.asyncio +async def test_user_with_no_invites_gets_empty_list(client_factory, regular_user): + """User with no invites gets empty list.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/invites") + + assert response.status_code == 200 + assert response.json() == [] + + +@pytest.mark.asyncio +async def test_spent_invite_shows_used_by_email(client_factory, admin_user, regular_user): + """Spent invite shows who used it.""" + # Create invite for regular user + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + identifier = create_resp.json()["identifier"] + + # Use the invite + invitee_email = unique_email("invitee") + async with client_factory.create() as client: + await client.post( + "/api/auth/register", + json={ + "email": invitee_email, + "password": "password123", + "invite_identifier": identifier, + }, + ) + + # Check that regular user sees the invitee email + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/invites") + + assert response.status_code == 200 + invites = response.json() + assert len(invites) == 1 + assert invites[0]["status"] == "spent" + assert invites[0]["used_by_email"] == invitee_email + + +@pytest.mark.asyncio +async def test_admin_cannot_list_own_invites(client_factory, admin_user): + """Admin without VIEW_OWN_INVITES permission gets 403.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.get("/api/invites") + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_unauthenticated_cannot_list_invites(client_factory): + """Unauthenticated user gets 401.""" + async with client_factory.create() as client: + response = await client.get("/api/invites") + + assert response.status_code == 401 + + +# ============================================================================ +# Admin Invite Management Tests (Phase 5) +# ============================================================================ + +@pytest.mark.asyncio +async def test_admin_can_list_all_invites(client_factory, admin_user, regular_user): + """Admin can list all invites.""" + # Create some invites + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) + await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) + + # List all + response = await client.get("/api/admin/invites") + + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 2 + assert len(data["records"]) >= 2 + + +@pytest.mark.asyncio +async def test_admin_list_pagination(client_factory, admin_user, regular_user): + """Admin can paginate invite list.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + # Create 5 invites + for _ in range(5): + await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) + + # Get page 1 with 2 per page + response = await client.get("/api/admin/invites?page=1&per_page=2") + + assert response.status_code == 200 + data = response.json() + assert len(data["records"]) == 2 + assert data["page"] == 1 + assert data["per_page"] == 2 + + +@pytest.mark.asyncio +async def test_admin_filter_by_status(client_factory, admin_user, regular_user): + """Admin can filter invites by status.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + # Create an invite + await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) + + # Filter by ready + response = await client.get("/api/admin/invites?status=ready") + + assert response.status_code == 200 + data = response.json() + for record in data["records"]: + assert record["status"] == "ready" + + +@pytest.mark.asyncio +async def test_admin_can_revoke_invite(client_factory, admin_user, regular_user): + """Admin can revoke a READY invite.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + # Create invite + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + invite_id = create_resp.json()["id"] + + # Revoke it + response = await client.post(f"/api/admin/invites/{invite_id}/revoke") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "revoked" + assert data["revoked_at"] is not None + + +@pytest.mark.asyncio +async def test_cannot_revoke_spent_invite(client_factory, admin_user, regular_user): + """Cannot revoke an already-spent invite.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + # Create invite + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + invite_data = create_resp.json() + + # Use the invite + async with client_factory.create() as client: + await client.post( + "/api/auth/register", + json={ + "email": unique_email("spent"), + "password": "password123", + "invite_identifier": invite_data["identifier"], + }, + ) + + # Try to revoke + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.post(f"/api/admin/invites/{invite_data['id']}/revoke") + + assert response.status_code == 400 + assert "only ready" in response.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_revoke_nonexistent_invite(client_factory, admin_user): + """Revoking non-existent invite returns 404.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.post("/api/admin/invites/99999/revoke") + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_regular_user_cannot_access_admin_invites(client_factory, regular_user): + """Regular user cannot access admin invite endpoints.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + # List + response = await client.get("/api/admin/invites") + assert response.status_code == 403 + + # Revoke + response = await client.post("/api/admin/invites/1/revoke") + assert response.status_code == 403 + diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py index e1a32ab..4981d5f 100644 --- a/backend/tests/test_permissions.py +++ b/backend/tests/test_permissions.py @@ -305,11 +305,18 @@ class TestSecurityBypassAttempts: Test that new registrations cannot claim admin role. New users should only get 'regular' role by default. """ - from tests.helpers import unique_email + from tests.helpers import unique_email, create_invite_for_registration + + async with client_factory.get_db_session() as db: + invite_code = await create_invite_for_registration(db, unique_email("gf")) response = await client_factory.post( "/api/auth/register", - json={"email": unique_email(), "password": "password123"}, + json={ + "email": unique_email(), + "password": "password123", + "invite_identifier": invite_code, + }, ) assert response.status_code == 200 diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 7cae958..3b69a17 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -397,3 +397,57 @@ class TestProfilePrivacy: assert "telegram" not in data assert "signal" not in data assert "nostr_npub" not in data + + +class TestProfileGodfather: + """Tests for godfather information in profile.""" + + async def test_profile_shows_godfather_email(self, client_factory, admin_user, regular_user): + """Profile shows godfather email for users who signed up with invite.""" + from tests.helpers import unique_email + from sqlalchemy import select + from models import User + + # Create invite + async with client_factory.create(cookies=admin_user["cookies"]) as client: + async with client_factory.get_db_session() as db: + result = await db.execute( + select(User).where(User.email == regular_user["email"]) + ) + godfather = result.scalar_one() + + create_resp = await client.post( + "/api/admin/invites", + json={"godfather_id": godfather.id}, + ) + identifier = create_resp.json()["identifier"] + + # Register new user with invite + new_email = unique_email("godchild") + async with client_factory.create() as client: + reg_resp = await client.post( + "/api/auth/register", + json={ + "email": new_email, + "password": "password123", + "invite_identifier": identifier, + }, + ) + new_user_cookies = dict(reg_resp.cookies) + + # Check profile shows godfather + async with client_factory.create(cookies=new_user_cookies) as client: + response = await client.get("/api/profile") + + assert response.status_code == 200 + data = response.json() + assert data["godfather_email"] == regular_user["email"] + + async def test_profile_godfather_null_for_seeded_users(self, client_factory, regular_user): + """Profile shows null godfather for users without one (e.g., seeded users).""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/profile") + + assert response.status_code == 200 + data = response.json() + assert data["godfather_email"] is None diff --git a/backend/words.txt b/backend/words.txt new file mode 100644 index 0000000..942040e --- /dev/null +++ b/backend/words.txt @@ -0,0 +1,2048 @@ +abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx new file mode 100644 index 0000000..bce5613 --- /dev/null +++ b/frontend/app/admin/invites/page.tsx @@ -0,0 +1,529 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Permission } from "../../auth-context"; +import { api } from "../../api"; +import { sharedStyles } from "../../styles/shared"; +import { Header } from "../../components/Header"; +import { useRequireAuth } from "../../hooks/useRequireAuth"; + +interface InviteRecord { + id: number; + identifier: string; + godfather_id: number; + godfather_email: string; + status: string; + used_by_id: number | null; + used_by_email: string | null; + created_at: string; + spent_at: string | null; + revoked_at: string | null; +} + +interface PaginatedResponse { + records: T[]; + total: number; + page: number; + per_page: number; + total_pages: number; +} + +interface UserOption { + id: number; + email: string; +} + +export default function AdminInvitesPage() { + const [data, setData] = useState | null>(null); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [statusFilter, setStatusFilter] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [newGodfatherId, setNewGodfatherId] = useState(""); + const [createError, setCreateError] = useState(null); + const [users, setUsers] = useState([]); + const { user, isLoading, isAuthorized } = useRequireAuth({ + requiredPermission: Permission.VIEW_AUDIT, // Admins have this + fallbackRedirect: "/", + }); + + const fetchUsers = useCallback(async () => { + try { + const data = await api.get("/api/admin/users"); + setUsers(data); + } catch (err) { + console.error("Failed to fetch users:", err); + } + }, []); + + const fetchInvites = useCallback(async (page: number, status: string) => { + setError(null); + try { + let url = `/api/admin/invites?page=${page}&per_page=10`; + if (status) { + url += `&status=${status}`; + } + const data = await api.get>(url); + setData(data); + } catch (err) { + setData(null); + setError(err instanceof Error ? err.message : "Failed to load invites"); + } + }, []); + + useEffect(() => { + if (user && isAuthorized) { + fetchUsers(); + fetchInvites(page, statusFilter); + } + }, [user, page, statusFilter, isAuthorized, fetchUsers, fetchInvites]); + + const handleCreateInvite = async () => { + if (!newGodfatherId) { + setCreateError("Please enter a godfather user ID"); + return; + } + + setIsCreating(true); + setCreateError(null); + + try { + await api.post("/api/admin/invites", { + godfather_id: parseInt(newGodfatherId), + }); + setNewGodfatherId(""); + fetchInvites(1, statusFilter); + setPage(1); + } catch (err) { + setCreateError(err instanceof Error ? err.message : "Failed to create invite"); + } finally { + setIsCreating(false); + } + }; + + const handleRevoke = async (inviteId: number) => { + try { + await api.post(`/api/admin/invites/${inviteId}/revoke`); + fetchInvites(page, statusFilter); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to revoke invite"); + } + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleString(); + }; + + const getStatusBadgeStyle = (status: string) => { + switch (status) { + case "ready": + return styles.statusReady; + case "spent": + return styles.statusSpent; + case "revoked": + return styles.statusRevoked; + default: + return {}; + } + }; + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!user || !isAuthorized) { + return null; + } + + return ( +
+
+ +
+
+ {/* Create Invite Section */} +
+

Create Invite

+
+
+ + + {users.length === 0 && ( + + No users loaded yet. Create at least one invite to populate the list. + + )} +
+ {createError &&
{createError}
} + +
+
+ + {/* Invites Table */} +
+
+

All Invites

+
+ + + {data?.total ?? 0} invites + +
+
+ +
+ + + + + + + + + + + + + {error && ( + + + + )} + {!error && data?.records.map((record) => ( + + + + + + + + + ))} + {!error && (!data || data.records.length === 0) && ( + + + + )} + +
CodeGodfatherStatusUsed ByCreatedActions
{error}
{record.identifier}{record.godfather_email} + + {record.status} + + + {record.used_by_email || "-"} + {formatDate(record.created_at)} + {record.status === "ready" && ( + + )} +
No invites yet
+
+ + {data && data.total_pages > 1 && ( +
+ + + {page} / {data.total_pages} + + +
+ )} +
+
+
+
+ ); +} + +const pageStyles: Record = { + content: { + flex: 1, + padding: "2rem", + overflowY: "auto", + }, + pageContainer: { + display: "flex", + flexDirection: "column", + gap: "2rem", + maxWidth: "1000px", + margin: "0 auto", + }, + createCard: { + background: "rgba(99, 102, 241, 0.08)", + border: "1px solid rgba(99, 102, 241, 0.2)", + borderRadius: "16px", + padding: "1.5rem", + }, + createTitle: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "1rem", + fontWeight: 600, + color: "rgba(255, 255, 255, 0.9)", + margin: "0 0 1rem 0", + }, + createForm: { + display: "flex", + flexDirection: "column", + gap: "1rem", + }, + inputGroup: { + display: "flex", + flexDirection: "column", + gap: "0.5rem", + }, + inputLabel: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.8rem", + color: "rgba(255, 255, 255, 0.5)", + }, + input: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.9rem", + padding: "0.75rem", + background: "rgba(255, 255, 255, 0.05)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "8px", + color: "#fff", + maxWidth: "300px", + }, + select: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.9rem", + padding: "0.75rem", + background: "rgba(255, 255, 255, 0.05)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "8px", + color: "#fff", + maxWidth: "400px", + cursor: "pointer", + }, + createError: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.85rem", + color: "#f87171", + }, + createButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.9rem", + fontWeight: 500, + padding: "0.75rem 1.5rem", + background: "rgba(99, 102, 241, 0.3)", + color: "#fff", + border: "1px solid rgba(99, 102, 241, 0.5)", + borderRadius: "8px", + cursor: "pointer", + alignSelf: "flex-start", + }, + createButtonDisabled: { + opacity: 0.5, + cursor: "not-allowed", + }, + inputHint: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.75rem", + color: "rgba(255, 255, 255, 0.4)", + fontStyle: "italic", + }, + tableCard: { + background: "rgba(255, 255, 255, 0.03)", + backdropFilter: "blur(10px)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "20px", + padding: "1.5rem", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", + }, + tableHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "1rem", + flexWrap: "wrap", + gap: "1rem", + }, + tableTitle: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "1.5rem", + fontWeight: 400, + color: "#fff", + margin: 0, + }, + filterGroup: { + display: "flex", + alignItems: "center", + gap: "1rem", + }, + filterSelect: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.85rem", + padding: "0.5rem 1rem", + background: "rgba(255, 255, 255, 0.05)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "8px", + color: "#fff", + cursor: "pointer", + }, + totalCount: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + color: "rgba(255, 255, 255, 0.4)", + }, + tableWrapper: { + overflowX: "auto", + }, + table: { + width: "100%", + borderCollapse: "collapse", + fontFamily: "'DM Sans', system-ui, sans-serif", + }, + th: { + textAlign: "left", + padding: "0.75rem 1rem", + fontSize: "0.75rem", + fontWeight: 600, + color: "rgba(255, 255, 255, 0.4)", + textTransform: "uppercase", + letterSpacing: "0.05em", + borderBottom: "1px solid rgba(255, 255, 255, 0.08)", + }, + tr: { + borderBottom: "1px solid rgba(255, 255, 255, 0.04)", + }, + td: { + padding: "0.875rem 1rem", + fontSize: "0.875rem", + color: "rgba(255, 255, 255, 0.7)", + }, + tdCode: { + padding: "0.875rem 1rem", + fontSize: "0.875rem", + color: "#fff", + fontFamily: "'DM Mono', monospace", + }, + tdDate: { + padding: "0.875rem 1rem", + fontSize: "0.75rem", + color: "rgba(255, 255, 255, 0.4)", + }, + statusBadge: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.7rem", + fontWeight: 500, + padding: "0.25rem 0.5rem", + borderRadius: "4px", + textTransform: "uppercase", + }, + statusReady: { + background: "rgba(99, 102, 241, 0.2)", + color: "rgba(129, 140, 248, 0.9)", + }, + statusSpent: { + background: "rgba(34, 197, 94, 0.2)", + color: "rgba(34, 197, 94, 0.9)", + }, + statusRevoked: { + background: "rgba(239, 68, 68, 0.2)", + color: "rgba(239, 68, 68, 0.9)", + }, + revokeButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.75rem", + padding: "0.4rem 0.75rem", + background: "rgba(239, 68, 68, 0.15)", + color: "rgba(239, 68, 68, 0.9)", + border: "1px solid rgba(239, 68, 68, 0.3)", + borderRadius: "6px", + cursor: "pointer", + }, + emptyRow: { + padding: "2rem 1rem", + textAlign: "center", + color: "rgba(255, 255, 255, 0.3)", + fontSize: "0.875rem", + }, + errorRow: { + padding: "2rem 1rem", + textAlign: "center", + color: "#f87171", + fontSize: "0.875rem", + }, + pagination: { + display: "flex", + justifyContent: "center", + alignItems: "center", + gap: "1rem", + marginTop: "1rem", + paddingTop: "1rem", + borderTop: "1px solid rgba(255, 255, 255, 0.06)", + }, + pageBtn: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.5rem 1rem", + fontSize: "1rem", + background: "rgba(255, 255, 255, 0.05)", + color: "rgba(255, 255, 255, 0.7)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "8px", + cursor: "pointer", + transition: "all 0.2s", + }, + pageInfo: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + color: "rgba(255, 255, 255, 0.5)", + }, +}; + +const styles = { ...sharedStyles, ...pageStyles }; + diff --git a/frontend/app/api.ts b/frontend/app/api.ts index 75cca5d..30aeacc 100644 --- a/frontend/app/api.ts +++ b/frontend/app/api.ts @@ -22,8 +22,8 @@ async function request( ): Promise { const url = `${API_URL}${endpoint}`; - const headers: HeadersInit = { - ...options.headers, + const headers: Record = { + ...(options.headers as Record), }; if (options.body && typeof options.body === "string") { diff --git a/frontend/app/auth-context.tsx b/frontend/app/auth-context.tsx index a5ba690..e04eb2d 100644 --- a/frontend/app/auth-context.tsx +++ b/frontend/app/auth-context.tsx @@ -10,6 +10,8 @@ export const Permission = { INCREMENT_COUNTER: "increment_counter", USE_SUM: "use_sum", VIEW_AUDIT: "view_audit", + MANAGE_INVITES: "manage_invites", + VIEW_OWN_INVITES: "view_own_invites", } as const; export type PermissionType = typeof Permission[keyof typeof Permission]; @@ -25,7 +27,7 @@ interface AuthContextType { user: User | null; isLoading: boolean; login: (email: string, password: string) => Promise; - register: (email: string, password: string) => Promise; + register: (email: string, password: string, inviteIdentifier: string) => Promise; logout: () => Promise; hasPermission: (permission: PermissionType) => boolean; hasRole: (role: string) => boolean; @@ -65,9 +67,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }; - const register = async (email: string, password: string) => { + const register = async (email: string, password: string, inviteIdentifier: string) => { try { - const userData = await api.post("/api/auth/register", { email, password }); + const userData = await api.post("/api/auth/register", { + email, + password, + invite_identifier: inviteIdentifier, + }); setUser(userData); } catch (err) { if (err instanceof ApiError) { diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx index e42a4eb..e0eabfc 100644 --- a/frontend/app/components/Header.tsx +++ b/frontend/app/components/Header.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/navigation"; import { useAuth } from "../auth-context"; import { sharedStyles } from "../styles/shared"; -type PageId = "counter" | "sum" | "profile" | "audit"; +type PageId = "counter" | "sum" | "profile" | "invites" | "audit" | "admin-invites"; interface HeaderProps { currentPage: PageId; @@ -15,18 +15,26 @@ interface NavItem { label: string; href: string; regularOnly?: boolean; + adminOnly?: boolean; } -const NAV_ITEMS: NavItem[] = [ +const REGULAR_NAV_ITEMS: NavItem[] = [ { id: "counter", label: "Counter", href: "/" }, { id: "sum", label: "Sum", href: "/sum" }, + { id: "invites", label: "My Invites", href: "/invites", regularOnly: true }, { id: "profile", label: "My Profile", href: "/profile", regularOnly: true }, ]; +const ADMIN_NAV_ITEMS: NavItem[] = [ + { id: "audit", label: "Audit", href: "/audit", adminOnly: true }, + { id: "admin-invites", label: "Invites", href: "/admin/invites", adminOnly: true }, +]; + export function Header({ currentPage }: HeaderProps) { const { user, logout, hasRole } = useAuth(); const router = useRouter(); const isRegularUser = hasRole("regular"); + const isAdminUser = hasRole("admin"); const handleLogout = async () => { await logout(); @@ -35,12 +43,23 @@ export function Header({ currentPage }: HeaderProps) { if (!user) return null; - // For audit page (admin), show only the current page label - if (currentPage === "audit") { + // For admin pages, show admin navigation + if (isAdminUser && (currentPage === "audit" || currentPage === "admin-invites")) { return (
- Audit + {ADMIN_NAV_ITEMS.map((item, index) => ( + + {index > 0 && } + {item.id === currentPage ? ( + {item.label} + ) : ( + + {item.label} + + )} + + ))}
{user.email} @@ -53,7 +72,7 @@ export function Header({ currentPage }: HeaderProps) { } // For regular pages, build nav with links - const visibleItems = NAV_ITEMS.filter( + const visibleItems = REGULAR_NAV_ITEMS.filter( (item) => !item.regularOnly || isRegularUser ); diff --git a/frontend/app/invites/page.tsx b/frontend/app/invites/page.tsx new file mode 100644 index 0000000..9a5aafa --- /dev/null +++ b/frontend/app/invites/page.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { api } from "../api"; +import { sharedStyles } from "../styles/shared"; +import { Header } from "../components/Header"; +import { useRequireAuth } from "../hooks/useRequireAuth"; + +interface Invite { + id: number; + identifier: string; + status: string; + used_by_email: string | null; + created_at: string; + spent_at: string | null; +} + +export default function InvitesPage() { + const { user, isLoading, isAuthorized } = useRequireAuth({ + requiredRole: "regular", + fallbackRedirect: "/audit", + }); + const [invites, setInvites] = useState([]); + const [isLoadingInvites, setIsLoadingInvites] = useState(true); + const [copiedId, setCopiedId] = useState(null); + + const fetchInvites = useCallback(async () => { + try { + const data = await api.get("/api/invites"); + setInvites(data); + } catch (err) { + console.error("Failed to load invites:", err); + } finally { + setIsLoadingInvites(false); + } + }, []); + + useEffect(() => { + if (user && isAuthorized) { + fetchInvites(); + } + }, [user, isAuthorized, fetchInvites]); + + const getInviteUrl = (identifier: string) => { + if (typeof window !== "undefined") { + return `${window.location.origin}/signup/${identifier}`; + } + return `/signup/${identifier}`; + }; + + const copyToClipboard = async (invite: Invite) => { + const url = getInviteUrl(invite.identifier); + try { + await navigator.clipboard.writeText(url); + setCopiedId(invite.id); + setTimeout(() => setCopiedId(null), 2000); + } catch (err) { + console.error("Failed to copy:", err); + } + }; + + if (isLoading || isLoadingInvites) { + return ( +
+
Loading...
+
+ ); + } + + if (!user || !isAuthorized) { + return null; + } + + const readyInvites = invites.filter((i) => i.status === "ready"); + const spentInvites = invites.filter((i) => i.status === "spent"); + const revokedInvites = invites.filter((i) => i.status === "revoked"); + + return ( +
+
+ +
+
+
+

My Invites

+

+ Share your invite codes with friends to let them join +

+
+ + {invites.length === 0 ? ( +
+

You don't have any invites yet.

+

+ Contact an admin if you need invite codes to share. +

+
+ ) : ( +
+ {/* Ready Invites */} + {readyInvites.length > 0 && ( +
+

+ Available ({readyInvites.length}) +

+

+ Share these links with people you want to invite +

+
+ {readyInvites.map((invite) => ( +
+
{invite.identifier}
+
+ +
+
+ ))} +
+
+ )} + + {/* Spent Invites */} + {spentInvites.length > 0 && ( +
+

+ Used ({spentInvites.length}) +

+
+ {spentInvites.map((invite) => ( +
+
{invite.identifier}
+
+ Used + + by {invite.used_by_email} + +
+
+ ))} +
+
+ )} + + {/* Revoked Invites */} + {revokedInvites.length > 0 && ( +
+

+ Revoked ({revokedInvites.length}) +

+
+ {revokedInvites.map((invite) => ( +
+
{invite.identifier}
+ Revoked +
+ ))} +
+
+ )} +
+ )} +
+
+
+ ); +} + +const pageStyles: Record = { + pageCard: { + background: "rgba(255, 255, 255, 0.03)", + backdropFilter: "blur(10px)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "24px", + padding: "2.5rem", + width: "100%", + maxWidth: "600px", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", + }, + cardHeader: { + marginBottom: "2rem", + }, + cardTitle: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "2rem", + fontWeight: 400, + color: "#fff", + margin: 0, + letterSpacing: "-0.02em", + }, + cardSubtitle: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + marginTop: "0.5rem", + fontSize: "0.95rem", + }, + emptyState: { + textAlign: "center", + padding: "2rem 0", + }, + emptyText: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.6)", + fontSize: "1rem", + margin: 0, + }, + emptyHint: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.4)", + fontSize: "0.85rem", + marginTop: "0.5rem", + }, + sections: { + display: "flex", + flexDirection: "column", + gap: "2rem", + }, + section: { + display: "flex", + flexDirection: "column", + gap: "0.75rem", + }, + sectionTitle: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + fontWeight: 600, + color: "rgba(255, 255, 255, 0.8)", + margin: 0, + textTransform: "uppercase", + letterSpacing: "0.05em", + }, + sectionHint: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.8rem", + color: "rgba(255, 255, 255, 0.4)", + margin: 0, + }, + inviteList: { + display: "flex", + flexDirection: "column", + gap: "0.75rem", + }, + inviteCard: { + background: "rgba(99, 102, 241, 0.1)", + border: "1px solid rgba(99, 102, 241, 0.3)", + borderRadius: "12px", + padding: "1rem", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }, + inviteCardSpent: { + background: "rgba(34, 197, 94, 0.08)", + border: "1px solid rgba(34, 197, 94, 0.2)", + borderRadius: "12px", + padding: "1rem", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }, + inviteCardRevoked: { + background: "rgba(239, 68, 68, 0.08)", + border: "1px solid rgba(239, 68, 68, 0.2)", + borderRadius: "12px", + padding: "1rem", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + opacity: 0.7, + }, + inviteCode: { + fontFamily: "'DM Mono', monospace", + fontSize: "0.95rem", + color: "#fff", + letterSpacing: "0.02em", + }, + inviteActions: { + display: "flex", + gap: "0.5rem", + }, + copyButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.8rem", + fontWeight: 500, + padding: "0.5rem 1rem", + background: "rgba(99, 102, 241, 0.3)", + color: "#fff", + border: "1px solid rgba(99, 102, 241, 0.5)", + borderRadius: "8px", + cursor: "pointer", + transition: "all 0.2s", + }, + inviteeMeta: { + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + statusBadgeSpent: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.7rem", + fontWeight: 500, + padding: "0.25rem 0.5rem", + background: "rgba(34, 197, 94, 0.2)", + color: "rgba(34, 197, 94, 0.9)", + borderRadius: "4px", + textTransform: "uppercase", + }, + statusBadgeRevoked: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.7rem", + fontWeight: 500, + padding: "0.25rem 0.5rem", + background: "rgba(239, 68, 68, 0.2)", + color: "rgba(239, 68, 68, 0.9)", + borderRadius: "4px", + textTransform: "uppercase", + }, + inviteeEmail: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.8rem", + color: "rgba(255, 255, 255, 0.6)", + }, +}; + +const styles = { ...sharedStyles, ...pageStyles }; + diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 478b2bb..44de30f 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -12,6 +12,7 @@ interface ProfileData { telegram: string | null; signal: string | null; nostr_npub: string | null; + godfather_email: string | null; } interface FormData { @@ -122,6 +123,7 @@ export default function ProfilePage() { signal: "", nostr_npub: "", }); + const [godfatherEmail, setGodfatherEmail] = useState(null); const [errors, setErrors] = useState({}); const [isLoadingProfile, setIsLoadingProfile] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); @@ -150,6 +152,7 @@ export default function ProfilePage() { const formValues = toFormData(data); setFormData(formValues); setOriginalData(formValues); + setGodfatherEmail(data.godfather_email); } catch (err) { console.error("Profile load error:", err); setToast({ message: "Failed to load profile", type: "error" }); @@ -302,6 +305,22 @@ export default function ProfilePage() {
+ {/* Godfather - shown if user was invited */} + {godfatherEmail && ( +
+ +
+ {godfatherEmail} +
+ + The user who invited you to join. + +
+ )} +

Contact Details

@@ -483,6 +502,17 @@ const pageStyles: Record = { color: "rgba(255, 255, 255, 0.5)", cursor: "not-allowed", }, + godfatherBox: { + padding: "0.875rem 1rem", + background: "rgba(99, 102, 241, 0.08)", + border: "1px solid rgba(99, 102, 241, 0.2)", + borderRadius: "12px", + }, + godfatherEmail: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "1rem", + color: "rgba(129, 140, 248, 0.9)", + }, inputError: { border: "1px solid rgba(239, 68, 68, 0.5)", boxShadow: "0 0 0 2px rgba(239, 68, 68, 0.1)", diff --git a/frontend/app/signup/[code]/page.tsx b/frontend/app/signup/[code]/page.tsx new file mode 100644 index 0000000..6b3bd6e --- /dev/null +++ b/frontend/app/signup/[code]/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { useAuth } from "../../auth-context"; + +export default function SignupWithCodePage() { + const params = useParams(); + const router = useRouter(); + const { user } = useAuth(); + const code = params.code as string; + + useEffect(() => { + if (user) { + // Already logged in, redirect to home + router.replace("/"); + } else { + // Redirect to signup with code as query param + router.replace(`/signup?code=${encodeURIComponent(code)}`); + } + }, [user, code, router]); + + return ( +
+ Redirecting... +
+ ); +} + diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index a5a9e17..982f54d 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -1,20 +1,85 @@ "use client"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useState, useEffect, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "../auth-context"; +import { api } from "../api"; import { authFormStyles as styles } from "../styles/auth-form"; -export default function SignupPage() { +interface InviteCheckResponse { + valid: boolean; + status?: string; + error?: string; +} + +function SignupContent() { + const searchParams = useSearchParams(); + const initialCode = searchParams.get("code") || ""; + + const [inviteCode, setInviteCode] = useState(initialCode); + const [inviteValid, setInviteValid] = useState(null); + const [inviteError, setInviteError] = useState(""); + const [isCheckingInvite, setIsCheckingInvite] = useState(false); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); - const { register } = useAuth(); + + const { user, register } = useAuth(); const router = useRouter(); - const handleSubmit = async (e: React.FormEvent) => { + // Redirect if already logged in + useEffect(() => { + if (user) { + router.push("/"); + } + }, [user, router]); + + // Check invite code on mount if provided in URL + useEffect(() => { + if (initialCode) { + checkInvite(initialCode); + } + }, [initialCode]); + + const checkInvite = async (code: string) => { + if (!code.trim()) { + setInviteValid(null); + setInviteError(""); + return; + } + + setIsCheckingInvite(true); + setInviteError(""); + + try { + const response = await api.get( + `/api/invites/${encodeURIComponent(code.trim())}/check` + ); + + if (response.valid) { + setInviteValid(true); + setInviteError(""); + } else { + setInviteValid(false); + setInviteError(response.error || "Invalid invite code"); + } + } catch { + setInviteValid(false); + setInviteError("Failed to verify invite code"); + } finally { + setIsCheckingInvite(false); + } + }; + + const handleInviteSubmit = (e: React.FormEvent) => { + e.preventDefault(); + checkInvite(inviteCode); + }; + + const handleSignupSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); @@ -31,7 +96,7 @@ export default function SignupPage() { setIsSubmitting(true); try { - await register(email, password); + await register(email, password, inviteCode.trim()); router.push("/"); } catch (err) { setError(err instanceof Error ? err.message : "Registration failed"); @@ -40,16 +105,88 @@ export default function SignupPage() { } }; + // Show loading or redirect if user is already logged in + if (user) { + return null; + } + + // Step 1: Enter invite code + if (!inviteValid) { + return ( +
+
+
+
+

Join with Invite

+

Enter your invite code to get started

+
+ +
+ {inviteError &&
{inviteError}
} + +
+ + { + setInviteCode(e.target.value); + setInviteError(""); + setInviteValid(null); + }} + style={styles.input} + placeholder="word-word-00" + required + autoFocus + /> + + Ask your inviter for this code + +
+ + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+
+ ); + } + + // Step 2: Enter email and password return (

Create account

-

Get started with your journey

+

+ Using invite: {inviteCode} +

-
+ {error &&
{error}
}
@@ -62,6 +199,7 @@ export default function SignupPage() { style={styles.input} placeholder="you@example.com" required + autoFocus />
@@ -104,13 +242,42 @@ export default function SignupPage() {

- Already have an account?{" "} - - Sign in - +

); } + +export default function SignupPage() { + return ( + +
+
+
+ Loading... +
+
+
+ + }> + +
+ ); +} diff --git a/frontend/e2e/admin-invites.spec.ts b/frontend/e2e/admin-invites.spec.ts new file mode 100644 index 0000000..421aa1d --- /dev/null +++ b/frontend/e2e/admin-invites.spec.ts @@ -0,0 +1,162 @@ +import { test, expect, Page } from "@playwright/test"; + +// Admin credentials from seed data +const ADMIN_EMAIL = "admin@example.com"; +const ADMIN_PASSWORD = "admin123"; + +// Regular user from seed data +const REGULAR_USER_EMAIL = "user@example.com"; + +async function loginAsAdmin(page: Page) { + await page.goto("/login"); + await page.fill('input[type="email"]', ADMIN_EMAIL); + await page.fill('input[type="password"]', ADMIN_PASSWORD); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/audit"); +} + +test.describe("Admin Invites Page", () => { + test.beforeEach(async ({ page }) => { + await page.context().clearCookies(); + await loginAsAdmin(page); + }); + + test("admin can access invites page", async ({ page }) => { + await page.goto("/admin/invites"); + await expect(page.getByRole("heading", { name: "Create Invite" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "All Invites" })).toBeVisible(); + }); + + test("godfather selection is a dropdown with users, not a number input", async ({ page }) => { + await page.goto("/admin/invites"); + + // Wait for users to load + await page.waitForSelector("select"); + + // The godfather selector should be a + const selectElement = page.locator("select").first(); + await expect(selectElement).toBeVisible(); + + // Verify it has user options (at least the seeded users) + const options = selectElement.locator("option"); + const optionCount = await options.count(); + + // Should have at least 2 options: placeholder + at least one user + expect(optionCount).toBeGreaterThanOrEqual(2); + + // Verify the regular user appears as an option + await expect(selectElement).toContainText(REGULAR_USER_EMAIL); + + // There should NOT be a number input for godfather ID + const numberInput = page.locator('input[type="number"]'); + await expect(numberInput).toHaveCount(0); + }); + + test("can create invite by selecting user from dropdown", async ({ page }) => { + await page.goto("/admin/invites"); + + // Wait for page to load + await page.waitForSelector("select"); + + // Select the regular user as godfather + const godfatherSelect = page.locator("select").first(); + await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL }); + + // Click create invite + await page.click('button:has-text("Create Invite")'); + + // Wait for the invite to appear in the table + await expect(page.locator("table")).toContainText(REGULAR_USER_EMAIL); + + // Verify an invite code appears (format: word-word-NN) + const inviteCodeCell = page.locator("td").first(); + await expect(inviteCodeCell).toHaveText(/^[a-z]+-[a-z]+-\d{2}$/); + }); + + test("create button is disabled when no user selected", async ({ page }) => { + await page.goto("/admin/invites"); + + // Wait for page to load + await page.waitForSelector("select"); + + // The create button should be disabled initially (no user selected) + const createButton = page.locator('button:has-text("Create Invite")'); + await expect(createButton).toBeDisabled(); + + // Select a user + const godfatherSelect = page.locator("select").first(); + await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL }); + + // Now the button should be enabled + await expect(createButton).toBeEnabled(); + }); + + test("can revoke a ready invite", async ({ page }) => { + await page.goto("/admin/invites"); + await page.waitForSelector("select"); + + // Create an invite first + const godfatherSelect = page.locator("select").first(); + await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL }); + await page.click('button:has-text("Create Invite")'); + + // Wait for the invite to appear + await expect(page.locator("table")).toContainText("ready"); + + // Click revoke on the first ready invite + const revokeButton = page.locator('button:has-text("Revoke")').first(); + await revokeButton.click(); + + // Verify the status changed to revoked + await expect(page.locator("table")).toContainText("revoked"); + }); + + test("status filter works", async ({ page }) => { + await page.goto("/admin/invites"); + await page.waitForSelector("select"); + + // Create an invite + const godfatherSelect = page.locator("select").first(); + await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL }); + await page.click('button:has-text("Create Invite")'); + await expect(page.locator("table")).toContainText("ready"); + + // Filter by "revoked" status - should show no ready invites + const statusFilter = page.locator("select").nth(1); // Second select is the status filter + await statusFilter.selectOption("revoked"); + + // Wait for the filter to apply + await page.waitForResponse((resp) => resp.url().includes("status=revoked")); + + // Filter by "ready" status - should show our invite + await statusFilter.selectOption("ready"); + await page.waitForResponse((resp) => resp.url().includes("status=ready")); + await expect(page.locator("table")).toContainText("ready"); + }); +}); + +test.describe("Admin Invites Access Control", () => { + test("regular user cannot access admin invites page", async ({ page }) => { + // Login as regular user + await page.goto("/login"); + await page.fill('input[type="email"]', REGULAR_USER_EMAIL); + await page.fill('input[type="password"]', "user123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); + + // Try to access admin invites page + await page.goto("/admin/invites"); + + // Should be redirected away (to home page based on fallbackRedirect) + await expect(page).not.toHaveURL("/admin/invites"); + }); + + test("unauthenticated user cannot access admin invites page", async ({ page }) => { + await page.context().clearCookies(); + await page.goto("/admin/invites"); + + // Should be redirected to login + await expect(page).toHaveURL("/login"); + }); +}); + diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 3d3c776..57cebdb 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, Page } from "@playwright/test"; +import { test, expect, Page, APIRequestContext } from "@playwright/test"; // Helper to generate unique email for each test function uniqueEmail(): string { @@ -10,6 +10,35 @@ async function clearAuth(page: Page) { await page.context().clearCookies(); } +// Admin credentials from seed data +const ADMIN_EMAIL = "admin@example.com"; +const ADMIN_PASSWORD = "admin123"; + +// Helper to create an invite via the API +const API_BASE = "http://localhost:8000"; + +async function createInvite(request: APIRequestContext): Promise { + // Login as admin + const loginResp = await request.post(`${API_BASE}/api/auth/login`, { + data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + const cookies = loginResp.headers()["set-cookie"]; + + // Get admin user ID (we'll use admin as godfather for simplicity) + const meResp = await request.get(`${API_BASE}/api/auth/me`, { + headers: { Cookie: cookies }, + }); + const admin = await meResp.json(); + + // Create invite + const inviteResp = await request.post(`${API_BASE}/api/admin/invites`, { + data: { godfather_id: admin.id }, + headers: { Cookie: cookies }, + }); + const invite = await inviteResp.json(); + return invite.identifier; +} + test.describe("Authentication Flow", () => { test.beforeEach(async ({ page }) => { await clearAuth(page); @@ -29,13 +58,11 @@ test.describe("Authentication Flow", () => { await expect(page.locator('a[href="/signup"]')).toBeVisible(); }); - test("signup page has correct form elements", async ({ page }) => { + test("signup page has invite code form", async ({ page }) => { await page.goto("/signup"); - await expect(page.locator("h1")).toHaveText("Create account"); - await expect(page.locator('input[type="email"]')).toBeVisible(); - await expect(page.locator('input[type="password"]').first()).toBeVisible(); - await expect(page.locator('input[type="password"]').nth(1)).toBeVisible(); - await expect(page.locator('button[type="submit"]')).toHaveText("Create account"); + await expect(page.locator("h1")).toHaveText("Join with Invite"); + await expect(page.locator('input#inviteCode')).toBeVisible(); + await expect(page.locator('button[type="submit"]')).toHaveText("Continue"); await expect(page.locator('a[href="/login"]')).toBeVisible(); }); @@ -52,18 +79,28 @@ test.describe("Authentication Flow", () => { }); }); -test.describe("Signup", () => { +test.describe("Signup with Invite", () => { test.beforeEach(async ({ page }) => { await clearAuth(page); }); - test("can create a new account", async ({ page }) => { + test("can create a new account with valid invite", async ({ page, request }) => { const email = uniqueEmail(); + const inviteCode = await createInvite(request); await page.goto("/signup"); - await page.fill('input[type="email"]', email); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("password123"); + + // Step 1: Enter invite code + await page.fill('input#inviteCode', inviteCode); + await page.click('button[type="submit"]'); + + // Wait for form to transition to registration form + await expect(page.locator("h1")).toHaveText("Create account"); + + // Step 2: Fill registration form + await page.fill('input#email', email); + await page.fill('input#password', "password123"); + await page.fill('input#confirmPassword', "password123"); await page.click('button[type="submit"]'); // Should redirect to home after signup @@ -72,76 +109,90 @@ test.describe("Signup", () => { await expect(page.getByText(email)).toBeVisible(); }); - test("shows error for duplicate email", async ({ page }) => { + test("signup with direct invite URL works", async ({ page, request }) => { const email = uniqueEmail(); + const inviteCode = await createInvite(request); - // First registration - await page.goto("/signup"); - await page.fill('input[type="email"]', email); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("password123"); + // Use direct URL with code + await page.goto(`/signup/${inviteCode}`); + + // Should redirect to signup with code in query and validate + await page.waitForURL(/\/signup\?code=/); + + // Wait for form to transition to registration form + await expect(page.locator("h1")).toHaveText("Create account"); + + // Fill registration form + await page.fill('input#email', email); + await page.fill('input#password', "password123"); + await page.fill('input#confirmPassword', "password123"); await page.click('button[type="submit"]'); - await expect(page).toHaveURL("/"); - // Clear cookies and try again with same email - await clearAuth(page); + // Should redirect to home + await expect(page).toHaveURL("/"); + }); + + test("shows error for invalid invite code", async ({ page }) => { await page.goto("/signup"); - await page.fill('input[type="email"]', email); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.fill('input#inviteCode', "fake-code-99"); await page.click('button[type="submit"]'); // Should show error - await expect(page.getByText("Email already registered")).toBeVisible(); + await expect(page.getByText(/not found/i)).toBeVisible(); }); - test("shows error for password mismatch", async ({ page }) => { + test("shows error for password mismatch", async ({ page, request }) => { + const inviteCode = await createInvite(request); + await page.goto("/signup"); - await page.fill('input[type="email"]', uniqueEmail()); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("differentpassword"); + await page.fill('input#inviteCode', inviteCode); + await page.click('button[type="submit"]'); + + await expect(page.locator("h1")).toHaveText("Create account"); + + await page.fill('input#email', uniqueEmail()); + await page.fill('input#password', "password123"); + await page.fill('input#confirmPassword', "differentpassword"); await page.click('button[type="submit"]'); await expect(page.getByText("Passwords do not match")).toBeVisible(); }); - test("shows error for short password", async ({ page }) => { + test("shows error for short password", async ({ page, request }) => { + const inviteCode = await createInvite(request); + await page.goto("/signup"); - await page.fill('input[type="email"]', uniqueEmail()); - await page.fill('input[type="password"]', "short"); - await page.locator('input[type="password"]').nth(1).fill("short"); + await page.fill('input#inviteCode', inviteCode); + await page.click('button[type="submit"]'); + + await expect(page.locator("h1")).toHaveText("Create account"); + + await page.fill('input#email', uniqueEmail()); + await page.fill('input#password', "short"); + await page.fill('input#confirmPassword', "short"); await page.click('button[type="submit"]'); await expect(page.getByText("Password must be at least 6 characters")).toBeVisible(); }); - - test("shows loading state while submitting", async ({ page }) => { - await page.goto("/signup"); - await page.fill('input[type="email"]', uniqueEmail()); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("password123"); - - // Start submission and check for loading state - const submitPromise = page.click('button[type="submit"]'); - await expect(page.locator('button[type="submit"]')).toHaveText("Creating account..."); - await submitPromise; - }); }); test.describe("Login", () => { - const testEmail = `login-test-${Date.now()}@example.com`; + let testEmail: string; const testPassword = "testpassword123"; - test.beforeAll(async ({ browser }) => { - // Create a test user - const page = await browser.newPage(); - await page.goto("/signup"); - await page.fill('input[type="email"]', testEmail); - await page.fill('input[type="password"]', testPassword); - await page.locator('input[type="password"]').nth(1).fill(testPassword); - await page.click('button[type="submit"]'); - await expect(page).toHaveURL("/"); - await page.close(); + test.beforeAll(async ({ request }) => { + // Create a test user with invite + testEmail = uniqueEmail(); + const inviteCode = await createInvite(request); + + // Register the test user via backend API + await request.post(`${API_BASE}/api/auth/register`, { + data: { + email: testEmail, + password: testPassword, + invite_identifier: inviteCode, + }, + }); }); test.beforeEach(async ({ page }) => { @@ -188,14 +239,19 @@ test.describe("Login", () => { }); test.describe("Logout", () => { - test("can logout", async ({ page }) => { + test("can logout", async ({ page, request }) => { const email = uniqueEmail(); + const inviteCode = await createInvite(request); - // Sign up first + // Sign up await page.goto("/signup"); - await page.fill('input[type="email"]', email); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.fill('input#inviteCode', inviteCode); + await page.click('button[type="submit"]'); + await expect(page.locator("h1")).toHaveText("Create account"); + + await page.fill('input#email', email); + await page.fill('input#password', "password123"); + await page.fill('input#confirmPassword', "password123"); await page.click('button[type="submit"]'); await expect(page).toHaveURL("/"); @@ -206,14 +262,19 @@ test.describe("Logout", () => { await expect(page).toHaveURL("/login"); }); - test("cannot access home after logout", async ({ page }) => { + test("cannot access home after logout", async ({ page, request }) => { const email = uniqueEmail(); + const inviteCode = await createInvite(request); // Sign up await page.goto("/signup"); - await page.fill('input[type="email"]', email); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.fill('input#inviteCode', inviteCode); + await page.click('button[type="submit"]'); + await expect(page.locator("h1")).toHaveText("Create account"); + + await page.fill('input#email', email); + await page.fill('input#password', "password123"); + await page.fill('input#confirmPassword', "password123"); await page.click('button[type="submit"]'); await expect(page).toHaveURL("/"); @@ -228,14 +289,19 @@ test.describe("Logout", () => { }); test.describe("Session Persistence", () => { - test("session persists after page reload", async ({ page }) => { + test("session persists after page reload", async ({ page, request }) => { const email = uniqueEmail(); + const inviteCode = await createInvite(request); // Sign up await page.goto("/signup"); - await page.fill('input[type="email"]', email); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.fill('input#inviteCode', inviteCode); + await page.click('button[type="submit"]'); + await expect(page.locator("h1")).toHaveText("Create account"); + + await page.fill('input#email', email); + await page.fill('input#password', "password123"); + await page.fill('input#confirmPassword', "password123"); await page.click('button[type="submit"]'); await expect(page).toHaveURL("/"); await expect(page.getByText(email)).toBeVisible(); @@ -248,13 +314,18 @@ test.describe("Session Persistence", () => { await expect(page.getByText(email)).toBeVisible(); }); - test("auth cookie is set after login", async ({ page }) => { + test("auth cookie is set after signup", async ({ page, request }) => { const email = uniqueEmail(); + const inviteCode = await createInvite(request); await page.goto("/signup"); - await page.fill('input[type="email"]', email); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.fill('input#inviteCode', inviteCode); + await page.click('button[type="submit"]'); + await expect(page.locator("h1")).toHaveText("Create account"); + + await page.fill('input#email', email); + await page.fill('input#password', "password123"); + await page.fill('input#confirmPassword', "password123"); await page.click('button[type="submit"]'); await expect(page).toHaveURL("/"); @@ -265,24 +336,26 @@ test.describe("Session Persistence", () => { expect(authCookie!.httpOnly).toBe(true); }); - test("auth cookie is cleared on logout", async ({ page }) => { + test("auth cookie is cleared on logout", async ({ page, request }) => { const email = uniqueEmail(); + const inviteCode = await createInvite(request); await page.goto("/signup"); - await page.fill('input[type="email"]', email); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.fill('input#inviteCode', inviteCode); + await page.click('button[type="submit"]'); + await expect(page.locator("h1")).toHaveText("Create account"); + + await page.fill('input#email', email); + await page.fill('input#password', "password123"); + await page.fill('input#confirmPassword', "password123"); await page.click('button[type="submit"]'); await expect(page).toHaveURL("/"); await page.click("text=Sign out"); - // Wait for navigation to complete - ensures the logout request finished - // and the Set-Cookie header was processed by the browser await expect(page).toHaveURL("/login"); const cookies = await page.context().cookies(); const authCookie = cookies.find((c) => c.name === "auth_token"); - // Cookie should be deleted or have empty value expect(!authCookie || authCookie.value === "").toBe(true); }); }); diff --git a/frontend/e2e/counter.spec.ts b/frontend/e2e/counter.spec.ts index 20a3bd7..9cad77c 100644 --- a/frontend/e2e/counter.spec.ts +++ b/frontend/e2e/counter.spec.ts @@ -1,39 +1,75 @@ -import { test, expect, Page } from "@playwright/test"; +import { test, expect, Page, APIRequestContext } from "@playwright/test"; + +const API_BASE = "http://localhost:8000"; +const ADMIN_EMAIL = "admin@example.com"; +const ADMIN_PASSWORD = "admin123"; // Helper to generate unique email for each test function uniqueEmail(): string { return `counter-${Date.now()}-${Math.random().toString(36).substring(7)}@example.com`; } -// Helper to authenticate a user -async function authenticate(page: Page): Promise { +// Helper to create an invite via API +async function createInvite(request: APIRequestContext): Promise { + const loginResp = await request.post(`${API_BASE}/api/auth/login`, { + data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + const cookies = loginResp.headers()["set-cookie"]; + + const meResp = await request.get(`${API_BASE}/api/auth/me`, { + headers: { Cookie: cookies }, + }); + const admin = await meResp.json(); + + const inviteResp = await request.post(`${API_BASE}/api/admin/invites`, { + data: { godfather_id: admin.id }, + headers: { Cookie: cookies }, + }); + const invite = await inviteResp.json(); + return invite.identifier; +} + +// Helper to authenticate a user with invite-based signup +async function authenticate(page: Page, request: APIRequestContext): Promise { const email = uniqueEmail(); + const inviteCode = await createInvite(request); + await page.context().clearCookies(); await page.goto("/signup"); - await page.fill('input[type="email"]', email); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("password123"); + + // Enter invite code first + await page.fill('input#inviteCode', inviteCode); await page.click('button[type="submit"]'); + + // Wait for registration form + await expect(page.locator("h1")).toHaveText("Create account"); + + // Fill registration + await page.fill('input#email', email); + await page.fill('input#password', "password123"); + await page.fill('input#confirmPassword', "password123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/"); return email; } test.describe("Counter - Authenticated", () => { - test("displays counter value", async ({ page }) => { - await authenticate(page); + test("displays counter value", async ({ page, request }) => { + await authenticate(page, request); await expect(page.locator("h1")).toBeVisible(); // Counter should be a number (not loading state) const text = await page.locator("h1").textContent(); expect(text).toMatch(/^\d+$/); }); - test("displays current count label", async ({ page }) => { - await authenticate(page); + test("displays current count label", async ({ page, request }) => { + await authenticate(page, request); await expect(page.getByText("Current Count")).toBeVisible(); }); - test("clicking increment button increases counter", async ({ page }) => { - await authenticate(page); + test("clicking increment button increases counter", async ({ page, request }) => { + await authenticate(page, request); await expect(page.locator("h1")).not.toHaveText("..."); const before = await page.locator("h1").textContent(); @@ -41,8 +77,8 @@ test.describe("Counter - Authenticated", () => { await expect(page.locator("h1")).toHaveText(String(Number(before) + 1)); }); - test("clicking increment multiple times", async ({ page }) => { - await authenticate(page); + test("clicking increment multiple times", async ({ page, request }) => { + await authenticate(page, request); await expect(page.locator("h1")).not.toHaveText("..."); const before = Number(await page.locator("h1").textContent()); @@ -64,8 +100,8 @@ test.describe("Counter - Authenticated", () => { expect(final).toBeGreaterThanOrEqual(before + 3); }); - test("counter persists after page reload", async ({ page }) => { - await authenticate(page); + test("counter persists after page reload", async ({ page, request }) => { + await authenticate(page, request); await expect(page.locator("h1")).not.toHaveText("..."); const before = await page.locator("h1").textContent(); @@ -77,9 +113,9 @@ test.describe("Counter - Authenticated", () => { await expect(page.locator("h1")).toHaveText(expected); }); - test("counter is shared between users", async ({ page, browser }) => { + test("counter is shared between users", async ({ page, browser, request }) => { // First user increments - await authenticate(page); + await authenticate(page, request); await expect(page.locator("h1")).not.toHaveText("..."); const initialValue = Number(await page.locator("h1").textContent()); @@ -92,7 +128,7 @@ test.describe("Counter - Authenticated", () => { // Second user in new context sees the current value const page2 = await browser.newPage(); - await authenticate(page2); + await authenticate(page2, request); await expect(page2.locator("h1")).not.toHaveText("..."); const page2InitialValue = Number(await page2.locator("h1").textContent()); // The value should be at least what user 1 saw (might be higher due to parallel tests) @@ -130,14 +166,19 @@ test.describe("Counter - Unauthenticated", () => { }); test.describe("Counter - Session Integration", () => { - test("can access counter after login", async ({ page }) => { + test("can access counter after login", async ({ page, request }) => { const email = uniqueEmail(); + const inviteCode = await createInvite(request); - // Sign up first + // Sign up with invite await page.goto("/signup"); - await page.fill('input[type="email"]', email); - await page.fill('input[type="password"]', "password123"); - await page.locator('input[type="password"]').nth(1).fill("password123"); + await page.fill('input#inviteCode', inviteCode); + await page.click('button[type="submit"]'); + await expect(page.locator("h1")).toHaveText("Create account"); + + await page.fill('input#email', email); + await page.fill('input#password', "password123"); + await page.fill('input#confirmPassword', "password123"); await page.click('button[type="submit"]'); await expect(page).toHaveURL("/"); diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..36c809d --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/next/dist/shared/lib/amp.d.ts","./node_modules/next/amp.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/blob.d.ts","./node_modules/@types/node/web-globals/console.d.ts","./node_modules/@types/node/web-globals/crypto.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/encoding.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/undici-types/utility.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client-stats.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/h2c-client.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-call-history.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/snapshot-agent.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/cache-interceptor.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/web-globals/importmeta.d.ts","./node_modules/@types/node/web-globals/messaging.d.ts","./node_modules/@types/node/web-globals/navigator.d.ts","./node_modules/@types/node/web-globals/performance.d.ts","./node_modules/@types/node/web-globals/storage.d.ts","./node_modules/@types/node/web-globals/streams.d.ts","./node_modules/@types/node/web-globals/timers.d.ts","./node_modules/@types/node/web-globals/url.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/inspector/promises.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/path/posix.d.ts","./node_modules/@types/node/path/win32.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/quic.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/sqlite.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/test/reporters.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/util/types.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/react/canary.d.ts","./node_modules/@types/react/experimental.d.ts","./node_modules/next/dist/lib/fallback.d.ts","./node_modules/next/dist/compiled/webpack/webpack.d.ts","./node_modules/next/dist/server/config.d.ts","./node_modules/next/dist/lib/load-custom-routes.d.ts","./node_modules/next/dist/shared/lib/image-config.d.ts","./node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","./node_modules/next/dist/server/body-streams.d.ts","./node_modules/next/dist/server/lib/revalidate.d.ts","./node_modules/next/dist/lib/setup-exception-listeners.d.ts","./node_modules/next/dist/lib/worker.d.ts","./node_modules/next/dist/lib/constants.d.ts","./node_modules/next/dist/client/components/app-router-headers.d.ts","./node_modules/next/dist/build/rendering-mode.d.ts","./node_modules/next/dist/server/require-hook.d.ts","./node_modules/next/dist/server/lib/experimental/ppr.d.ts","./node_modules/next/dist/build/webpack/plugins/app-build-manifest-plugin.d.ts","./node_modules/next/dist/lib/page-types.d.ts","./node_modules/next/dist/build/segment-config/app/app-segment-config.d.ts","./node_modules/next/dist/build/segment-config/pages/pages-segment-config.d.ts","./node_modules/next/dist/build/analysis/get-page-static-info.d.ts","./node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","./node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","./node_modules/next/dist/server/route-kind.d.ts","./node_modules/next/dist/server/route-definitions/route-definition.d.ts","./node_modules/next/dist/server/route-definitions/app-page-route-definition.d.ts","./node_modules/next/dist/server/lib/cache-handlers/types.d.ts","./node_modules/next/dist/server/response-cache/types.d.ts","./node_modules/next/dist/server/resume-data-cache/cache-store.d.ts","./node_modules/next/dist/server/resume-data-cache/resume-data-cache.d.ts","./node_modules/next/dist/server/render-result.d.ts","./node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","./node_modules/next/dist/server/route-modules/route-module.d.ts","./node_modules/next/dist/shared/lib/deep-readonly.d.ts","./node_modules/next/dist/server/load-components.d.ts","./node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","./node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","./node_modules/next/dist/client/flight-data-helpers.d.ts","./node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","./node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","./node_modules/next/dist/server/route-definitions/locale-route-definition.d.ts","./node_modules/next/dist/server/route-definitions/pages-route-definition.d.ts","./node_modules/next/dist/shared/lib/mitt.d.ts","./node_modules/next/dist/client/with-router.d.ts","./node_modules/next/dist/client/router.d.ts","./node_modules/next/dist/client/route-loader.d.ts","./node_modules/next/dist/client/page-loader.d.ts","./node_modules/next/dist/shared/lib/bloom-filter.d.ts","./node_modules/next/dist/shared/lib/router/router.d.ts","./node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/amp-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","./node_modules/next/dist/server/route-modules/pages/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/pages/module.compiled.d.ts","./node_modules/next/dist/build/templates/pages.d.ts","./node_modules/next/dist/server/route-modules/pages/module.d.ts","./node_modules/next/dist/server/render.d.ts","./node_modules/next/dist/server/response-cache/index.d.ts","./node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","./node_modules/next/dist/server/route-definitions/pages-api-route-definition.d.ts","./node_modules/next/dist/server/route-matches/pages-api-route-match.d.ts","./node_modules/next/dist/server/instrumentation/types.d.ts","./node_modules/next/dist/server/route-matchers/route-matcher.d.ts","./node_modules/next/dist/server/route-matcher-providers/route-matcher-provider.d.ts","./node_modules/next/dist/server/lib/i18n-provider.d.ts","./node_modules/next/dist/server/route-matcher-managers/route-matcher-manager.d.ts","./node_modules/next/dist/server/normalizers/normalizer.d.ts","./node_modules/next/dist/server/normalizers/locale-route-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/pathname-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/suffix.d.ts","./node_modules/next/dist/server/normalizers/request/rsc.d.ts","./node_modules/next/dist/server/normalizers/request/prefetch-rsc.d.ts","./node_modules/next/dist/server/normalizers/request/next-data.d.ts","./node_modules/next/dist/server/after/builtin-request-context.d.ts","./node_modules/next/dist/server/base-server.d.ts","./node_modules/next/dist/server/web/next-url.d.ts","./node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","./node_modules/next/dist/server/web/spec-extension/cookies.d.ts","./node_modules/next/dist/server/web/spec-extension/request.d.ts","./node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","./node_modules/next/dist/server/web/spec-extension/response.d.ts","./node_modules/next/dist/build/segment-config/middleware/middleware-config.d.ts","./node_modules/next/dist/server/web/types.d.ts","./node_modules/next/dist/server/web/adapter.d.ts","./node_modules/next/dist/server/use-cache/cache-life.d.ts","./node_modules/next/dist/server/app-render/types.d.ts","./node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","./node_modules/next/dist/shared/lib/constants.d.ts","./node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","./node_modules/next/dist/build/page-extensions-type.d.ts","./node_modules/next/dist/build/webpack/loaders/next-app-loader/index.d.ts","./node_modules/next/dist/server/lib/app-dir-module.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","./node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","./node_modules/next/dist/server/app-render/cache-signal.d.ts","./node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage.external.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-relative-url.d.ts","./node_modules/next/dist/server/request/fallback-params.d.ts","./node_modules/next/dist/server/app-render/clean-async-snapshot-instance.d.ts","./node_modules/next/dist/server/app-render/clean-async-snapshot.external.d.ts","./node_modules/next/dist/server/app-render/app-render.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.compiled.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./node_modules/next/dist/client/components/error-boundary.d.ts","./node_modules/next/dist/client/components/layout-router.d.ts","./node_modules/next/dist/client/components/render-from-template-context.d.ts","./node_modules/next/dist/server/app-render/action-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/action-async-storage.external.d.ts","./node_modules/next/dist/client/components/client-page.d.ts","./node_modules/next/dist/client/components/client-segment.d.ts","./node_modules/next/dist/server/request/search-params.d.ts","./node_modules/next/dist/client/components/hooks-server-context.d.ts","./node_modules/next/dist/client/components/http-access-fallback/error-boundary.d.ts","./node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","./node_modules/next/dist/lib/metadata/types/extra-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","./node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","./node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","./node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","./node_modules/next/dist/lib/metadata/types/resolvers.d.ts","./node_modules/next/dist/lib/metadata/types/icons.d.ts","./node_modules/next/dist/lib/metadata/resolve-metadata.d.ts","./node_modules/next/dist/lib/metadata/metadata.d.ts","./node_modules/next/dist/lib/metadata/metadata-boundary.d.ts","./node_modules/next/dist/server/app-render/rsc/preloads.d.ts","./node_modules/next/dist/server/app-render/rsc/postpone.d.ts","./node_modules/next/dist/server/app-render/rsc/taint.d.ts","./node_modules/next/dist/server/app-render/collect-segment-data.d.ts","./node_modules/next/dist/server/app-render/entry-base.d.ts","./node_modules/next/dist/build/templates/app-page.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.d.ts","./node_modules/next/dist/server/node-polyfill-crypto.d.ts","./node_modules/next/dist/server/node-environment-baseline.d.ts","./node_modules/next/dist/server/node-environment-extensions/error-inspect.d.ts","./node_modules/next/dist/server/node-environment-extensions/random.d.ts","./node_modules/next/dist/server/node-environment-extensions/date.d.ts","./node_modules/next/dist/server/node-environment-extensions/web-crypto.d.ts","./node_modules/next/dist/server/node-environment-extensions/node-crypto.d.ts","./node_modules/next/dist/server/node-environment.d.ts","./node_modules/next/dist/server/route-definitions/app-route-route-definition.d.ts","./node_modules/next/dist/server/async-storage/work-store.d.ts","./node_modules/next/dist/server/web/http.d.ts","./node_modules/next/dist/server/route-modules/app-route/shared-modules.d.ts","./node_modules/next/dist/client/components/redirect-status-code.d.ts","./node_modules/next/dist/client/components/redirect-error.d.ts","./node_modules/next/dist/build/templates/app-route.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.compiled.d.ts","./node_modules/next/dist/build/segment-config/app/app-segments.d.ts","./node_modules/next/dist/build/utils.d.ts","./node_modules/next/dist/build/turborepo-access-trace/types.d.ts","./node_modules/next/dist/build/turborepo-access-trace/result.d.ts","./node_modules/next/dist/build/turborepo-access-trace/helpers.d.ts","./node_modules/next/dist/build/turborepo-access-trace/index.d.ts","./node_modules/next/dist/export/types.d.ts","./node_modules/next/dist/export/worker.d.ts","./node_modules/next/dist/build/worker.d.ts","./node_modules/next/dist/build/index.d.ts","./node_modules/next/dist/server/lib/incremental-cache/index.d.ts","./node_modules/next/dist/server/after/after.d.ts","./node_modules/next/dist/server/after/after-context.d.ts","./node_modules/next/dist/server/app-render/work-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/work-async-storage.external.d.ts","./node_modules/next/dist/server/request/params.d.ts","./node_modules/next/dist/server/route-matches/route-match.d.ts","./node_modules/next/dist/server/request-meta.d.ts","./node_modules/next/dist/cli/next-test.d.ts","./node_modules/next/dist/server/config-shared.d.ts","./node_modules/next/dist/server/base-http/index.d.ts","./node_modules/next/dist/server/api-utils/index.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","./node_modules/next/dist/server/base-http/node.d.ts","./node_modules/next/dist/server/lib/async-callback-set.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","./node_modules/sharp/lib/index.d.ts","./node_modules/next/dist/server/image-optimizer.d.ts","./node_modules/next/dist/server/next-server.d.ts","./node_modules/next/dist/lib/coalesced-function.d.ts","./node_modules/next/dist/server/lib/router-utils/types.d.ts","./node_modules/next/dist/trace/types.d.ts","./node_modules/next/dist/trace/trace.d.ts","./node_modules/next/dist/trace/shared.d.ts","./node_modules/next/dist/trace/index.d.ts","./node_modules/next/dist/build/load-jsconfig.d.ts","./node_modules/next/dist/build/webpack-config.d.ts","./node_modules/next/dist/build/swc/generated-native.d.ts","./node_modules/next/dist/build/swc/types.d.ts","./node_modules/next/dist/server/dev/parse-version-info.d.ts","./node_modules/next/dist/client/components/react-dev-overlay/types.d.ts","./node_modules/next/dist/server/dev/hot-reloader-types.d.ts","./node_modules/next/dist/telemetry/storage.d.ts","./node_modules/next/dist/server/lib/render-server.d.ts","./node_modules/next/dist/server/lib/router-server.d.ts","./node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","./node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","./node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","./node_modules/next/dist/server/lib/types.d.ts","./node_modules/next/dist/server/lib/lru-cache.d.ts","./node_modules/next/dist/server/lib/dev-bundler-service.d.ts","./node_modules/next/dist/server/dev/static-paths-worker.d.ts","./node_modules/next/dist/server/dev/next-dev-server.d.ts","./node_modules/next/dist/server/next.d.ts","./node_modules/next/dist/types.d.ts","./node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","./node_modules/@next/env/dist/index.d.ts","./node_modules/next/dist/shared/lib/utils.d.ts","./node_modules/next/dist/pages/_app.d.ts","./node_modules/next/app.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","./node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","./node_modules/next/dist/server/use-cache/cache-tag.d.ts","./node_modules/next/cache.d.ts","./node_modules/next/dist/shared/lib/runtime-config.external.d.ts","./node_modules/next/config.d.ts","./node_modules/next/dist/pages/_document.d.ts","./node_modules/next/document.d.ts","./node_modules/next/dist/shared/lib/dynamic.d.ts","./node_modules/next/dynamic.d.ts","./node_modules/next/dist/pages/_error.d.ts","./node_modules/next/error.d.ts","./node_modules/next/dist/shared/lib/head.d.ts","./node_modules/next/head.d.ts","./node_modules/next/dist/server/request/cookies.d.ts","./node_modules/next/dist/server/request/headers.d.ts","./node_modules/next/dist/server/request/draft-mode.d.ts","./node_modules/next/headers.d.ts","./node_modules/next/dist/shared/lib/get-img-props.d.ts","./node_modules/next/dist/client/image-component.d.ts","./node_modules/next/dist/shared/lib/image-external.d.ts","./node_modules/next/image.d.ts","./node_modules/next/dist/client/link.d.ts","./node_modules/next/link.d.ts","./node_modules/next/dist/client/components/redirect.d.ts","./node_modules/next/dist/client/components/not-found.d.ts","./node_modules/next/dist/client/components/forbidden.d.ts","./node_modules/next/dist/client/components/unauthorized.d.ts","./node_modules/next/dist/client/components/unstable-rethrow.d.ts","./node_modules/next/dist/client/components/navigation.react-server.d.ts","./node_modules/next/dist/client/components/navigation.d.ts","./node_modules/next/navigation.d.ts","./node_modules/next/router.d.ts","./node_modules/next/dist/client/script.d.ts","./node_modules/next/script.d.ts","./node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","./node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","./node_modules/next/dist/server/web/spec-extension/image-response.d.ts","./node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/types.d.ts","./node_modules/next/dist/server/after/index.d.ts","./node_modules/next/dist/server/request/connection.d.ts","./node_modules/next/server.d.ts","./node_modules/next/types/global.d.ts","./node_modules/next/types/compiled.d.ts","./node_modules/next/types.d.ts","./node_modules/next/index.d.ts","./node_modules/next/image-types/global.d.ts","./next-env.d.ts","./next.config.ts","./node_modules/playwright-core/types/protocol.d.ts","./node_modules/playwright-core/types/structs.d.ts","./node_modules/playwright-core/types/types.d.ts","./node_modules/playwright-core/index.d.ts","./node_modules/playwright/types/test.d.ts","./node_modules/playwright/test.d.ts","./node_modules/@playwright/test/index.d.ts","./playwright.config.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseAst.d.ts","./node_modules/vite/types/hmrPayload.d.ts","./node_modules/vite/types/customEvent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-aGj9QkWt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/vite/node_modules/postcss/lib/previous-map.d.ts","./node_modules/vite/node_modules/postcss/lib/input.d.ts","./node_modules/vite/node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/vite/node_modules/postcss/lib/declaration.d.ts","./node_modules/vite/node_modules/postcss/lib/root.d.ts","./node_modules/vite/node_modules/postcss/lib/warning.d.ts","./node_modules/vite/node_modules/postcss/lib/lazy-result.d.ts","./node_modules/vite/node_modules/postcss/lib/no-work-result.d.ts","./node_modules/vite/node_modules/postcss/lib/processor.d.ts","./node_modules/vite/node_modules/postcss/lib/result.d.ts","./node_modules/vite/node_modules/postcss/lib/document.d.ts","./node_modules/vite/node_modules/postcss/lib/rule.d.ts","./node_modules/vite/node_modules/postcss/lib/node.d.ts","./node_modules/vite/node_modules/postcss/lib/comment.d.ts","./node_modules/vite/node_modules/postcss/lib/container.d.ts","./node_modules/vite/node_modules/postcss/lib/at-rule.d.ts","./node_modules/vite/node_modules/postcss/lib/list.d.ts","./node_modules/vite/node_modules/postcss/lib/postcss.d.ts","./node_modules/vite/node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importGlob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@vitest/pretty-format/dist/index.d.ts","./node_modules/@vitest/utils/dist/types.d.ts","./node_modules/@vitest/utils/dist/helpers.d.ts","./node_modules/tinyrainbow/dist/index-c1cfc5e9.d.ts","./node_modules/tinyrainbow/dist/node.d.ts","./node_modules/@vitest/utils/dist/index.d.ts","./node_modules/@vitest/runner/dist/tasks-3ZnPj1LR.d.ts","./node_modules/@vitest/utils/dist/types-Bxe-2Udy.d.ts","./node_modules/@vitest/utils/dist/diff.d.ts","./node_modules/@vitest/runner/dist/types.d.ts","./node_modules/@vitest/utils/dist/error.d.ts","./node_modules/@vitest/runner/dist/index.d.ts","./node_modules/vitest/dist/chunks/environment.LoooBwUu.d.ts","./node_modules/@vitest/snapshot/dist/environment-Ddx0EDtY.d.ts","./node_modules/@vitest/snapshot/dist/rawSnapshot-CPNkto81.d.ts","./node_modules/@vitest/snapshot/dist/index.d.ts","./node_modules/@vitest/snapshot/dist/environment.d.ts","./node_modules/vitest/dist/chunks/config.Cy0C388Z.d.ts","./node_modules/vite-node/dist/trace-mapping.d-DLVdEqOp.d.ts","./node_modules/vite-node/dist/index-z0R8hVRu.d.ts","./node_modules/vite-node/dist/index.d.ts","./node_modules/@vitest/utils/dist/source-map.d.ts","./node_modules/vite-node/dist/client.d.ts","./node_modules/vite-node/dist/server.d.ts","./node_modules/@vitest/runner/dist/utils.d.ts","./node_modules/tinybench/dist/index.d.ts","./node_modules/vitest/dist/chunks/benchmark.geERunq4.d.ts","./node_modules/@vitest/snapshot/dist/manager.d.ts","./node_modules/vitest/dist/chunks/reporters.nr4dxCkA.d.ts","./node_modules/vitest/dist/chunks/vite.CzKp4x9w.d.ts","./node_modules/vitest/dist/config.d.ts","./node_modules/vitest/config.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vitest.config.ts","./app/config.ts","./app/api.ts","./app/auth-context.tsx","./app/hooks/useRequireAuth.ts","./app/styles/auth-form.ts","./app/styles/shared.ts","./e2e/admin-invites.spec.ts","./e2e/auth.spec.ts","./e2e/counter.spec.ts","./e2e/permissions.spec.ts","./e2e/profile.spec.ts","./app/layout.tsx","./node_modules/@types/aria-query/index.d.ts","./node_modules/@testing-library/dom/types/matches.d.ts","./node_modules/@testing-library/dom/types/wait-for.d.ts","./node_modules/@testing-library/dom/types/query-helpers.d.ts","./node_modules/@testing-library/dom/types/queries.d.ts","./node_modules/@testing-library/dom/types/get-queries-for-element.d.ts","./node_modules/pretty-format/build/types.d.ts","./node_modules/pretty-format/build/index.d.ts","./node_modules/@testing-library/dom/types/screen.d.ts","./node_modules/@testing-library/dom/types/wait-for-element-to-be-removed.d.ts","./node_modules/@testing-library/dom/types/get-node-text.d.ts","./node_modules/@testing-library/dom/types/events.d.ts","./node_modules/@testing-library/dom/types/pretty-dom.d.ts","./node_modules/@testing-library/dom/types/role-helpers.d.ts","./node_modules/@testing-library/dom/types/config.d.ts","./node_modules/@testing-library/dom/types/suggestions.d.ts","./node_modules/@testing-library/dom/types/index.d.ts","./node_modules/@testing-library/react/types/index.d.ts","./node_modules/vitest/dist/chunks/worker.tN5KGIih.d.ts","./node_modules/vitest/dist/chunks/worker.B9FxPCaC.d.ts","./node_modules/@vitest/expect/dist/chai.d.cts","./node_modules/@vitest/expect/dist/index.d.ts","./node_modules/@vitest/expect/index.d.ts","./node_modules/@vitest/spy/dist/index.d.ts","./node_modules/@vitest/mocker/dist/types-DZOqTgiN.d.ts","./node_modules/@vitest/mocker/dist/index.d.ts","./node_modules/vitest/dist/chunks/mocker.cRtM890J.d.ts","./node_modules/vitest/dist/chunks/suite.B2jumIFP.d.ts","./node_modules/expect-type/dist/utils.d.ts","./node_modules/expect-type/dist/overloads.d.ts","./node_modules/expect-type/dist/branding.d.ts","./node_modules/expect-type/dist/messages.d.ts","./node_modules/expect-type/dist/index.d.ts","./node_modules/vitest/dist/index.d.ts","./app/components/Header.tsx","./app/page.tsx","./app/page.test.tsx","./app/admin/invites/page.tsx","./app/audit/page.tsx","./app/audit/page.test.tsx","./app/invites/page.tsx","./app/login/page.tsx","./app/login/page.test.tsx","./node_modules/bech32/dist/index.d.ts","./app/profile/page.tsx","./app/profile/page.test.tsx","./app/signup/page.tsx","./app/signup/page.test.tsx","./app/signup/[code]/page.tsx","./app/sum/page.tsx","./.next/types/cache-life.d.ts","./.next/types/app/layout.ts","./.next/types/app/page.ts","./.next/types/app/admin/invites/page.ts","./.next/types/app/audit/page.ts","./.next/types/app/login/page.ts","./.next/types/app/profile/page.ts","./.next/types/app/signup/page.ts","./.next/types/app/sum/page.ts"],"fileIdsList":[[96,158,166,170,173,175,176,177,189,345,617],[96,158,166,170,173,175,176,177,189,345,618],[96,158,166,170,173,175,176,177,189,345,579],[96,158,166,170,173,175,176,177,189,345,621],[96,158,166,170,173,175,176,177,189,345,615],[96,158,166,170,173,175,176,177,189,345,624],[96,158,166,170,173,175,176,177,189,345,626],[96,158,166,170,173,175,176,177,189,345,629],[96,158,166,170,173,175,176,177,189,436,437,438,439],[85,96,158,166,170,173,175,176,177,189,569,570,571,573,614],[96,158,166,170,173,175,176,177,189,568],[96,158,166,170,173,175,176,177,189,597,613,618],[85,96,158,166,170,173,175,176,177,189,569],[96,158,166,170,173,175,176,177,189,468,570,573],[96,158,166,170,173,175,176,177,189],[85,96,158,166,170,173,175,176,177,189,468,570],[85,96,158,166,170,173,175,176,177,189,569,571,573,614],[96,158,166,170,173,175,176,177,189,570],[96,158,166,170,173,175,176,177,189,597,613,621],[85,96,158,166,170,173,175,176,177,189,468,570,572],[96,158,166,170,173,175,176,177,189,597,613,615],[96,158,166,170,173,175,176,177,189,597,613,624],[85,96,158,166,170,173,175,176,177,189,569,571,573,614,623],[96,158,166,170,173,175,176,177,189,597,613,626],[85,96,158,166,170,173,175,176,177,189,468,569,570,572],[85,96,158,166,170,173,175,176,177,189],[96,158,166,170,173,175,176,177,189,494],[96,158,166,170,173,175,176,177,189,484,485],[96,158,166,170,173,175,176,177,189,484],[96,158,166,170,173,175,176,177,189,560],[96,158,166,170,173,175,176,177,189,493],[96,158,166,170,173,175,176,177,189,584],[96,158,166,170,173,175,176,177,189,581,582,583,584,585,588,589,590,591,592,593,594,595],[96,158,166,170,173,175,176,177,189,580],[96,158,166,170,173,175,176,177,189,587],[96,158,166,170,173,175,176,177,189,581,582,583],[96,158,166,170,173,175,176,177,189,581,582],[96,158,166,170,173,175,176,177,189,584,585,587],[96,158,166,170,173,175,176,177,189,582],[85,96,158,166,170,173,175,176,177,189,596],[96,158,166,170,173,175,176,177,189,560,561,562,563,564],[96,158,166,170,173,175,176,177,189,560,562],[96,155,156,158,166,170,173,175,176,177,189],[96,157,158,166,170,173,175,176,177,189],[158,166,170,173,175,176,177,189],[96,158,166,170,173,175,176,177,189,197],[96,158,159,164,166,169,170,173,175,176,177,179,189,194,206],[96,158,159,160,166,169,170,173,175,176,177,189],[96,158,161,166,170,173,175,176,177,189,207],[96,158,162,163,166,170,173,175,176,177,180,189],[96,158,163,166,170,173,175,176,177,189,194,203],[96,158,164,166,169,170,173,175,176,177,179,189],[96,157,158,165,166,170,173,175,176,177,189],[96,158,166,167,170,173,175,176,177,189],[96,158,166,168,169,170,173,175,176,177,189],[96,157,158,166,169,170,173,175,176,177,189],[96,158,166,169,170,171,173,175,176,177,189,194,206],[96,158,166,169,170,171,173,175,176,177,189,194,197],[96,145,158,166,169,170,172,173,175,176,177,179,189,194,206],[96,158,166,169,170,172,173,175,176,177,179,189,194,203,206],[96,158,166,170,172,173,174,175,176,177,189,194,203,206],[94,95,96,97,98,99,100,101,102,103,104,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213],[96,158,166,169,170,173,175,176,177,189],[96,158,166,170,173,175,177,189],[96,158,166,170,173,175,176,177,178,189,206],[96,158,166,169,170,173,175,176,177,179,189,194],[96,158,166,170,173,175,176,177,180,189],[96,158,166,170,173,175,176,177,181,189],[96,158,166,169,170,173,175,176,177,184,189],[96,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213],[96,158,166,170,173,175,176,177,186,189],[96,158,166,170,173,175,176,177,187,189],[96,158,163,166,170,173,175,176,177,179,189,197],[96,158,166,169,170,173,175,176,177,189,190],[96,158,166,170,173,175,176,177,189,191,207,210],[96,158,166,169,170,173,175,176,177,189,194,196,197],[96,158,166,170,173,175,176,177,189,195,197],[96,158,166,170,173,175,176,177,189,197,207],[96,158,166,170,173,175,176,177,189,198],[96,155,158,166,170,173,175,176,177,189,194,200],[96,158,166,170,173,175,176,177,189,194,199],[96,158,166,169,170,173,175,176,177,189,201,202],[96,158,166,170,173,175,176,177,189,201,202],[96,158,163,166,170,173,175,176,177,179,189,194,203],[96,158,166,170,173,175,176,177,189,204],[96,158,166,170,173,175,176,177,179,189,205],[96,158,166,170,172,173,175,176,177,187,189,206],[96,158,166,170,173,175,176,177,189,207,208],[96,158,163,166,170,173,175,176,177,189,208],[96,158,166,170,173,175,176,177,189,194,209],[96,158,166,170,173,175,176,177,178,189,210],[96,158,166,170,173,175,176,177,189,211],[96,158,161,166,170,173,175,176,177,189],[96,158,163,166,170,173,175,176,177,189],[96,158,166,170,173,175,176,177,189,207],[96,145,158,166,170,173,175,176,177,189],[96,158,166,170,173,175,176,177,189,206],[96,158,166,170,173,175,176,177,189,212],[96,158,166,170,173,175,176,177,184,189],[96,158,166,170,173,175,176,177,189,202],[96,145,158,166,169,170,171,173,175,176,177,184,189,194,197,206,209,210,212],[96,158,166,170,173,175,176,177,189,194,213],[85,89,96,158,166,170,173,175,176,177,189,216,430,477],[85,89,96,158,166,170,173,175,176,177,189,215,430,477],[83,84,96,158,166,170,173,175,176,177,189],[96,158,166,170,173,175,176,177,189,527,557,565],[96,158,166,170,173,175,176,177,189,532,533,536],[96,158,166,170,173,175,176,177,189,601],[96,158,166,170,173,175,176,177,189,604],[96,158,166,170,173,175,176,177,189,533,534,536,537,538],[96,158,166,170,173,175,176,177,189,533],[96,158,166,170,173,175,176,177,189,533,534,536],[96,158,166,170,173,175,176,177,189,533,534],[96,158,166,170,173,175,176,177,189,541],[96,158,166,170,173,175,176,177,189,528,541,542],[96,158,166,170,173,175,176,177,189,528,541],[96,158,166,170,173,175,176,177,189,528,535],[96,158,166,170,173,175,176,177,189,529],[96,158,166,170,173,175,176,177,189,528,529,530,532],[96,158,166,170,173,175,176,177,189,528],[96,158,166,170,173,175,176,177,189,608,609],[96,158,166,170,173,175,176,177,189,608,609,610,611],[96,158,166,170,173,175,176,177,189,608,610],[96,158,166,170,173,175,176,177,189,608],[91,96,158,166,170,173,175,176,177,189],[96,158,166,170,173,175,176,177,189,434],[96,158,166,170,173,175,176,177,189,441],[96,158,166,170,173,175,176,177,189,220,233,234,235,237,394],[96,158,166,170,173,175,176,177,189,220,224,226,227,228,229,383,394,396],[96,158,166,170,173,175,176,177,189,394],[96,158,166,170,173,175,176,177,189,234,250,327,374,390],[96,158,166,170,173,175,176,177,189,220],[96,158,166,170,173,175,176,177,189,414],[96,158,166,170,173,175,176,177,189,394,396,413],[96,158,166,170,173,175,176,177,189,313,327,355,482],[96,158,166,170,173,175,176,177,189,320,337,374,389],[96,158,166,170,173,175,176,177,189,275],[96,158,166,170,173,175,176,177,189,378],[96,158,166,170,173,175,176,177,189,377,378,379],[96,158,166,170,173,175,176,177,189,377],[93,96,158,166,170,172,173,175,176,177,189,217,220,227,230,231,232,234,238,306,311,357,365,375,385,394,430],[96,158,166,170,173,175,176,177,189,220,236,264,309,394,410,411,482],[96,158,166,170,173,175,176,177,189,236,482],[96,158,166,170,173,175,176,177,189,309,310,311,394,482],[96,158,166,170,173,175,176,177,189,482],[96,158,166,170,173,175,176,177,189,220,236,237,482],[96,158,166,170,173,175,176,177,189,230,376,382],[96,158,166,170,173,175,176,177,187,189,328,390],[96,158,166,170,173,175,176,177,189,328,390],[85,96,158,166,170,173,175,176,177,189,328],[85,96,158,166,170,173,175,176,177,189,307,328,329],[96,158,166,170,173,175,176,177,189,255,273,390,466],[96,158,166,170,173,175,176,177,189,371,461,462,463,464,465],[96,158,166,170,173,175,176,177,189,370],[96,158,166,170,173,175,176,177,189,370,371],[96,158,166,170,173,175,176,177,189,228,252,253,307],[96,158,166,170,173,175,176,177,189,254,255,307],[96,158,166,170,173,175,176,177,189,307],[85,96,158,166,170,173,175,176,177,189,221,455],[85,96,158,166,170,173,175,176,177,189,206],[85,96,158,166,170,173,175,176,177,189,236,262],[85,96,158,166,170,173,175,176,177,189,236],[96,158,166,170,173,175,176,177,189,260,265],[85,96,158,166,170,173,175,176,177,189,261,433],[85,89,96,158,166,170,172,173,175,176,177,189,214,215,216,430,475,476],[96,158,166,170,172,173,175,176,177,189,224,250,278,296,307,380,394,395,482],[96,158,166,170,173,175,176,177,189,365,381],[96,158,166,170,173,175,176,177,189,430],[96,158,166,170,173,175,176,177,189,219],[96,158,166,170,173,175,176,177,187,189,313,325,346,348,389,390],[96,158,166,170,173,175,176,177,187,189,313,325,345,346,347,389,390],[96,158,166,170,173,175,176,177,189,339,340,341,342,343,344],[96,158,166,170,173,175,176,177,189,341],[96,158,166,170,173,175,176,177,189,345],[85,96,158,166,170,173,175,176,177,189,261,328,433],[85,96,158,166,170,173,175,176,177,189,328,431,433],[85,96,158,166,170,173,175,176,177,189,328,433],[96,158,166,170,173,175,176,177,189,296,386],[96,158,166,170,173,175,176,177,189,386],[96,158,166,170,172,173,175,176,177,189,395,433],[96,158,166,170,173,175,176,177,189,333],[96,157,158,166,170,173,175,176,177,189,332],[96,158,166,170,173,175,176,177,189,246,247,249,279,307,320,321,322,324,357,389,392,395],[96,158,166,170,173,175,176,177,189,323],[96,158,166,170,173,175,176,177,189,247,255,307],[96,158,166,170,173,175,176,177,189,320,389],[96,158,166,170,173,175,176,177,189,320,329,330,331,333,334,335,336,337,338,349,350,351,352,353,354,389,390,482],[96,158,166,170,173,175,176,177,189,318],[96,158,166,170,172,173,175,176,177,187,189,224,245,247,249,250,251,255,283,296,305,306,357,385,394,395,396,430,482],[96,158,166,170,173,175,176,177,189,389],[96,157,158,166,170,173,175,176,177,189,234,249,306,322,337,385,387,388,395],[96,158,166,170,173,175,176,177,189,320],[96,157,158,166,170,173,175,176,177,189,245,279,299,314,315,316,317,318,319],[96,158,166,170,172,173,175,176,177,189,299,300,314,395,396],[96,158,166,170,173,175,176,177,189,234,296,306,307,322,385,389,395],[96,158,166,170,172,173,175,176,177,189,394,396],[96,158,166,170,172,173,175,176,177,189,194,392,395,396],[96,158,166,170,172,173,175,176,177,187,189,206,217,224,236,246,247,249,250,251,256,278,279,280,282,283,286,287,289,292,293,294,295,307,384,385,390,392,394,395,396],[96,158,166,170,172,173,175,176,177,189,194],[96,158,166,170,173,175,176,177,189,220,221,222,224,231,392,393,430,433,482],[96,158,166,170,172,173,175,176,177,189,194,206,240,412,414,415,416,482],[96,158,166,170,173,175,176,177,187,189,206,217,240,250,279,280,287,296,304,307,385,390,392,397,398,404,410,426,427],[96,158,166,170,173,175,176,177,189,230,231,306,365,376,385,394],[96,158,166,170,172,173,175,176,177,189,206,221,279,392,394,402],[96,158,166,170,173,175,176,177,189,312],[96,158,166,170,172,173,175,176,177,189,423,424,425],[96,158,166,170,173,175,176,177,189,392,394],[96,158,166,170,173,175,176,177,189,224,249,279,384,433],[96,158,166,170,172,173,175,176,177,187,189,287,296,392,398,404,406,410,426,429],[96,158,166,170,172,173,175,176,177,189,230,365,410,419],[96,158,166,170,173,175,176,177,189,220,256,384,394,421],[96,158,166,170,172,173,175,176,177,189,236,256,394,405,406,417,418,420,422],[93,96,158,166,170,173,175,176,177,189,247,248,249,430,433],[96,158,166,170,172,173,175,176,177,187,189,206,224,230,238,246,250,251,279,280,282,283,295,296,304,307,365,384,385,390,391,392,397,398,399,401,403,433],[96,158,166,170,172,173,175,176,177,189,194,230,392,404,423,428],[96,158,166,170,173,175,176,177,189,360,361,362,363,364],[96,158,166,170,173,175,176,177,189,286,288],[96,158,166,170,173,175,176,177,189,290],[96,158,166,170,173,175,176,177,189,288],[96,158,166,170,173,175,176,177,189,290,291],[96,158,166,170,172,173,175,176,177,189,224,245,395],[85,96,158,166,170,172,173,175,176,177,187,189,219,221,224,246,247,249,250,251,277,392,396,430,433],[96,158,166,170,172,173,175,176,177,187,189,206,223,228,279,391,395],[96,158,166,170,173,175,176,177,189,314],[96,158,166,170,173,175,176,177,189,315],[96,158,166,170,173,175,176,177,189,316],[96,158,166,170,173,175,176,177,189,239,243],[96,158,166,170,172,173,175,176,177,189,224,239,246],[96,158,166,170,173,175,176,177,189,242,243],[96,158,166,170,173,175,176,177,189,244],[96,158,166,170,173,175,176,177,189,239,240],[96,158,166,170,173,175,176,177,189,239,257],[96,158,166,170,173,175,176,177,189,239],[96,158,166,170,173,175,176,177,189,285,286,391],[96,158,166,170,173,175,176,177,189,284],[96,158,166,170,173,175,176,177,189,240,390,391],[96,158,166,170,173,175,176,177,189,281,391],[96,158,166,170,173,175,176,177,189,240,390],[96,158,166,170,173,175,176,177,189,357],[96,158,166,170,173,175,176,177,189,241,246,248,279,307,313,322,325,326,356,392,395],[96,158,166,170,173,175,176,177,189,255,266,269,270,271,272,273],[96,158,166,170,173,175,176,177,189,373],[96,158,166,170,173,175,176,177,189,234,248,249,300,307,320,333,337,366,367,368,369,371,372,375,384,389,394],[96,158,166,170,173,175,176,177,189,255],[96,158,166,170,173,175,176,177,189,277],[96,158,166,170,172,173,175,176,177,189,246,248,258,274,276,278,392,430,433],[96,158,166,170,173,175,176,177,189,255,266,267,268,269,270,271,272,273,431],[96,158,166,170,173,175,176,177,189,240],[96,158,166,170,173,175,176,177,189,300,301,304,385],[96,158,166,170,172,173,175,176,177,189,286,394],[96,158,166,170,172,173,175,176,177,189],[96,158,166,170,173,175,176,177,189,299,320],[96,158,166,170,173,175,176,177,189,298],[96,158,166,170,173,175,176,177,189,295,300],[96,158,166,170,173,175,176,177,189,297,299,394],[96,158,166,170,172,173,175,176,177,189,223,300,301,302,303,394,395],[85,96,158,166,170,173,175,176,177,189,252,254,307],[96,158,166,170,173,175,176,177,189,308],[85,96,158,166,170,173,175,176,177,189,221],[85,96,158,166,170,173,175,176,177,189,390],[85,93,96,158,166,170,173,175,176,177,189,249,251,430,433],[96,158,166,170,173,175,176,177,189,221,455,456],[85,96,158,166,170,173,175,176,177,189,265],[85,96,158,166,170,173,175,176,177,187,189,206,219,259,261,263,264,433],[96,158,166,170,173,175,176,177,189,236,390,395],[96,158,166,170,173,175,176,177,189,390,400],[85,96,158,166,170,172,173,175,176,177,187,189,219,265,309,430,431,432],[85,96,158,166,170,173,175,176,177,189,215,216,430,477],[85,86,87,88,89,96,158,166,170,173,175,176,177,189],[96,158,166,170,173,175,176,177,189,407,408,409],[96,158,166,170,173,175,176,177,189,407],[85,89,96,158,166,170,172,173,174,175,176,177,187,189,214,215,216,217,219,283,345,396,429,433,477],[96,158,166,170,173,175,176,177,189,443],[96,158,166,170,173,175,176,177,189,445],[96,158,166,170,173,175,176,177,189,447],[96,158,166,170,173,175,176,177,189,449],[96,158,166,170,173,175,176,177,189,451,452,453],[96,158,166,170,173,175,176,177,189,457],[90,92,96,158,166,170,173,175,176,177,189,435,440,442,444,446,448,450,454,458,460,468,469,471,480,481,482,483],[96,158,166,170,173,175,176,177,189,459],[96,158,166,170,173,175,176,177,189,467],[96,158,166,170,173,175,176,177,189,261],[96,158,166,170,173,175,176,177,189,470],[96,157,158,166,170,173,175,176,177,189,300,301,302,304,336,390,472,473,474,477,478,479],[96,158,166,170,173,175,176,177,189,214],[96,158,166,170,173,175,176,177,189,490],[96,158,159,166,170,173,175,176,177,189,194,488,489],[96,158,166,170,173,175,176,177,189,492],[96,158,166,170,173,175,176,177,189,491],[96,158,166,170,173,175,176,177,189,586],[96,158,166,170,173,175,176,177,189,497,526],[96,158,166,170,173,175,176,177,189,496,497],[96,158,166,170,173,175,176,177,189,194,214],[96,158,166,170,173,175,176,177,189,531],[96,111,114,117,118,158,166,170,173,175,176,177,189,206],[96,114,158,166,170,173,175,176,177,189,194,206],[96,114,118,158,166,170,173,175,176,177,189,206],[96,158,166,170,173,175,176,177,189,194],[96,108,158,166,170,173,175,176,177,189],[96,112,158,166,170,173,175,176,177,189],[96,110,111,114,158,166,170,173,175,176,177,189,206],[96,158,166,170,173,175,176,177,179,189,203],[96,108,158,166,170,173,175,176,177,189,214],[96,110,114,158,166,170,173,175,176,177,179,189,206],[96,105,106,107,109,113,158,166,169,170,173,175,176,177,189,194,206],[96,114,122,130,158,166,170,173,175,176,177,189],[96,106,112,158,166,170,173,175,176,177,189],[96,114,139,140,158,166,170,173,175,176,177,189],[96,106,109,114,158,166,170,173,175,176,177,189,197,206,214],[96,114,158,166,170,173,175,176,177,189],[96,110,114,158,166,170,173,175,176,177,189,206],[96,105,158,166,170,173,175,176,177,189],[96,108,109,110,112,113,114,115,116,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,140,141,142,143,144,158,166,170,173,175,176,177,189],[96,114,132,135,158,166,170,173,175,176,177,189],[96,114,122,123,124,158,166,170,173,175,176,177,189],[96,112,114,123,125,158,166,170,173,175,176,177,189],[96,113,158,166,170,173,175,176,177,189],[96,106,108,114,158,166,170,173,175,176,177,189],[96,114,118,123,125,158,166,170,173,175,176,177,189],[96,118,158,166,170,173,175,176,177,189],[96,112,114,117,158,166,170,173,175,176,177,189,206],[96,106,110,114,122,158,166,170,173,175,176,177,189],[96,114,132,158,166,170,173,175,176,177,189],[96,125,158,166,170,173,175,176,177,189],[96,108,114,139,158,166,170,173,175,176,177,189,197,212,214],[96,158,166,170,173,175,176,177,189,546,547],[96,158,166,170,173,175,176,177,189,546],[96,158,166,170,173,175,176,177,189,527,546,547,557],[96,158,166,169,170,172,173,174,175,176,177,179,189,194,203,206,213,214,497,498,499,500,501,502,503,523,524,525,526],[96,158,166,170,173,175,176,177,189,499,500,501,502],[96,158,166,170,173,175,176,177,189,499,500,501],[96,158,166,170,173,175,176,177,189,519],[96,158,166,170,173,175,176,177,189,517,519],[96,158,166,170,173,175,176,177,189,508,516,517,518,520,522],[96,158,166,170,173,175,176,177,189,506],[96,158,166,170,173,175,176,177,189,509,514,519,522],[96,158,166,170,173,175,176,177,189,505,522],[96,158,166,170,173,175,176,177,189,509,510,513,514,515,522],[96,158,166,170,173,175,176,177,189,509,510,511,513,514,522],[96,158,166,170,173,175,176,177,189,506,507,508,509,510,514,515,516,518,519,520,522],[96,158,166,170,173,175,176,177,189,522],[96,158,166,170,173,175,176,177,189,504,506,507,508,509,510,511,513,514,515,516,517,518,519,520,521],[96,158,166,170,173,175,176,177,189,504,522],[96,158,166,170,173,175,176,177,189,509,511,512,514,515,522],[96,158,166,170,173,175,176,177,189,513,522],[96,158,166,170,173,175,176,177,189,514,515,519,522],[96,158,166,170,173,175,176,177,189,507,517],[96,158,166,170,173,175,176,177,189,499],[96,158,166,170,173,175,176,177,189,500],[96,158,166,170,173,175,176,177,189,497],[96,158,166,170,173,175,176,177,189,558],[96,158,166,170,173,175,176,177,189,539,552,553,613],[96,158,166,170,173,175,176,177,189,528,539,543,544,613],[96,158,166,170,173,175,176,177,189,605],[96,158,166,170,173,175,176,177,189,194,527,528,533,539,540,543,545,548,549,550,551,554,555,557,600,613],[96,158,166,170,173,175,176,177,189,539,552,553,554,613],[96,158,166,170,173,175,176,177,189,527,556],[96,158,166,170,173,175,176,177,189,212,598],[96,158,166,170,173,175,176,177,189,539,540,543,545,548,613],[96,158,166,170,173,175,176,177,189,194,527,528,533,539,540,543,544,545,548,549,550,551,552,553,554,555,556,557,600,613],[96,158,166,170,173,175,176,177,189,194,212,527,528,533,536,539,540,543,544,545,548,549,550,551,552,553,554,555,556,557,598,599,600,602,603,605,606,607,612,613],[96,158,166,170,173,175,176,177,189,559,566]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"170d4db14678c68178ee8a3d5a990d5afb759ecb6ec44dbd885c50f6da6204f6","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"5e76305d58bcdc924ff2bf14f6a9dc2aa5441ed06464b7e7bd039e611d66a89b","impliedFormat":1},{"version":"acd8fd5090ac73902278889c38336ff3f48af6ba03aa665eb34a75e7ba1dccc4","impliedFormat":1},{"version":"d6258883868fb2680d2ca96bc8b1352cab69874581493e6d52680c5ffecdb6cc","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"f258e3960f324a956fc76a3d3d9e964fff2244ff5859dcc6ce5951e5413ca826","impliedFormat":1},{"version":"643f7232d07bf75e15bd8f658f664d6183a0efaca5eb84b48201c7671a266979","impliedFormat":1},{"version":"0f6666b58e9276ac3a38fdc80993d19208442d6027ab885580d93aec76b4ef00","impliedFormat":1},{"version":"05fd364b8ef02fb1e174fbac8b825bdb1e5a36a016997c8e421f5fab0a6da0a0","impliedFormat":1},{"version":"631eff75b0e35d1b1b31081d55209abc43e16b49426546ab5a9b40bdd40b1f60","impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"0ccdaa19852d25ecd84eec365c3bfa16e7859cadecf6e9ca6d0dbbbee439743f","affectsGlobalScope":true,"impliedFormat":1},{"version":"438b41419b1df9f1fbe33b5e1b18f5853432be205991d1b19f5b7f351675541e","affectsGlobalScope":true,"impliedFormat":1},{"version":"096116f8fedc1765d5bd6ef360c257b4a9048e5415054b3bf3c41b07f8951b0b","affectsGlobalScope":true,"impliedFormat":1},{"version":"e5e01375c9e124a83b52ee4b3244ed1a4d214a6cfb54ac73e164a823a4a7860a","affectsGlobalScope":true,"impliedFormat":1},{"version":"f90ae2bbce1505e67f2f6502392e318f5714bae82d2d969185c4a6cecc8af2fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"4b58e207b93a8f1c88bbf2a95ddc686ac83962b13830fe8ad3f404ffc7051fb4","affectsGlobalScope":true,"impliedFormat":1},{"version":"1fefabcb2b06736a66d2904074d56268753654805e829989a46a0161cd8412c5","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"c18a99f01eb788d849ad032b31cafd49de0b19e083fe775370834c5675d7df8e","affectsGlobalScope":true,"impliedFormat":1},{"version":"5247874c2a23b9a62d178ae84f2db6a1d54e6c9a2e7e057e178cc5eea13757fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"cdcf9ea426ad970f96ac930cd176d5c69c6c24eebd9fc580e1572d6c6a88f62c","impliedFormat":1},{"version":"23cd712e2ce083d68afe69224587438e5914b457b8acf87073c22494d706a3d0","impliedFormat":1},{"version":"487b694c3de27ddf4ad107d4007ad304d29effccf9800c8ae23c2093638d906a","impliedFormat":1},{"version":"3a80bc85f38526ca3b08007ee80712e7bb0601df178b23fbf0bf87036fce40ce","impliedFormat":1},{"version":"ccf4552357ce3c159ef75f0f0114e80401702228f1898bdc9402214c9499e8c0","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"68834d631c8838c715f225509cfc3927913b9cc7a4870460b5b60c8dbdb99baf","impliedFormat":1},{"version":"2931540c47ee0ff8a62860e61782eb17b155615db61e36986e54645ec67f67c2","impliedFormat":1},{"version":"ccab02f3920fc75c01174c47fcf67882a11daf16baf9e81701d0a94636e94556","impliedFormat":1},{"version":"f6faf5f74e4c4cc309a6c6a6c4da02dbb840be5d3e92905a23dcd7b2b0bd1986","impliedFormat":1},{"version":"ea6bc8de8b59f90a7a3960005fd01988f98fd0784e14bc6922dde2e93305ec7d","impliedFormat":1},{"version":"36107995674b29284a115e21a0618c4c2751b32a8766dd4cb3ba740308b16d59","impliedFormat":1},{"version":"914a0ae30d96d71915fc519ccb4efbf2b62c0ddfb3a3fc6129151076bc01dc60","impliedFormat":1},{"version":"33e981bf6376e939f99bd7f89abec757c64897d33c005036b9a10d9587d80187","impliedFormat":1},{"version":"7fd1b31fd35876b0aa650811c25ec2c97a3c6387e5473eb18004bed86cdd76b6","impliedFormat":1},{"version":"b41767d372275c154c7ea6c9d5449d9a741b8ce080f640155cc88ba1763e35b3","impliedFormat":1},{"version":"3bacf516d686d08682751a3bd2519ea3b8041a164bfb4f1d35728993e70a2426","impliedFormat":1},{"version":"7fb266686238369442bd1719bc0d7edd0199da4fb8540354e1ff7f16669b4323","impliedFormat":1},{"version":"0a60a292b89ca7218b8616f78e5bbd1c96b87e048849469cccb4355e98af959a","impliedFormat":1},{"version":"0b6e25234b4eec6ed96ab138d96eb70b135690d7dd01f3dd8a8ab291c35a683a","impliedFormat":1},{"version":"9666f2f84b985b62400d2e5ab0adae9ff44de9b2a34803c2c5bd3c8325b17dc0","impliedFormat":1},{"version":"40cd35c95e9cf22cfa5bd84e96408b6fcbca55295f4ff822390abb11afbc3dca","impliedFormat":1},{"version":"b1616b8959bf557feb16369c6124a97a0e74ed6f49d1df73bb4b9ddf68acf3f3","impliedFormat":1},{"version":"5b03a034c72146b61573aab280f295b015b9168470f2df05f6080a2122f9b4df","impliedFormat":1},{"version":"40b463c6766ca1b689bfcc46d26b5e295954f32ad43e37ee6953c0a677e4ae2b","impliedFormat":1},{"version":"249b9cab7f5d628b71308c7d9bb0a808b50b091e640ba3ed6e2d0516f4a8d91d","impliedFormat":1},{"version":"80aae6afc67faa5ac0b32b5b8bc8cc9f7fa299cff15cf09cc2e11fd28c6ae29e","impliedFormat":1},{"version":"f473cd2288991ff3221165dcf73cd5d24da30391f87e85b3dd4d0450c787a391","impliedFormat":1},{"version":"499e5b055a5aba1e1998f7311a6c441a369831c70905cc565ceac93c28083d53","impliedFormat":1},{"version":"54c3e2371e3d016469ad959697fd257e5621e16296fa67082c2575d0bf8eced0","impliedFormat":1},{"version":"beb8233b2c220cfa0feea31fbe9218d89fa02faa81ef744be8dce5acb89bb1fd","impliedFormat":1},{"version":"c183b931b68ad184bc8e8372bf663f3d33304772fb482f29fb91b3c391031f3e","impliedFormat":1},{"version":"5d0375ca7310efb77e3ef18d068d53784faf62705e0ad04569597ae0e755c401","impliedFormat":1},{"version":"59af37caec41ecf7b2e76059c9672a49e682c1a2aa6f9d7dc78878f53aa284d6","impliedFormat":1},{"version":"addf417b9eb3f938fddf8d81e96393a165e4be0d4a8b6402292f9c634b1cb00d","impliedFormat":1},{"version":"48cc3ec153b50985fb95153258a710782b25975b10dd4ac8a4f3920632d10790","impliedFormat":1},{"version":"adf27937dba6af9f08a68c5b1d3fce0ca7d4b960c57e6d6c844e7d1a8e53adae","impliedFormat":1},{"version":"e1528ca65ac90f6fa0e4a247eb656b4263c470bb22d9033e466463e13395e599","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"866078923a56d026e39243b4392e282c1c63159723996fa89243140e1388a98d","impliedFormat":1},{"version":"dd0109710de4cd93e245121ab86d8c66d20f3ead80074b68e9c3e349c4f53342","affectsGlobalScope":true,"impliedFormat":1},{"version":"b3275d55fac10b799c9546804126239baf020d220136163f763b55a74e50e750","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa68a0a3b7cb32c00e39ee3cd31f8f15b80cac97dce51b6ee7fc14a1e8deb30b","affectsGlobalScope":true,"impliedFormat":1},{"version":"1cf059eaf468efcc649f8cf6075d3cb98e9a35a0fe9c44419ec3d2f5428d7123","affectsGlobalScope":true,"impliedFormat":1},{"version":"6c36e755bced82df7fb6ce8169265d0a7bb046ab4e2cb6d0da0cb72b22033e89","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"7a93de4ff8a63bafe62ba86b89af1df0ccb5e40bb85b0c67d6bbcfdcf96bf3d4","affectsGlobalScope":true,"impliedFormat":1},{"version":"90e85f9bc549dfe2b5749b45fe734144e96cd5d04b38eae244028794e142a77e","affectsGlobalScope":true,"impliedFormat":1},{"version":"e0a5deeb610b2a50a6350bd23df6490036a1773a8a71d70f2f9549ab009e67ee","affectsGlobalScope":true,"impliedFormat":1},{"version":"435b3711465425770ed2ee2f1cf00ce071835265e0851a7dc4600ab4b007550e","impliedFormat":1},{"version":"7e49f52a159435fc8df4de9dc377ef5860732ca2dc9efec1640531d3cf5da7a3","impliedFormat":1},{"version":"dd4bde4bdc2e5394aed6855e98cf135dfdf5dd6468cad842e03116d31bbcc9bc","impliedFormat":1},{"version":"4d4e879009a84a47c05350b8dca823036ba3a29a3038efed1be76c9f81e45edf","affectsGlobalScope":true,"impliedFormat":1},{"version":"cf83d90d5faf27b994c2e79af02e32b555dbfe42cd9bd1571445f2168d1f4e2d","impliedFormat":1},{"version":"9ba13b47cb450a438e3076c4a3f6afb9dc85e17eae50f26d4b2d72c0688c9251","impliedFormat":1},{"version":"b64cd4401633ea4ecadfd700ddc8323a13b63b106ac7127c1d2726f32424622c","impliedFormat":1},{"version":"37c6e5fe5715814412b43cc9b50b24c67a63c4e04e753e0d1305970d65417a60","impliedFormat":1},{"version":"0e28335ac43f4d94dd2fe6d9e6fa6813570640839addd10d309d7985f33a6308","impliedFormat":1},{"version":"ee0e4946247f842c6dd483cbb60a5e6b484fee07996e3a7bc7343dfb68a04c5d","impliedFormat":1},{"version":"ef051f42b7e0ef5ca04552f54c4552eac84099d64b6c5ad0ef4033574b6035b8","impliedFormat":1},{"version":"853a43154f1d01b0173d9cbd74063507ece57170bad7a3b68f3fa1229ad0a92f","impliedFormat":1},{"version":"56231e3c39a031bfb0afb797690b20ed4537670c93c0318b72d5180833d98b72","impliedFormat":1},{"version":"5cc7c39031bfd8b00ad58f32143d59eb6ffc24f5d41a20931269011dccd36c5e","impliedFormat":1},{"version":"b0b69c61b0f0ec8ca15db4c8c41f6e77f4cacb784d42bca948f42dea33e8757e","affectsGlobalScope":true,"impliedFormat":1},{"version":"961cf7535b9c521cd634055b1b6ac49b94d055f0b573ce7fdc4cfaddab080b7c","impliedFormat":1},{"version":"806a8c6daae69e5695e7200d9eca6bc1e4298f38d90edda3ce67a794da31a24f","impliedFormat":1},{"version":"ac86245c2f31335bfd52cbe7fc760f9fc4f165387875869a478a6d9616a95e72","impliedFormat":1},{"version":"01ff95aa1443e3f7248974e5a771f513cb2ac158c8898f470a1792f817bee497","impliedFormat":1},{"version":"9d96a7ce809392ff2cb99691acf7c62e632fe56897356ba013b689277aca3619","impliedFormat":1},{"version":"42a05d8f239f74587d4926aba8cc54792eed8e8a442c7adc9b38b516642aadfe","impliedFormat":1},{"version":"5d21b58d60383cc6ab9ad3d3e265d7d25af24a2c9b506247e0e50b0a884920be","impliedFormat":1},{"version":"101f482fd48cb4c7c0468dcc6d62c843d842977aea6235644b1edd05e81fbf22","impliedFormat":1},{"version":"ae6757460f37078884b1571a3de3ebaf724d827d7e1d53626c02b3c2a408ac63","affectsGlobalScope":true,"impliedFormat":1},{"version":"27c0a08e343c6a0ae17bd13ba6d44a9758236dc904cd5e4b43456996cd51f520","impliedFormat":1},{"version":"3ef397f12387eff17f550bc484ea7c27d21d43816bbe609d495107f44b97e933","impliedFormat":1},{"version":"1023282e2ba810bc07905d3668349fbd37a26411f0c8f94a70ef3c05fe523fcf","impliedFormat":1},{"version":"b214ebcf76c51b115453f69729ee8aa7b7f8eccdae2a922b568a45c2d7ff52f7","impliedFormat":1},{"version":"429c9cdfa7d126255779efd7e6d9057ced2d69c81859bbab32073bad52e9ba76","impliedFormat":1},{"version":"6f80e51ba310608cd71bcdc09a171d7bbfb3b316048601c9ec215ce16a8dcfbc","impliedFormat":1},{"version":"a3bdc774995d56caaac759a424831091bb22450ca3590f34dae53d98323be191","affectsGlobalScope":true,"impliedFormat":1},{"version":"7f2c62938251b45715fd2a9887060ec4fbc8724727029d1cbce373747252bdd7","impliedFormat":1},{"version":"e3ace08b6bbd84655d41e244677b474fd995923ffef7149ddb68af8848b60b05","impliedFormat":1},{"version":"132580b0e86c48fab152bab850fc57a4b74fe915c8958d2ccb052b809a44b61c","impliedFormat":1},{"version":"af4ab0aa8908fc9a655bb833d3bc28e117c4f0e1038c5a891546158beb25accb","impliedFormat":1},{"version":"69c9a5a9392e8564bd81116e1ed93b13205201fb44cb35a7fde8c9f9e21c4b23","impliedFormat":1},{"version":"5f8fc37f8434691ffac1bfd8fc2634647da2c0e84253ab5d2dd19a7718915b35","impliedFormat":1},{"version":"5981c2340fd8b076cae8efbae818d42c11ffc615994cb060b1cd390795f1be2b","impliedFormat":1},{"version":"2ca2bca6845a7234eff5c3d192727a068fca72ac565f3c819c6b04ccc83dadc0","impliedFormat":1},{"version":"ed4f674fc8c0c993cc7e145069ac44129e03519b910c62be206a0cc777bdc60b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0250da3eb85c99624f974e77ef355cdf86f43980251bc371475c2b397ba55bcd","impliedFormat":1},{"version":"f1c93e046fb3d9b7f8249629f4b63dc068dd839b824dd0aa39a5e68476dc9420","impliedFormat":1},{"version":"3d3a5f27ffbc06c885dd4d5f9ee20de61faf877fe2c3a7051c4825903d9a7fdc","impliedFormat":1},{"version":"12806f9f085598ef930edaf2467a5fa1789a878fba077cd27e85dc5851e11834","impliedFormat":1},{"version":"17d06eb5709839c7ce719f0c38ada6f308fb433f2cd6d8c87b35856e07400950","impliedFormat":1},{"version":"a43fe41c33d0a192a0ecaf9b92e87bef3709c9972e6d53c42c49251ccb962d69","impliedFormat":1},{"version":"a177959203c017fad3ecc4f3d96c8757a840957a4959a3ae00dab9d35961ca6c","affectsGlobalScope":true,"impliedFormat":1},{"version":"6fc727ccf9b36e257ff982ea0badeffbfc2c151802f741bddff00c6af3b784cf","impliedFormat":1},{"version":"6c00f77f0335ae0c18bd45a6c7c9c97c9625fb7e5dd6d5936eadf70718bce52e","impliedFormat":1},{"version":"4844a4c9b4b1e812b257676ed8a80b3f3be0e29bf05e742cc2ea9c3c6865e6c6","impliedFormat":1},{"version":"064878a60367e0407c42fb7ba02a2ea4d83257357dc20088e549bd4d89433e9c","impliedFormat":1},{"version":"14d4bd22d1b05824971b98f7e91b2484c90f1a684805c330476641417c3d9735","impliedFormat":1},{"version":"586eaf66bace2e731cee0ddfbfac326ad74a83c1acfeac4afb2db85ad23226c7","impliedFormat":1},{"version":"b484ec11ba00e3a2235562a41898d55372ccabe607986c6fa4f4aba72093749f","impliedFormat":1},{"version":"d1a14d87cedcf4f0b8173720d6eb29cc02878bf2b6dabf9c9d9cee742f275368","impliedFormat":1},{"version":"e60efae9fe48a2955f66bf4cbf0f082516185b877daf50d9c5e2a009660a7714","impliedFormat":1},{"version":"041a7781b9127ab568d2cdcce62c58fdea7c7407f40b8c50045d7866a2727130","impliedFormat":1},{"version":"cd9189eacf0f9143b8830e9d6769335aa6d902c04195f04145bcbf19e7f26fcb","impliedFormat":1},{"version":"e1cb68f3ef3a8dd7b2a9dfb3de482ed6c0f1586ba0db4e7d73c1d2147b6ffc51","impliedFormat":1},{"version":"55cdbeebe76a1fa18bbd7e7bf73350a2173926bd3085bb050cf5a5397025ee4e","impliedFormat":1},{"version":"24259d3dae14de55d22f8b3d3e96954e5175a925ab6a830dc05a1993d4794eda","impliedFormat":1},{"version":"27e046d30d55669e9b5a325788a9b4073b05ce62607867754d2918af559a0877","impliedFormat":1},{"version":"3da0083607976261730c44908eab1b6262f727747ef3230a65ecd0153d9e8639","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"dd721e5707f241e4ef4ab36570d9e2a79f66aad63a339e3cbdbac7d9164d2431","impliedFormat":1},{"version":"24f8562308dd8ba6013120557fa7b44950b619610b2c6cb8784c79f11e3c4f90","impliedFormat":1},{"version":"3849a7f92d0e11b785f6ae7bedb25d9aad8d1234b3f1cf530a4e7404be26dd0a","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"57d6ac03382e30e9213641ff4f18cf9402bb246b77c13c8e848c0b1ca2b7ef92","impliedFormat":1},{"version":"f040772329d757ecd38479991101ef7bc9bf8d8f4dd8ee5d96fe00aa264f2a2b","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"57e47d02e88abef89d214cdf52b478104dc17997015746e288cbb580beaef266","impliedFormat":1},{"version":"04a2d0bd8166f057cc980608bd5898bfc91198636af3c1eb6cb4eb5e8652fbea","impliedFormat":1},{"version":"376c21ad92ca004531807ea4498f90a740fd04598b45a19335a865408180eddd","impliedFormat":1},{"version":"9e2739b32f741859263fdba0244c194ca8e96da49b430377930b8f721d77c000","impliedFormat":1},{"version":"a9af0e608929aaf9ce96bd7a7b99c9360636c31d73670e4af09a09950df97841","impliedFormat":1},{"version":"48d37b90a04e753a925228f50304d02c4f95d57bf682f8bb688621c3cd9d32ec","impliedFormat":1},{"version":"361e2b13c6765d7f85bb7600b48fde782b90c7c41105b7dab1f6e7871071ba20","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"b6db56e4903e9c32e533b78ac85522de734b3d3a8541bf24d256058d464bf04b","impliedFormat":1},{"version":"24daa0366f837d22c94a5c0bad5bf1fd0f6b29e1fae92dc47c3072c3fdb2fbd5","impliedFormat":1},{"version":"b68c4ed987ef5693d3dccd85222d60769463aca404f2ffca1c4c42781dce388e","impliedFormat":1},{"version":"cfb5b5d514eb4ad0ee25f313b197f3baa493eee31f27613facd71efb68206720","impliedFormat":1},{"version":"65f43099ded6073336e697512d9b80f2d4fec3182b7b2316abf712e84104db00","impliedFormat":1},{"version":"3e7efde639c6a6c3edb9847b3f61e308bf7a69685b92f665048c45132f51c218","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"9715fe982fccf375c88ac4d3cc8f6a126a7b7596be8d60190a0c7d22b45b4be4","impliedFormat":1},{"version":"1fe24e25a00c7dd689cb8c0fb4f1048b4a6d1c50f76aaca2ca5c6cdb44e01442","impliedFormat":1},{"version":"672f293c53a07b8c1c1940797cd5c7984482a0df3dd9c1f14aaee8d3474c2d83","impliedFormat":1},{"version":"0a66cb2511fa8e3e0e6ba9c09923f664a0a00896f486e6f09fc11ff806a12b0c","impliedFormat":1},{"version":"d703f98676a44f90d63b3ffc791faac42c2af0dd2b4a312f4afdb5db471df3de","impliedFormat":1},{"version":"0cfe1d0b90d24f5c105db5a2117192d082f7d048801d22a9ea5c62fae07b80a0","impliedFormat":1},{"version":"ef61792acbfa8c27c9bd113f02731e66229f7d3a169e3c1993b508134f1a58e0","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"3ccf230b00deed31582c84b968cb3a977dae3b3446107d7aa790efaa079c06ac","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"5c2e5ca7d53236bbf483a81ae283e2695e291fe69490cd139b33fa9e71838a69","impliedFormat":1},{"version":"a73bee51e3820392023252c36348e62dd72e6bae30a345166e9c78360f1aba7e","impliedFormat":1},{"version":"6ea68b3b7d342d1716cc4293813410d3f09ff1d1ca4be14c42e6d51e810962e1","impliedFormat":1},{"version":"c319e82ac16a5a5da9e28dfdefdad72cebb5e1e67cbdcc63cce8ae86be1e454f","impliedFormat":1},{"version":"a23185bc5ef590c287c28a91baf280367b50ae4ea40327366ad01f6f4a8edbc5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"0c7c947ff881c4274c0800deaa0086971e0bfe51f89a33bd3048eaa3792d4876","affectsGlobalScope":true,"impliedFormat":1},{"version":"db01d18853469bcb5601b9fc9826931cc84cc1a1944b33cad76fd6f1e3d8c544","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"a020158a317c07774393974d26723af551e569f1ba4d6524e8e245f10e11b976","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"15b36126e0089bfef173ab61329e8286ce74af5e809d8a72edcafd0cc049057f","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"13283350547389802aa35d9f2188effaeac805499169a06ef5cd77ce2a0bd63f","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"d07cbc787a997d83f7bde3877fec5fb5b12ce8c1b7047eb792996ed9726b4dde","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"8bba776476c48b0e319d243f353190f24096057acede3c2f620fee17ff885dba","impliedFormat":1},{"version":"a3abe92070fbd33714bd837806030b39cfb1f8283a98c7c1f55fffeea388809e","impliedFormat":1},{"version":"ceb6696b98a72f2dae802260c5b0940ea338de65edd372ff9e13ab0a410c3a88","impliedFormat":1},{"version":"2cd914e04d403bdc7263074c63168335d44ce9367e8a74f6896c77d4d26a1038","impliedFormat":1},{"version":"ac60bbee0d4235643cc52b57768b22de8c257c12bd8c2039860540cab1fa1d82","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"208c9af9429dd3c76f5927b971263174aaa4bc7621ddec63f163640cbd3c473c","impliedFormat":1},{"version":"20865ac316b8893c1a0cc383ccfc1801443fbcc2a7255be166cf90d03fac88c9","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"3bc8605900fd1668f6d93ce8e14386478b6caa6fda41be633ee0fe4d0c716e62","impliedFormat":1},{"version":"461d0ad8ae5f2ff981778af912ba71b37a8426a33301daa00f21c6ccb27f8156","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"fcafff163ca5e66d3b87126e756e1b6dfa8c526aa9cd2a2b0a9da837d81bbd72","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"45490817629431853543adcb91c0673c25af52a456479588b6486daba34f68bb","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","impliedFormat":1},{"version":"9f31420a5040dbfb49ab94bcaaa5103a9a464e607cabe288958f53303f1da32e","impliedFormat":1},{"version":"6124e973eab8c52cabf3c07575204efc1784aca6b0a30c79eb85fe240a857efa","impliedFormat":1},{"version":"0d891735a21edc75df51f3eb995e18149e119d1ce22fd40db2b260c5960b914e","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"f11d0dcaa4a1cba6d6513b04ceb31a262f223f56e18b289c0ba3133b4d3cd9a6","impliedFormat":1},{"version":"0a437ae178f999b46b6153d79095b60c42c996bc0458c04955f1c996dc68b971","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"4a7baeb6325920044f66c0f8e5e6f1f52e06e6d87588d837bdf44feb6f35c664","impliedFormat":1},{"version":"6dcf60530c25194a9ee0962230e874ff29d34c59605d8e069a49928759a17e0a","impliedFormat":1},{"version":"56013416784a6b754f3855f8f2bf6ce132320679b8a435389aca0361bce4df6b","impliedFormat":1},{"version":"43e96a3d5d1411ab40ba2f61d6a3192e58177bcf3b133a80ad2a16591611726d","impliedFormat":1},{"version":"9c066f3b46cf016e5d072b464821c5b21cc9adcc44743de0f6c75e2509a357ab","impliedFormat":1},{"version":"002eae065e6960458bda3cf695e578b0d1e2785523476f8a9170b103c709cd4f","impliedFormat":1},{"version":"c51641ab4bfa31b7a50a0ca37edff67f56fab3149881024345b13f2b48b7d2de","impliedFormat":1},{"version":"a57b1802794433adec9ff3fed12aa79d671faed86c49b09e02e1ac41b4f1d33a","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"52abbd5035a97ebfb4240ec8ade2741229a7c26450c84eb73490dc5ea048b911","impliedFormat":1},{"version":"1042064ece5bb47d6aba91648fbe0635c17c600ebdf567588b4ca715602f0a9d","impliedFormat":1},{"version":"4360ad4de54de2d5c642c4375d5eab0e7fe94ebe8adca907e6c186bbef75a54d","impliedFormat":1},{"version":"c338dff3233675f87a3869417aaea8b8bf590505106d38907dc1d0144f6402ef","impliedFormat":1},{"version":"7bb79aa2fead87d9d56294ef71e056487e848d7b550c9a367523ee5416c44cfa","impliedFormat":1},{"version":"9c9cae45dc94c2192c7d25f80649414fa13c425d0399a2c7cb2b979e4e50af42","impliedFormat":1},{"version":"068f063c2420b20f8845afadb38a14c640aed6bb01063df224edb24af92b4550","impliedFormat":1},{"version":"27ff4196654e6373c9af16b6165120e2dd2169f9ad6abb5c935af5abd8c7938c","impliedFormat":1},{"version":"b8719d4483ebef35e9cb67cd5677b7e0103cf2ed8973df6aba6fdd02896ddc6e","impliedFormat":1},{"version":"643672ce383e1c58ea665a92c5481f8441edbd3e91db36e535abccbc9035adeb","impliedFormat":1},{"version":"6dd9bcf10678b889842d467706836a0ab42e6c58711e33918ed127073807ee65","impliedFormat":1},{"version":"8fa022ea514ce0ea78ac9b7092a9f97f08ead20c839c779891019e110fce8307","impliedFormat":1},{"version":"c93235337600b786fd7d0ff9c71a00f37ca65c4d63e5d695fc75153be2690f09","impliedFormat":1},{"version":"10179c817a384983f6925f778a2dac2c9427817f7d79e27d3e9b1c8d0564f1f4","impliedFormat":1},{"version":"ce791f6ea807560f08065d1af6014581eeb54a05abd73294777a281b6dfd73c2","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"42c169fb8c2d42f4f668c624a9a11e719d5d07dacbebb63cbcf7ef365b0a75b3","impliedFormat":1},{"version":"c0a666b005521f52e2db0b685d659d7ee9b0b60bc0d347dfc5e826c7957bdb83","impliedFormat":1},{"version":"807d38d00ce6ab9395380c0f64e52f2f158cc804ac22745d8f05f0efdec87c33","impliedFormat":1},{"version":"ce0df82a9ae6f914ba08409d4d883983cc08e6d59eb2df02d8e4d68309e7848b","impliedFormat":1},{"version":"796273b2edc72e78a04e86d7c58ae94d370ab93a0ddf40b1aa85a37a1c29ecd7","impliedFormat":1},{"version":"5df15a69187d737d6d8d066e189ae4f97e41f4d53712a46b2710ff9f8563ec9f","impliedFormat":1},{"version":"e17cd049a1448de4944800399daa4a64c5db8657cc9be7ef46be66e2a2cd0e7c","impliedFormat":1},{"version":"d05fb434f4ba073aed74b6c62eff1723c835de2a963dbb091e000a2decb5a691","impliedFormat":1},{"version":"10e6166be454ddb8c81000019ce1069b476b478c316e7c25965a91904ec5c1e3","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"4d4927cbee21750904af7acf940c5e3c491b4d5ebc676530211e389dd375607a","impliedFormat":1},{"version":"72105519d0390262cf0abe84cf41c926ade0ff475d35eb21307b2f94de985778","impliedFormat":1},{"version":"703989a003790524b4e34a1758941d05c121d5d352bccca55a5cfb0c76bca592","impliedFormat":1},{"version":"a58abf1f5c8feb335475097abeddd32fd71c4dc2065a3d28cf15cacabad9654a","impliedFormat":1},{"version":"ccf6dd45b708fb74ba9ed0f2478d4eb9195c9dfef0ff83a6092fa3cf2ff53b4f","impliedFormat":1},{"version":"2d7db1d73456e8c5075387d4240c29a2a900847f9c1bff106a2e490da8fbd457","impliedFormat":1},{"version":"2b15c805f48e4e970f8ec0b1915f22d13ca6212375e8987663e2ef5f0205e832","impliedFormat":1},{"version":"671aeae7130038566a8d00affeb1b3e3b131edf93cbcfff6f55ed68f1ca4c1b3","impliedFormat":1},{"version":"f0f05149debcf31b3a717ce8dd16e0323a789905cb9e27239167b604153b8885","impliedFormat":1},{"version":"35069c2c417bd7443ae7c7cafd1de02f665bf015479fec998985ffbbf500628c","impliedFormat":1},{"version":"955c69dde189d5f47a886ed454ff50c69d4d8aaec3a454c9ab9c3551db727861","impliedFormat":1},{"version":"cec8b16ff98600e4f6777d1e1d4ddf815a5556a9c59bc08cc16db4fd4ae2cf00","impliedFormat":1},{"version":"9e21f8e2c0cfea713a4a372f284b60089c0841eb90bf3610539d89dbcd12d65a","impliedFormat":1},{"version":"045b752f44bf9bbdcaffd882424ab0e15cb8d11fa94e1448942e338c8ef19fba","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"c226288bda11cee97850f0149cc4ff5a244d42ed3f5a9f6e9b02f1162bf1e3f4","impliedFormat":1},{"version":"210a4ec6fd58f6c0358e68f69501a74aef547c82deb920c1dec7fa04f737915a","impliedFormat":1},{"version":"8eea4cc42d04d26bcbcaf209366956e9f7abaf56b0601c101016bb773730c5fe","impliedFormat":1},{"version":"f5319e38724c54dff74ee734950926a745c203dcce00bb0343cb08fbb2f6b546","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"12b8dfed70961bea1861e5d39e433580e71323abb5d33da6605182ec569db584","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"7e560f533aaf88cf9d3b427dcf6c112dd3f2ee26d610e2587583b6c354c753db","impliedFormat":1},{"version":"71e0082342008e4dfb43202df85ea0986ef8e003c921a1e49999d0234a3019da","impliedFormat":1},{"version":"27ab780875bcbb65e09da7496f2ca36288b0c541abaa75c311450a077d54ec15","impliedFormat":1},{"version":"2652448ac55a2010a1f71dd141f828b682298d39728f9871e1cdf8696ef443fd","impliedFormat":1},{"version":"e71e103fb212e015394def7f1379706fce637fec9f91aa88410a73b7c5cbd4e3","impliedFormat":1},{"version":"120599fd965257b1f4d0ff794bc696162832d9d8467224f4665f713a3119078b","impliedFormat":1},{"version":"5433f33b0a20300cca35d2f229a7fc20b0e8477c44be2affeb21cb464af60c76","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"bd4131091b773973ca5d2326c60b789ab1f5e02d8843b3587effe6e1ea7c9d86","impliedFormat":1},{"version":"794998dc1c5a19ce77a75086fe829fb9c92f2fd07b5631c7d5e0d04fd9bc540c","impliedFormat":1},{"version":"2b0b12d0ee52373b1e7b09226eae8fbf6a2043916b7c19e2c39b15243f32bde2","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"0427df5c06fafc5fe126d14b9becd24160a288deff40e838bfbd92a35f8d0d00","impliedFormat":1},{"version":"bdc5fd605a6d315ded648abf2c691a22d0b0c774b78c15512c40ddf138e51950","impliedFormat":1},{"version":"49c346823ba6d4b12278c12c977fb3a31c06b9ca719015978cb145eb86da1c61","impliedFormat":1},{"version":"bfac6e50eaa7e73bb66b7e052c38fdc8ccfc8dbde2777648642af33cf349f7f1","impliedFormat":1},{"version":"92f7c1a4da7fbfd67a2228d1687d5c2e1faa0ba865a94d3550a3941d7527a45d","impliedFormat":1},{"version":"f53b120213a9289d9a26f5af90c4c686dd71d91487a0aa5451a38366c70dc64b","impliedFormat":1},{"version":"6cd4b0986c638d92f7204d1407b1cb3e0a79d7a2d23b0f141c1a0829540ce7ef","impliedFormat":1},{"version":"57d67b72e06059adc5e9454de26bbfe567d412b962a501d263c75c2db430f40e","impliedFormat":1},{"version":"6511e4503cf74c469c60aafd6589e4d14d5eb0a25f9bf043dcbecdf65f261972","impliedFormat":1},{"version":"d58265e159fc3cb30aa8878ba5e986a314b1759c824ff66d777b9fe42117231a","impliedFormat":1},{"version":"ff8fccaae640b0bb364340216dcc7423e55b6bb182ca2334837fee38636ad32e","impliedFormat":1},{"version":"a67b87d0281c97dfc1197ef28dfe397fc2c865ccd41f7e32b53f647184cc7307","impliedFormat":1},{"version":"771ffb773f1ddd562492a6b9aaca648192ac3f056f0e1d997678ff97dbb6bf9b","impliedFormat":1},{"version":"232f70c0cf2b432f3a6e56a8dc3417103eb162292a9fd376d51a3a9ea5fbbf6f","impliedFormat":1},{"version":"59ee66cf96b093b18c90a8f6dbb3f0e3b65c758fba7b8b980af9f2726c32c1a2","impliedFormat":1},{"version":"c590195790d7fa35b4abed577a605d283b8336b9e01fa9bf4ae4be49855940f9","impliedFormat":1},{"version":"8a0e762ceb20c7e72504feef83d709468a70af4abccb304f32d6b9bac1129b2c","impliedFormat":1},{"version":"026a43d8239b8f12d2fc4fa5a7acbc2ad06dd989d8c71286d791d9f57ca22b78","impliedFormat":1},{"version":"9252d498a77517aab5d8d4b5eb9d71e4b225bbc7123df9713e08181de63180f6","impliedFormat":1},{"version":"14cf3683955f914b4695e92c93aae5f3fe1e60f3321d712605164bfe53b34334","impliedFormat":1},{"version":"12f0fb50e28b9d48fe5b7580580efe7cc0bd38e4b8c02d21c175aa9a4fd839b0","impliedFormat":1},{"version":"1fffe726740f9787f15b532e1dc870af3cd964dbe29e191e76121aa3dd8693f2","impliedFormat":1},{"version":"7cd657e359eac7829db5f02c856993e8945ffccc71999cdfb4ab3bf801a1bbc6","impliedFormat":1},{"version":"1a82deef4c1d39f6882f28d275cad4c01f907b9b39be9cbc472fcf2cf051e05b","impliedFormat":1},{"version":"4b20fcf10a5413680e39f5666464859fc56b1003e7dfe2405ced82371ebd49b6","impliedFormat":1},{"version":"29c2aa0712786a4a504fce3acd50928f086027276f7490965cb467d2ce638bae","impliedFormat":1},{"version":"f14e63395b54caecc486f00a39953ab00b7e4d428a4e2c38325154b08eb5dcc2","impliedFormat":1},{"version":"e749bbd37dadf82c9833278780527c717226e1e2c9bc7b2576c8ec1c40ec5647","impliedFormat":1},{"version":"46e4e179b295f08d0bd0176fe44cf6c89558c9091d3cb3894f9eaaa42ea1add1","impliedFormat":1},{"version":"1101ceda2dfd8e0c7ae87cda8053533a187ecc58c5ef72074afb97d2bf4daa08","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"bb37588926aba35c9283fe8d46ebf4e79ffe976343105f5c6d45f282793352b2","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"72179f9dd22a86deaad4cc3490eb0fe69ee084d503b686985965654013f1391b","impliedFormat":1},{"version":"2e6114a7dd6feeef85b2c80120fdbfb59a5529c0dcc5bfa8447b6996c97a69f5","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"29164fb428c851bc35b632761daad3ae075993a0bf9c43e9e3bc6468b32d9aa5","impliedFormat":1},{"version":"3c01539405051bffccacffd617254c8d0f665cdce00ec568c6f66ccb712b734f","impliedFormat":1},{"version":"ef9021bdfe54f4df005d0b81170bd2da9bfd86ef552cde2a049ba85c9649658f","impliedFormat":1},{"version":"17a1a0d1c492d73017c6e9a8feb79e9c8a2d41ef08b0fe51debc093a0b2e9459","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"50256e9c31318487f3752b7ac12ff365c8949953e04568009c8705db802776fb","impliedFormat":1},{"version":"96e1caae9b78cde35c62fee46c1ec9fa5f12c16bc1e2ab08d48e5921e29a6958","impliedFormat":1},{"version":"8de9fe97fa9e00ec00666fa77ab6e91b35d25af8ca75dabcb01e14ad3299b150","impliedFormat":1},{"version":"9e0327857503a958348d9e8e9dd57ed155a1e6ec0071eb5eb946fe06ccdf7680","impliedFormat":1},{"version":"6c800b281b9e89e69165fd11536195488de3ff53004e55905e6c0059a2d8591e","impliedFormat":1},{"version":"01aa917531e116485beca44a14970834687b857757159769c16b228eb1e49c5f","impliedFormat":1},{"version":"397f568f996f8ffcf12d9156342552b0da42f6571eadba6bce61c99e1651977d","impliedFormat":1},{"version":"e2fd426f3cbc5bbff7860378784037c8fa9c1644785eed83c47c902b99b6cda9","impliedFormat":1},{"version":"d663134457d8d669ae0df34eabd57028bddc04fc444c4bc04bc5215afc91e1f4","impliedFormat":1},{"version":"a52674bc98da7979607e0f44d4c015c59c1b1d264c83fc50ec79ff2cfea06723","impliedFormat":1},{"version":"bcca16e60015db8bbf6bd117e88c5f7269337aebb05fc2b0701ae658a458c9c3","impliedFormat":1},{"version":"5e1246644fab20200cdc7c66348f3c861772669e945f2888ef58b461b81e1cd8","impliedFormat":1},{"version":"eb39550e2485298d91099e8ab2a1f7b32777d9a5ba34e9028ea8df2e64891172","impliedFormat":1},{"version":"e108f38a04a607f9386d68a4c6f3fdae1b712960f11f6482c6f1769bab056c2e","impliedFormat":1},{"version":"a3128a84a9568762a2996df79717d92154d18dd894681fc0ab3a098fa7f8ee3b","affectsGlobalScope":true,"impliedFormat":1},{"version":"347791f3792f436950396dd6171d6450234358001ae7c94ca209f1406566ccbf","impliedFormat":1},{"version":"dd80b1e600d00f5c6a6ba23f455b84a7db121219e68f89f10552c54ba46e4dc9","impliedFormat":1},{"version":"714d8ebb298c7acc9bd1f34bd479c57d12b73371078a0c5a1883a68b8f1b9389","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"51bf55bb6eb80f11b3aa59fb0a9571565a7ea304a19381f6da5630f4b2e206c4","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"98a787be42bd92f8c2a37d7df5f13e5992da0d967fab794adbb7ee18370f9849","impliedFormat":1},{"version":"02f8ef78d46c5b27f108dbb56709daa0aff625c20247abb0e6bb67cd73439f9f","impliedFormat":1},{"version":"b7fff2d004c5879cae335db8f954eb1d61242d9f2d28515e67902032723caeab","impliedFormat":1},{"version":"5f3dc10ae646f375776b4e028d2bed039a93eebbba105694d8b910feebbe8b9c","impliedFormat":1},{"version":"bb0cd7862b72f5eba39909c9889d566e198fcaddf7207c16737d0c2246112678","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"6812502cc640de74782ce9121592ae3765deb1c5c8e795b179736b308dd65e90","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"2b664c3cc544d0e35276e1fb2d4989f7d4b4027ffc64da34ec83a6ccf2e5c528","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"bad68fd0401eb90fe7da408565c8aee9c7a7021c2577aec92fa1382e8876071a","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"fec01479923e169fb52bd4f668dbeef1d7a7ea6e6d491e15617b46f2cacfa37d","impliedFormat":1},{"version":"8a8fb3097ba52f0ae6530ec6ab34e43e316506eb1d9aa29420a4b1e92a81442d","impliedFormat":1},{"version":"44e09c831fefb6fe59b8e65ad8f68a7ecc0e708d152cfcbe7ba6d6080c31c61e","impliedFormat":1},{"version":"1c0a98de1323051010ce5b958ad47bc1c007f7921973123c999300e2b7b0ecc0","impliedFormat":1},{"version":"b10bc147143031b250dc36815fd835543f67278245bf2d0a46dca765f215124e","impliedFormat":1},{"version":"87affad8e2243635d3a191fa72ef896842748d812e973b7510a55c6200b3c2a4","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"1e4c6ac595b6d734c056ac285b9ee50d27a2c7afe7d15bd14ed16210e71593b0","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"330896c1a2b9693edd617be24fbf9e5895d6e18c7955d6c08f028f272b37314d","impliedFormat":1},{"version":"1d9c0a9a6df4e8f29dc84c25c5aa0bb1da5456ebede7a03e03df08bb8b27bae6","impliedFormat":1},{"version":"84380af21da938a567c65ef95aefb5354f676368ee1a1cbb4cae81604a4c7d17","impliedFormat":1},{"version":"1af3e1f2a5d1332e136f8b0b95c0e6c0a02aaabd5092b36b64f3042a03debf28","impliedFormat":1},{"version":"3c7b3aecd652169787b3c512d8f274a3511c475f84dcd6cead164e40cad64480","impliedFormat":1},{"version":"9a01f12466488eccd8d9eafc8fecb9926c175a4bf4a8f73a07c3bcf8b3363282","impliedFormat":1},{"version":"b80f624162276f24a4ec78b8e86fbee80ca255938e12f8b58e7a8f1a6937120b","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"5bf5c7a44e779790d1eb54c234b668b15e34affa95e78eada73e5757f61ed76a","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"5c634644d45a1b6bc7b05e71e05e52ec04f3d73d9ac85d5927f647a5f965181a","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"b95a6f019095dd1d48fd04965b50dfd63e5743a6e75478343c46d2582a5132bf","impliedFormat":99},{"version":"c2008605e78208cfa9cd70bd29856b72dda7ad89df5dc895920f8e10bcb9cd0a","impliedFormat":99},{"version":"b97cb5616d2ab82a98ec9ada7b9e9cabb1f5da880ec50ea2b8dc5baa4cbf3c16","impliedFormat":99},{"version":"63a7595a5015e65262557f883463f934904959da563b4f788306f699411e9bac","impliedFormat":1},{"version":"4ba137d6553965703b6b55fd2000b4e07ba365f8caeb0359162ad7247f9707a6","impliedFormat":1},{"version":"00b0f43b3770f66aa1e105327980c0ff17a868d0e5d9f5689f15f8d6bf4fb1f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"272a7e7dbe05e8aaba1662ef1a16bbd57975cc352648b24e7a61b7798f3a0ad7","affectsGlobalScope":true,"impliedFormat":1},{"version":"a1219ee18b9282b4c6a31f1f0bcc9255b425e99363268ba6752a932cf76662f0","impliedFormat":1},{"version":"3dc14e1ab45e497e5d5e4295271d54ff689aeae00b4277979fdd10fa563540ae","impliedFormat":1},{"version":"1d63055b690a582006435ddd3aa9c03aac16a696fac77ce2ed808f3e5a06efab","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},"f2b3bca04d1bfe583daae1e1f798c92ec24bb6693bd88d0a09ba6802dee362a8","8fce868229bd1f0d7fcf841b3623b75c2b85c061b0fbe96975f72cad94b6d8ef",{"version":"b34209befaf07b7cc1932e5cc137ce121cbc9f892551126962d9e908be91adb4","impliedFormat":1},{"version":"32727845ab5bd8a9ef3e4844c567c09f6d418fcf0f90d381c00652a6f23e7f6e","impliedFormat":1},{"version":"2c0b5ace721ddf7314b622bbad664a9958cfd1068422dbed5cdb760cba1c7f0c","impliedFormat":1},{"version":"7a8ec10b0834eb7183e4bfcd929838ac77583828e343211bb73676d1e47f6f01","impliedFormat":1},{"version":"707332817d714a4277d2d386d9c209cdb2137313284c65621849a12f413aaf5e","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f00324f263189b385c3a9383b1f4dae6237697bcf0801f96aa35c340512d79c","impliedFormat":1},{"version":"ec8997c2e5cea26befc76e7bf990750e96babb16977673a9ff3b5c0575d01e48","impliedFormat":1},"e87d391c83bd371375a0266ad508b9cf450335259535bc06e8552445719d637a",{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"4e741b9c88e80c9e4cedf07b5a698e8e3a3bd73cf649f664d6dd3f868c05c2f3","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"8885cf05f3e2abf117590bbb951dcf6359e3e5ac462af1c901cfd24c6a6472e2","impliedFormat":1},{"version":"333caa2bfff7f06017f114de738050dd99a765c7eb16571c6d25a38c0d5365dc","impliedFormat":1},{"version":"e61df3640a38d535fd4bc9f4a53aef17c296b58dc4b6394fd576b808dd2fe5e6","impliedFormat":1},{"version":"459920181700cec8cbdf2a5faca127f3f17fd8dd9d9e577ed3f5f3af5d12a2e4","impliedFormat":1},{"version":"4719c209b9c00b579553859407a7e5dcfaa1c472994bd62aa5dd3cc0757eb077","impliedFormat":1},{"version":"7ec359bbc29b69d4063fe7dad0baaf35f1856f914db16b3f4f6e3e1bca4099fa","impliedFormat":1},{"version":"70790a7f0040993ca66ab8a07a059a0f8256e7bb57d968ae945f696cbff4ac7a","impliedFormat":1},{"version":"d1b9a81e99a0050ca7f2d98d7eedc6cda768f0eb9fa90b602e7107433e64c04c","impliedFormat":1},{"version":"a022503e75d6953d0e82c2c564508a5c7f8556fad5d7f971372d2d40479e4034","impliedFormat":1},{"version":"b215c4f0096f108020f666ffcc1f072c81e9f2f95464e894a5d5f34c5ea2a8b1","impliedFormat":1},{"version":"644491cde678bd462bb922c1d0cfab8f17d626b195ccb7f008612dc31f445d2d","impliedFormat":1},{"version":"dfe54dab1fa4961a6bcfba68c4ca955f8b5bbeb5f2ab3c915aa7adaa2eabc03a","impliedFormat":1},{"version":"1251d53755b03cde02466064260bb88fd83c30006a46395b7d9167340bc59b73","impliedFormat":1},{"version":"47865c5e695a382a916b1eedda1b6523145426e48a2eae4647e96b3b5e52024f","impliedFormat":1},{"version":"4cdf27e29feae6c7826cdd5c91751cc35559125e8304f9e7aed8faef97dcf572","impliedFormat":1},{"version":"331b8f71bfae1df25d564f5ea9ee65a0d847c4a94baa45925b6f38c55c7039bf","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"183f480885db5caa5a8acb833c2be04f98056bdcc5fb29e969ff86e07efe57ab","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"d2e64a6f25013b099e83bfadb2c388d7bef3e8f3fdb25528225bbc841e7e7e3a","impliedFormat":99},{"version":"369ba5259e66ca8c7d35e3234f7a2a0863a770fdb8266505747c65cf346a0804","impliedFormat":99},{"version":"64d984f55025daf604f670b7dfd090ea765f2098aee871174ef2ee3e94479098","impliedFormat":99},{"version":"f147b6710441cf3ec3234adf63b0593ce5e8c9b692959d21d3babc8454bcf743","impliedFormat":99},{"version":"e96d5373a66c2cfbbc7e6642cf274055aa2c7ff6bd37be7480c66faf9804db6d","impliedFormat":99},{"version":"02bcdd7a76c5c1c485cbf05626d24c86ac8f9a1d8dc31f8924108bbaa4cf3ba9","impliedFormat":99},{"version":"c874ab6feac6e0fdf9142727c9a876065777a5392f14b0bbcf869b1e69eb46b5","impliedFormat":99},{"version":"7c553fc9e34773ddbaabe0fa1367d4b109101d0868a008f11042bee24b5a925d","impliedFormat":99},{"version":"9962ce696fbdce2421d883ca4b062a54f982496625437ae4d3633376c5ad4a80","impliedFormat":99},{"version":"e3ea467c4a7f743f3548c9ed61300591965b1d12c08c8bb9aaff8a002ba95fce","impliedFormat":99},{"version":"4c17183a07a63bea2653fbfc0a942b027160ddbee823024789a415f9589de327","impliedFormat":99},{"version":"3e2203c892297ea44b87470fde51b3d48cfe3eeb6901995de429539462894464","impliedFormat":99},{"version":"c84bf7a4abc5e7fdf45971a71b25b0e0d34ccd5e720a866dd78bb71d60d41a3f","impliedFormat":99},{"version":"e01ea380015ed698c3c0e2ccd0db72f3fc3ef1abc4519f122aa1c1a8d419a505","impliedFormat":99},{"version":"5ada1f8a9580c0f7478fe03ae3e07e958f0b79bdfb9dd50eeb98c1324f40011b","impliedFormat":99},{"version":"a8301dc90b4bd9fba333226ee0f1681aeeff1bd90233a8f647e687cb4b7d3521","impliedFormat":99},{"version":"e3225dc0bec183183509d290f641786245e6652bc3dce755f7ef404060693c35","impliedFormat":99},{"version":"09a03870ed8c55d7453bc9ad684df88965f2f770f987481ca71b8a09be5205bc","impliedFormat":99},{"version":"e6233e1c976265e85aa8ad76c3881febe6264cb06ae3136f0257e1eab4a6cc5a","impliedFormat":99},{"version":"2cdd50ddc49e2d608ee848fc4ab0db9a2716624fabb4209c7c683d87e54d79c5","impliedFormat":99},{"version":"e431d664338b8470abb1750d699c7dfcebb1a25434559ef85bb96f1e82de5972","impliedFormat":99},{"version":"2c4254139d037c3caca66ce291c1308c1b5092cfcb151eb25980db932dd3b01a","impliedFormat":99},{"version":"970ae00ed018cb96352dc3f37355ef9c2d9f8aa94d7174ccd6d0ed855e462097","impliedFormat":99},{"version":"d2f8dee457ef7660b604226d471d55d927c3051766bdd80353553837492635c3","impliedFormat":99},{"version":"110a503289a2ef76141ffff3ffceb9a1c3662c32748eb9f6777a2bd0866d6fb1","impliedFormat":99},{"version":"69bf2422313487956e4dacf049f30cb91b34968912058d244cb19e4baa24da97","impliedFormat":99},{"version":"310e6b62c493ce991624169a1c1904015769d947be88dc67e00adc7ebebcfa87","impliedFormat":99},{"version":"62fefda288160bf6e435b21cc03d3fbac11193d8d3bd0e82d86623cca7691c29","impliedFormat":99},{"version":"fcc46a8bcbf9bef21023bba1995160a25f0bc590ca3563ec44c315b4f4c1b18a","impliedFormat":99},{"version":"669573548930fb7d0a0761b827e203dc623581e21febf0be80fb02414f217d74","impliedFormat":99},{"version":"f974db5be6d7428044e19c2848e72495a9b56a8d33b6fcab392e7fb5328eb8df","impliedFormat":99},{"version":"aa348c4fb2f8ac77df855f07fb66281c9f6e71746fdff3b13c7932aa7642b788","impliedFormat":99},{"version":"c2c2a861a338244d7dd700d0c52a78916b4bb75b98fc8ca5e7c501899fc03796","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"adb467429462e3891de5bb4a82a4189b92005d61c7f9367c089baf03997c104e","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},"c58c88e1a1e92e5f08e3e6868d98206943373813bb3a908cc530854f4441d757","68d97ade5116476ff952de4dc592a55b5fd14eb0bbf307625cd81e0216a88495","be2852eb960029435c4cfa02d67cb9992946195d1a4c2d9f5daffe1b0815aed5","498b9f4e0f4ed09cb062d49bd522290b13fa35357a84f8443864462b2d20ed98","d0d55e2125c158fa951d5369f1cae35bdbe9d146385ab8a598d67d7f1358e6e2","243d52e7790706c552c148b11e5e268a4096be1a14392638bfa3e1fc4eb7997e","62c0829d4bfc8d0de1a4661d7417f4f2e71dc4a4d2eedfc2ae6f8ca31ac1c455","04b84c549955d4f957eebdcc216d2c2bbd7dcce2606d813aa53fcac7c8dbf91c","899c2d25fca9ae605d5fcb33513671811be9ff12a4506fcb1c37559e54890027","3e8399d099161924af8bdc9e0a51daf1e359cdc3f4ed9df527d0f7e31e42bb1b","ad4b6d75ec2e2dd66b66c75c4ba8274e7e9fa81f4a5db500fc2d2ef398280fe4","7037be5039e5a33eb035e6afbc296ca54695aa88e4a100757895b4ea9fa239f2","7cfe23982c28bed5643917133847f55d2aabc86a68a91fe9716fa993cab3d257",{"version":"ae77d81a5541a8abb938a0efedf9ac4bea36fb3a24cc28cfa11c598863aba571","impliedFormat":1},{"version":"3cfb7c0c642b19fb75132154040bb7cd840f0002f9955b14154e69611b9b3f81","impliedFormat":1},{"version":"8387ec1601cf6b8948672537cf8d430431ba0d87b1f9537b4597c1ab8d3ade5b","impliedFormat":1},{"version":"d16f1c460b1ca9158e030fdf3641e1de11135e0c7169d3e8cf17cc4cc35d5e64","impliedFormat":1},{"version":"a934063af84f8117b8ce51851c1af2b76efe960aa4c7b48d0343a1b15c01aedf","impliedFormat":1},{"version":"e3c5ad476eb2fca8505aee5bdfdf9bf11760df5d0f9545db23f12a5c4d72a718","impliedFormat":1},{"version":"462bccdf75fcafc1ae8c30400c9425e1a4681db5d605d1a0edb4f990a54d8094","impliedFormat":1},{"version":"5923d8facbac6ecf7c84739a5c701a57af94a6f6648d6229a6c768cf28f0f8cb","impliedFormat":1},{"version":"d0570ce419fb38287e7b39c910b468becb5b2278cf33b1000a3d3e82a46ecae2","impliedFormat":1},{"version":"3aca7f4260dad9dcc0a0333654cb3cde6664d34a553ec06c953bce11151764d7","impliedFormat":1},{"version":"a0a6f0095f25f08a7129bc4d7cb8438039ec422dc341218d274e1e5131115988","impliedFormat":1},{"version":"b58f396fe4cfe5a0e4d594996bc8c1bfe25496fbc66cf169d41ac3c139418c77","impliedFormat":1},{"version":"45785e608b3d380c79e21957a6d1467e1206ac0281644e43e8ed6498808ace72","impliedFormat":1},{"version":"bece27602416508ba946868ad34d09997911016dbd6893fb884633017f74e2c5","impliedFormat":1},{"version":"2a90177ebaef25de89351de964c2c601ab54d6e3a157cba60d9cd3eaf5a5ee1a","impliedFormat":1},{"version":"82200e963d3c767976a5a9f41ecf8c65eca14a6b33dcbe00214fcbe959698c46","impliedFormat":1},{"version":"b4966c503c08bbd9e834037a8ab60e5f53c5fd1092e8873c4a1c344806acdab2","impliedFormat":1},{"version":"480c20eddc2ee5f57954609b2f7a3368f6e0dda4037aa09ccf0d37e0b20d4e5c","impliedFormat":1},{"version":"0309a01650023994ed96edbd675ea4fdc3779a823ce716ad876cc77afb792b62","impliedFormat":99},{"version":"f13d7beeea58e219daef3a40e0dc4f2bd7d9581ac04cedec236102a12dfd2090","impliedFormat":99},{"version":"48c411efce1848d1ed55de41d7deb93cbf7c04080912fd87aa517ed25ef42639","affectsGlobalScope":true,"impliedFormat":1},{"version":"a094636c05f3e75cb072684dd42cd25a4c1324bec4a866706c85c04cecd49613","affectsGlobalScope":true,"impliedFormat":99},{"version":"fe2d63fcfdde197391b6b70daf7be8c02a60afa90754a5f4a04bdc367f62793d","impliedFormat":99},{"version":"9a3e2c85ec1ab7a0874a19814cc73c691b716282cb727914093089c5a8475955","impliedFormat":99},{"version":"cbdc781d2429935c9c42acd680f2a53a9f633e8de03290ec6ea818e4f7bff19a","impliedFormat":99},{"version":"9f6d9f5dd710922f82f69abf9a324e28122b5f31ae6f6ce78427716db30a377e","impliedFormat":99},{"version":"ac2414a284bdecfd6ab7b87578744ab056cd04dd574b17853cd76830ef5b72f2","impliedFormat":99},{"version":"c3f921bbc9d2e65bd503a56fbc66da910e68467baedb0b9db0cc939e1876c0d7","impliedFormat":99},{"version":"c30a41267fc04c6518b17e55dcb2b810f267af4314b0b6d7df1c33a76ce1b330","impliedFormat":1},{"version":"72422d0bac4076912385d0c10911b82e4694fc106e2d70added091f88f0824ba","impliedFormat":1},{"version":"da251b82c25bee1d93f9fd80c5a61d945da4f708ca21285541d7aff83ecb8200","impliedFormat":1},{"version":"64db14db2bf37ac089766fdb3c7e1160fabc10e9929bc2deeede7237e4419fc8","impliedFormat":1},{"version":"98b94085c9f78eba36d3d2314affe973e8994f99864b8708122750788825c771","impliedFormat":1},{"version":"0cc99fbb161d78729d71fad66c6c363e3095862d6277160f29fa960744b785c6","affectsGlobalScope":true,"impliedFormat":99},"45209167a6c06a02a5572a4ac70b02d45202fe16c54a40c7f0735f73d1911cb1","2f6faa69c1bf19ff3c85fe39cba1961a8907218ba4787efbea2f741bc10afd0a","19c1032a21e151f5cbf2b9dce9316ff5f88bfd279d350515b7c361b2ae7bb24a","8dceb53378d81f8526b089081bdc518bb2218c670bf83f6d8493c8adb9dbac3a","92308b6855a3ae6718dfd65ca2c917b55d5a409f722affb84f6aca8fdd1fe7e3","3ae71bd86e771a9987659bb1ed406d37f2da6c333e500cfa561a13ab6820674e","e2c8531a6825a9edbece4d600cc9ebf6bfae8a07b613173f21461ab63e99a25b","903bfe5a0c44ed01e6df359bac16ff407c83904122fc450a9f0cc5c3075642a9","66273d98f46911c7c3e8da319061ad557f7e25928cb8635c91b9e90514e3ef72",{"version":"a7a74b55a74b69119990ec09675e3f7c484cea5e7fc21639d7aafe26225b118f","impliedFormat":1},"2245159118728e38c5eb55a2cf92a45a1cce734eb54929a10c5cb7aa98131580","bfc5714950d7b774885c9cf4ea014b8f4578a67cd50e8472b37e11e34ebeec26","179d1515cca70d15aca54cc33d80916b0e1c6a7a77b5db69cc6074cdd1f53021","488d02ff174cabdf3e07c4fc518a4dceaf645018c26f2571a80d69d5345251ed","f68a130684bf5cc9bc1f5b681f36f2efde0ff988689e00c2db85f767aa59ddbf","abeb8c44a69ecc5a74d024376ae4620096457df50594b9485fbf8af621d05e41","ff25d2789578679f3f1b1f5dcbb5c87a0dad6488fa583cd1fded8ae83c58b1b5","14d7fc14ab43f247b86706599909c72f82b6ce027b5e4dd7b9a8e09b211c9f84","650280a20c4831fa934287af21cc0ee06b33cfd1c881a7b69740dbd7b8ec5d1c","e079251c353e83cedb0cb3a4454e3fbd815f38c4dcd004b6d5fbb1c8eab2fefe","84b473a145d2d9fd9923a1925097b07789a805bb5d5c0ba9fbaee8fb4b5b8a1b","b3cb86402dd1053b5e828848aefc4e9a292c33c0199290d1cdce2c28534019a9","0d2a6bd5c33a053c28224f352811e9801c5482c9b604c19e483955c3f82653ca","ec62dfac79570698a7f2567dab7ad84b924867b3d27e68e7523354f5f0cb3390","23f575e4ef12c62de54e4ea74df480737d6dd78463345a9b95d63804fedacc20"],"root":[486,487,495,[567,579],[614,622],[624,638]],"options":{"allowJs":true,"esModuleInterop":true,"jsx":1,"module":99,"skipLibCheck":true,"strict":true,"target":4},"referencedMap":[[633,1],[634,2],[631,3],[635,4],[632,5],[636,6],[637,7],[638,8],[630,9],[617,10],[569,11],[619,12],[618,10],[570,13],[614,14],[568,15],[571,16],[620,17],[579,18],[622,19],[621,20],[616,21],[615,10],[625,22],[624,23],[628,16],[627,24],[626,25],[572,26],[573,26],[629,10],[574,27],[575,27],[576,27],[577,27],[578,27],[486,28],[487,29],[562,30],[560,15],[432,15],[494,31],[594,15],[591,15],[590,15],[585,32],[596,33],[581,34],[592,35],[584,36],[583,37],[593,15],[588,38],[595,15],[589,39],[582,15],[597,40],[580,15],[565,41],[561,30],[563,42],[564,30],[496,15],[155,43],[156,43],[157,44],[96,45],[158,46],[159,47],[160,48],[94,15],[161,49],[162,50],[163,51],[164,52],[165,53],[166,54],[167,54],[168,55],[169,56],[170,57],[171,58],[97,15],[95,15],[172,59],[173,60],[174,61],[214,62],[175,63],[176,64],[177,63],[178,65],[179,66],[180,67],[181,68],[182,68],[183,68],[184,69],[185,70],[186,71],[187,72],[188,73],[189,74],[190,74],[191,75],[192,15],[193,15],[194,76],[195,77],[196,76],[197,78],[198,79],[199,80],[200,81],[201,82],[202,83],[203,84],[204,85],[205,86],[206,87],[207,88],[208,89],[209,90],[210,91],[211,92],[98,63],[99,15],[100,93],[101,94],[102,15],[103,95],[104,15],[146,96],[147,97],[148,98],[149,98],[150,99],[151,15],[152,46],[153,100],[154,97],[212,101],[213,102],[215,103],[216,104],[83,15],[85,105],[328,26],[566,106],[600,15],[601,107],[602,108],[605,109],[604,15],[528,15],[539,110],[534,111],[537,112],[552,113],[541,15],[544,114],[543,115],[555,115],[542,116],[603,15],[536,117],[538,117],[530,118],[533,119],[549,118],[535,120],[529,15],[623,15],[84,15],[503,15],[610,121],[612,122],[611,123],[609,124],[608,15],[92,125],[435,126],[440,9],[442,127],[236,128],[384,129],[411,130],[311,15],[229,15],[234,15],[375,131],[303,132],[235,15],[413,133],[414,134],[356,135],[372,136],[276,137],[379,138],[380,139],[378,140],[377,15],[376,141],[412,142],[237,143],[310,15],[312,144],[232,15],[247,145],[238,146],[251,145],[280,145],[222,145],[383,147],[393,15],[228,15],[334,148],[335,149],[329,150],[463,15],[337,15],[338,150],[330,151],[467,152],[466,153],[462,15],[416,15],[371,154],[370,15],[461,155],[331,26],[254,156],[252,157],[464,15],[465,15],[253,158],[456,159],[459,160],[263,161],[262,162],[261,163],[470,26],[260,164],[298,15],[473,15],[476,15],[475,26],[477,165],[218,15],[381,166],[382,167],[405,15],[227,168],[217,15],[220,169],[350,26],[349,170],[348,171],[339,15],[340,15],[347,15],[342,15],[345,172],[341,15],[343,173],[346,174],[344,173],[233,15],[225,15],[226,145],[434,175],[443,176],[447,177],[387,178],[386,15],[295,15],[478,179],[396,180],[332,181],[333,182],[325,183],[317,15],[323,15],[324,184],[354,185],[318,186],[355,187],[352,188],[351,15],[353,15],[307,189],[388,190],[389,191],[319,192],[320,193],[315,194],[367,195],[395,196],[398,197],[296,198],[223,199],[394,200],[219,130],[417,201],[428,202],[415,15],[427,203],[93,15],[403,204],[283,15],[313,205],[399,15],[242,15],[426,206],[231,15],[286,207],[385,208],[425,15],[419,209],[224,15],[420,210],[422,211],[423,212],[406,15],[424,199],[250,213],[404,214],[429,215],[359,15],[362,15],[360,15],[364,15],[361,15],[363,15],[365,216],[358,15],[289,217],[288,15],[294,218],[290,219],[293,220],[292,220],[291,219],[246,221],[278,222],[392,223],[479,15],[451,224],[453,225],[322,15],[452,226],[390,190],[336,190],[230,15],[279,227],[243,228],[244,229],[245,230],[241,231],[366,231],[257,231],[281,232],[258,232],[240,233],[239,15],[287,234],[285,235],[284,236],[282,237],[391,238],[327,239],[357,240],[326,241],[374,242],[373,243],[369,244],[275,245],[277,246],[274,247],[248,248],[306,15],[439,15],[305,249],[368,15],[297,250],[316,251],[314,252],[299,253],[301,254],[474,15],[300,255],[302,255],[437,15],[436,15],[438,15],[472,15],[304,256],[272,26],[91,15],[255,257],[264,15],[309,258],[249,15],[445,26],[455,259],[271,26],[449,150],[270,260],[431,261],[269,259],[221,15],[457,262],[267,26],[268,26],[259,15],[308,15],[266,263],[265,264],[256,265],[321,72],[397,72],[421,15],[401,266],[400,15],[441,15],[273,26],[433,267],[86,26],[89,268],[90,269],[87,26],[88,15],[418,94],[410,270],[409,15],[408,271],[407,15],[430,272],[444,273],[446,274],[448,275],[450,276],[454,277],[485,278],[458,278],[484,279],[460,280],[468,281],[469,282],[471,283],[480,284],[483,168],[482,15],[481,285],[491,286],[488,15],[489,286],[490,287],[493,288],[492,289],[587,290],[586,15],[498,291],[497,292],[402,293],[504,15],[553,15],[531,15],[532,294],[81,15],[82,15],[13,15],[14,15],[16,15],[15,15],[2,15],[17,15],[18,15],[19,15],[20,15],[21,15],[22,15],[23,15],[24,15],[3,15],[25,15],[26,15],[4,15],[27,15],[31,15],[28,15],[29,15],[30,15],[32,15],[33,15],[34,15],[5,15],[35,15],[36,15],[37,15],[38,15],[6,15],[42,15],[39,15],[40,15],[41,15],[43,15],[7,15],[44,15],[49,15],[50,15],[45,15],[46,15],[47,15],[48,15],[8,15],[54,15],[51,15],[52,15],[53,15],[55,15],[9,15],[56,15],[57,15],[58,15],[60,15],[59,15],[61,15],[62,15],[10,15],[63,15],[64,15],[65,15],[11,15],[66,15],[67,15],[68,15],[69,15],[70,15],[1,15],[71,15],[72,15],[12,15],[76,15],[74,15],[79,15],[78,15],[73,15],[77,15],[75,15],[80,15],[122,295],[134,296],[120,297],[135,298],[144,299],[111,300],[112,301],[110,302],[143,285],[138,303],[142,304],[114,305],[131,306],[113,307],[141,308],[108,309],[109,303],[115,310],[116,15],[121,311],[119,310],[106,312],[145,313],[136,314],[125,315],[124,310],[126,316],[129,317],[123,318],[127,319],[139,285],[117,320],[118,321],[130,322],[107,298],[133,323],[132,310],[128,324],[137,15],[105,15],[140,325],[550,326],[547,327],[548,326],[551,328],[546,15],[527,329],[524,330],[502,331],[520,332],[518,333],[519,334],[507,335],[508,333],[515,336],[506,337],[511,338],[521,15],[512,339],[517,340],[523,341],[522,342],[505,343],[513,344],[514,345],[509,346],[516,332],[510,347],[500,348],[499,15],[501,349],[525,15],[526,350],[559,351],[554,352],[545,353],[540,15],[606,354],[556,355],[607,356],[557,357],[599,358],[598,359],[558,360],[613,361],[495,27],[567,362]],"affectedFilesPendingEmit":[633,634,631,635,632,636,637,638,617,569,619,618,570,614,568,571,620,579,622,621,616,615,625,624,628,627,626,572,573,629,574,575,576,577,578,487,495,567],"version":"5.9.3"} \ No newline at end of file