diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2aebedb..8bb1a53 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,6 @@ import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth'; import { useCurrentBusiness } from './hooks/useBusiness'; import { useUpdateBusiness } from './hooks/useBusiness'; import { setCookie } from './utils/cookies'; -import { DevQuickLogin } from './components/DevQuickLogin'; // Import Login Page import LoginPage from './pages/LoginPage'; @@ -549,9 +548,8 @@ const App: React.FC = () => { - ); }; -export default App; +export default App; \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts.disabled similarity index 100% rename from frontend/vite.config.ts rename to frontend/vite.config.ts.disabled diff --git a/smoothschedule/blank_screen_debug.png b/smoothschedule/blank_screen_debug.png new file mode 100644 index 0000000..11946ac Binary files /dev/null and b/smoothschedule/blank_screen_debug.png differ diff --git a/smoothschedule/config/settings/local.py b/smoothschedule/config/settings/local.py index 6bbcf84..7c3a9fc 100644 --- a/smoothschedule/config/settings/local.py +++ b/smoothschedule/config/settings/local.py @@ -14,6 +14,8 @@ SECRET_KEY = env( # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", ".lvh.me", "lvh.me"] # noqa: S104 +from corsheaders.defaults import default_headers + # django-cors-headers # ------------------------------------------------------------------------------ # https://github.com/adamchainz/django-cors-headers#configuration @@ -29,6 +31,9 @@ CORS_ALLOWED_ORIGIN_REGEXES = [ r"^http://.*\.lvh\.me:517[34]$", # Allow all subdomains on ports 5173/5174 ] CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_HEADERS = list(default_headers) + [ + "x-business-subdomain", +] # CSRF # ------------------------------------------------------------------------------ diff --git a/smoothschedule/config/settings/multitenancy.py b/smoothschedule/config/settings/multitenancy.py index 95419bf..6cd4979 100644 --- a/smoothschedule/config/settings/multitenancy.py +++ b/smoothschedule/config/settings/multitenancy.py @@ -41,6 +41,7 @@ SHARED_APPS = [ 'hijack.contrib.admin', 'crispy_forms', 'crispy_bootstrap5', + 'csp', ] # Tenant-specific apps - Each tenant gets isolated data in their own schema @@ -87,6 +88,7 @@ MIDDLEWARE = [ # 2. Security middleware 'django.middleware.security.SecurityMiddleware', + 'csp.middleware.CSPMiddleware', 'corsheaders.middleware.CorsMiddleware', # Moved up for better CORS handling 'whitenoise.middleware.WhiteNoiseMiddleware', @@ -211,3 +213,40 @@ LOGGING['loggers']['smoothschedule.security.masquerade'] = { # Create logs directory if it doesn't exist import os os.makedirs(BASE_DIR / 'logs', exist_ok=True) + +# ============================================================================= +# CONTENT SECURITY POLICY (CSP) +# ============================================================================= +# https://django-csp.readthedocs.io/en/latest/configuration.html + +CSP_DEFAULT_SRC = ("'self'",) +CSP_SCRIPT_SRC = ( + "'self'", + "https://js.stripe.com", + "https://connect-js.stripe.com", + "https://www.googletagmanager.com", + "https://www.google-analytics.com", + "blob:", # Required for Stripe +) +CSP_STYLE_SRC = ( + "'self'", + "'unsafe-inline'", # Required for Stripe and many UI libraries +) +CSP_IMG_SRC = ( + "'self'", + "data:", + "https://*.stripe.com", + "https://www.google-analytics.com", +) +CSP_CONNECT_SRC = ( + "'self'", + "https://api.stripe.com", + "https://www.google-analytics.com", + "https://stats.g.doubleclick.net", +) +CSP_FRAME_SRC = ( + "'self'", + "https://js.stripe.com", + "https://hooks.stripe.com", + "https://connect-js.stripe.com", +) diff --git a/smoothschedule/platform_admin/serializers.py b/smoothschedule/platform_admin/serializers.py index 3de92a8..2e0fa53 100644 --- a/smoothschedule/platform_admin/serializers.py +++ b/smoothschedule/platform_admin/serializers.py @@ -43,7 +43,7 @@ class TenantSerializer(serializers.ModelSerializer): try: owner = User.objects.filter( role=User.Role.TENANT_OWNER, - # Note: We need to add a tenant reference to User model + tenant=obj ).first() if owner: @@ -52,7 +52,7 @@ class TenantSerializer(serializers.ModelSerializer): 'username': owner.username, 'full_name': owner.full_name, 'email': owner.email, - 'role': owner.role, + 'role': owner.role.lower(), } except: pass @@ -65,6 +65,7 @@ class PlatformUserSerializer(serializers.ModelSerializer): business_name = serializers.SerializerMethodField() business_subdomain = serializers.SerializerMethodField() full_name = serializers.SerializerMethodField() + role = serializers.SerializerMethodField() class Meta: model = User @@ -76,6 +77,9 @@ class PlatformUserSerializer(serializers.ModelSerializer): ] read_only_fields = fields + def get_role(self, obj): + return obj.role.lower() + def get_full_name(self, obj): """Get user's full name""" return obj.full_name diff --git a/smoothschedule/pyproject.toml b/smoothschedule/pyproject.toml index 3c1efc3..52c566f 100644 --- a/smoothschedule/pyproject.toml +++ b/smoothschedule/pyproject.toml @@ -141,6 +141,7 @@ dev = [ "factory-boy==3.3.2", "ipdb==0.13.13", "mypy==1.18.2", + "playwright>=1.56.0", "pre-commit==4.5.0", "psycopg[c]==3.2.13", "pytest==9.0.1", @@ -192,4 +193,5 @@ dependencies = [ "sentry-sdk==2.46.0", "whitenoise==6.11.0", "stripe>=7.0.0", + "django-csp==3.8.0", ] diff --git a/smoothschedule/schedule/api_views.py b/smoothschedule/schedule/api_views.py index d2bdfec..58377bb 100644 --- a/smoothschedule/schedule/api_views.py +++ b/smoothschedule/schedule/api_views.py @@ -25,7 +25,7 @@ def current_business_view(request): # Get subdomain from primary domain subdomain = None - primary_domain = tenant.domain_set.filter(is_primary=True).first() + primary_domain = tenant.domains.filter(is_primary=True).first() if primary_domain: # Extract subdomain from domain (e.g., "mybusiness.lvh.me" -> "mybusiness") domain_parts = primary_domain.domain.split('.') diff --git a/smoothschedule/smoothschedule/users/api_views.py b/smoothschedule/smoothschedule/users/api_views.py index 8b42eca..693c0fd 100644 --- a/smoothschedule/smoothschedule/users/api_views.py +++ b/smoothschedule/smoothschedule/users/api_views.py @@ -23,14 +23,20 @@ def current_user_view(request): business_subdomain = None if user.tenant: business_name = user.tenant.name - business_subdomain = user.tenant.subdomain + # user.tenant.subdomain does not exist. Fetch from domains relation. + # Assuming 'domains' is the related_name from Domain model to Tenant + primary_domain = user.tenant.domains.filter(is_primary=True).first() + if primary_domain: + business_subdomain = primary_domain.domain.split('.')[0] + else: + business_subdomain = user.tenant.schema_name user_data = { 'id': user.id, 'username': user.username, 'email': user.email, 'name': user.full_name, - 'role': user.role, + 'role': user.role.lower(), 'avatar_url': None, # TODO: Implement avatar 'email_verified': False, # TODO: Implement email verification 'is_staff': user.is_staff, diff --git a/smoothschedule/uv.lock b/smoothschedule/uv.lock index db8b4a0..0ba68d5 100644 --- a/smoothschedule/uv.lock +++ b/smoothschedule/uv.lock @@ -520,6 +520,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ac3a11950baaf75c1f3242e3af9dfe45201f6ee10c113dd37a9c000876d2/django_crispy_forms-2.5-py3-none-any.whl", hash = "sha256:adc99d5901baca09479c53bf536b3909e80a9f2bb299438a223de4c106ebf1f9", size = 31464, upload-time = "2025-11-06T20:44:00.795Z" }, ] +[[package]] +name = "django-csp" +version = "3.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/16/c3c65ad59997284402e54d00797c7aca96572df911aede3e1f2cc2e029f8/django_csp-3.8.tar.gz", hash = "sha256:ef0f1a9f7d8da68ae6e169c02e9ac661c0ecf04db70e0d1d85640512a68471c0", size = 13341, upload-time = "2024-03-01T14:00:30.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/ff/2c7a4b6706125a17bd0071802e4894c28772cfcdea20a086a2be3c5fafda/django_csp-3.8-py3-none-any.whl", hash = "sha256:19b2978b03fcd73517d7d67acbc04fbbcaec0facc3e83baa502965892d1e0719", size = 17410, upload-time = "2024-03-01T14:00:28.135Z" }, +] + [[package]] name = "django-debug-toolbar" version = "6.1.0" @@ -822,6 +834,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/ff/ee2f67c0ff146ec98b5df1df637b2bc2d17beeb05df9f427a67bd7a7d79c/flower-2.0.1-py2.py3-none-any.whl", hash = "sha256:9db2c621eeefbc844c8dd88be64aef61e84e2deb29b271e02ab2b5b9f01068e2", size = 383553, upload-time = "2023-08-13T14:37:41.552Z" }, ] +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, +] + [[package]] name = "gunicorn" version = "23.0.0" @@ -1222,6 +1253,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "playwright" +version = "1.56.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/31/a5362cee43f844509f1f10d8a27c9cc0e2f7bdce5353d304d93b2151c1b1/playwright-1.56.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33eb89c516cbc6723f2e3523bada4a4eb0984a9c411325c02d7016a5d625e9c", size = 40611424, upload-time = "2025-11-11T18:39:10.175Z" }, + { url = "https://files.pythonhosted.org/packages/ef/95/347eef596d8778fb53590dc326c344d427fa19ba3d42b646fce2a4572eb3/playwright-1.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b228b3395212b9472a4ee5f1afe40d376eef9568eb039fcb3e563de8f4f4657b", size = 39400228, upload-time = "2025-11-11T18:39:13.915Z" }, + { url = "https://files.pythonhosted.org/packages/b9/54/6ad97b08b2ca1dfcb4fbde4536c4f45c0d9d8b1857a2d20e7bbfdf43bf15/playwright-1.56.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:0ef7e6fd653267798a8a968ff7aa2dcac14398b7dd7440ef57524e01e0fbbd65", size = 40611424, upload-time = "2025-11-11T18:39:17.093Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/6d409e37e82cdd5dda3df1ab958130ae32b46e42458bd4fc93d7eb8749cb/playwright-1.56.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:404be089b49d94bc4c1fe0dfb07664bda5ffe87789034a03bffb884489bdfb5c", size = 46263122, upload-time = "2025-11-11T18:39:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/4f/84/fb292cc5d45f3252e255ea39066cd1d2385c61c6c1596548dfbf59c88605/playwright-1.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64cda7cf4e51c0d35dab55190841bfcdfb5871685ec22cb722cd0ad2df183e34", size = 46110645, upload-time = "2025-11-11T18:39:24.005Z" }, + { url = "https://files.pythonhosted.org/packages/61/bd/8c02c3388ae14edc374ac9f22cbe4e14826c6a51b2d8eaf86e89fabee264/playwright-1.56.0-py3-none-win32.whl", hash = "sha256:d87b79bcb082092d916a332c27ec9732e0418c319755d235d93cc6be13bdd721", size = 35639837, upload-time = "2025-11-11T18:39:27.174Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/f13b538fbc6b7a00152f4379054a49f6abc0bf55ac86f677ae54bc49fb82/playwright-1.56.0-py3-none-win_amd64.whl", hash = "sha256:3c7fc49bb9e673489bf2622855f9486d41c5101bbed964638552b864c4591f94", size = 35639843, upload-time = "2025-11-11T18:39:30.851Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c7/3ee8b556107995846576b4fe42a08ed49b8677619421f2afacf6ee421138/playwright-1.56.0-py3-none-win_arm64.whl", hash = "sha256:2745490ae8dd58d27e5ea4d9aa28402e8e2991eb84fb4b2fd5fbde2106716f6f", size = 31248959, upload-time = "2025-11-11T18:39:33.998Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1318,6 +1368,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1633,6 +1695,7 @@ dependencies = [ { name = "django-celery-beat" }, { name = "django-cors-headers" }, { name = "django-crispy-forms" }, + { name = "django-csp" }, { name = "django-environ" }, { name = "django-hijack" }, { name = "django-model-utils" }, @@ -1649,6 +1712,7 @@ dependencies = [ { name = "python-slugify" }, { name = "redis" }, { name = "sentry-sdk" }, + { name = "stripe" }, { name = "whitenoise" }, ] @@ -1664,6 +1728,7 @@ dev = [ { name = "factory-boy" }, { name = "ipdb" }, { name = "mypy" }, + { name = "playwright" }, { name = "pre-commit" }, { name = "psycopg", extra = ["c"] }, { name = "pytest" }, @@ -1687,6 +1752,7 @@ requires-dist = [ { name = "django-celery-beat", specifier = "==2.8.1" }, { name = "django-cors-headers", specifier = "==4.9.0" }, { name = "django-crispy-forms", specifier = "==2.5" }, + { name = "django-csp", specifier = "==3.8.0" }, { name = "django-environ", specifier = "==0.12.0" }, { name = "django-hijack", specifier = ">=3.4" }, { name = "django-model-utils", specifier = "==5.0.0" }, @@ -1703,6 +1769,7 @@ requires-dist = [ { name = "python-slugify", specifier = "==8.0.4" }, { name = "redis", specifier = "==7.1.0" }, { name = "sentry-sdk", specifier = "==2.46.0" }, + { name = "stripe", specifier = ">=7.0.0" }, { name = "whitenoise", specifier = "==6.11.0" }, ] @@ -1718,6 +1785,7 @@ dev = [ { name = "factory-boy", specifier = "==3.3.2" }, { name = "ipdb", specifier = "==0.13.13" }, { name = "mypy", specifier = "==1.18.2" }, + { name = "playwright", specifier = ">=1.56.0" }, { name = "pre-commit", specifier = "==4.5.0" }, { name = "psycopg", extras = ["c"], specifier = "==3.2.13" }, { name = "pytest", specifier = "==9.0.1" }, @@ -1882,6 +1950,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "stripe" +version = "14.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/49/08df0acc094587f4d76c2ab31ebbecb8a37312ab558cddaa6a4c2ff19579/stripe-14.0.1.tar.gz", hash = "sha256:f2d56345bf5d41c1f21f814b00174a3173a0b5eb4e8fc46a8f779e3d7a2efc6e", size = 1362960, upload-time = "2025-11-22T01:07:48.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/88/0db878a84d333a188714f4ade57c9ae765a14a0b81862eb133ad7864711c/stripe-14.0.1-py3-none-any.whl", hash = "sha256:ff25c5e5f085beaa98b6b9c2c729d22ad99068196cbd83fdf82669fd08311b76", size = 1970603, upload-time = "2025-11-22T01:07:47.309Z" }, +] + [[package]] name = "termcolor" version = "3.2.0"