karaoke-gen 0.96.0__py3-none-any.whl → 0.101.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. backend/api/routes/admin.py +696 -92
  2. backend/api/routes/audio_search.py +29 -8
  3. backend/api/routes/file_upload.py +99 -22
  4. backend/api/routes/health.py +65 -0
  5. backend/api/routes/internal.py +6 -0
  6. backend/api/routes/jobs.py +28 -1
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +472 -51
  10. backend/main.py +31 -2
  11. backend/middleware/__init__.py +7 -1
  12. backend/middleware/tenant.py +192 -0
  13. backend/models/job.py +19 -3
  14. backend/models/tenant.py +208 -0
  15. backend/models/user.py +18 -0
  16. backend/services/email_service.py +253 -6
  17. backend/services/encoding_service.py +128 -31
  18. backend/services/firestore_service.py +6 -0
  19. backend/services/job_manager.py +44 -2
  20. backend/services/langfuse_preloader.py +98 -0
  21. backend/services/nltk_preloader.py +122 -0
  22. backend/services/spacy_preloader.py +65 -0
  23. backend/services/stripe_service.py +133 -11
  24. backend/services/tenant_service.py +285 -0
  25. backend/services/user_service.py +85 -7
  26. backend/tests/emulator/conftest.py +22 -1
  27. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  28. backend/tests/test_admin_job_files.py +337 -0
  29. backend/tests/test_admin_job_reset.py +384 -0
  30. backend/tests/test_admin_job_update.py +326 -0
  31. backend/tests/test_email_service.py +233 -0
  32. backend/tests/test_impersonation.py +223 -0
  33. backend/tests/test_job_creation_regression.py +4 -0
  34. backend/tests/test_job_manager.py +171 -9
  35. backend/tests/test_jobs_api.py +11 -1
  36. backend/tests/test_made_for_you.py +2086 -0
  37. backend/tests/test_models.py +139 -0
  38. backend/tests/test_spacy_preloader.py +119 -0
  39. backend/tests/test_tenant_api.py +350 -0
  40. backend/tests/test_tenant_middleware.py +345 -0
  41. backend/tests/test_tenant_models.py +406 -0
  42. backend/tests/test_tenant_service.py +418 -0
  43. backend/utils/test_data.py +27 -0
  44. backend/workers/screens_worker.py +16 -6
  45. backend/workers/video_worker.py +8 -3
  46. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
  47. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
  48. lyrics_transcriber/correction/agentic/agent.py +17 -6
  49. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
  50. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  51. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  52. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  53. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  54. lyrics_transcriber/frontend/src/api.ts +13 -5
  55. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  56. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
  57. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
  58. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
backend/main.py CHANGED
@@ -7,10 +7,14 @@ from fastapi import FastAPI
7
7
  from fastapi.middleware.cors import CORSMiddleware
8
8
 
9
9
  from backend.config import settings
10
- from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin
10
+ from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin, tenant
11
11
  from backend.services.tracing import setup_tracing, instrument_app, get_current_trace_id
12
12
  from backend.services.structured_logging import setup_structured_logging
13
+ from backend.services.spacy_preloader import preload_spacy_model
14
+ from backend.services.nltk_preloader import preload_all_nltk_resources
15
+ from backend.services.langfuse_preloader import preload_langfuse_handler
13
16
  from backend.middleware.audit_logging import AuditLoggingMiddleware
17
+ from backend.middleware.tenant import TenantMiddleware
14
18
 
15
19
 
16
20
  from backend.version import VERSION
@@ -67,7 +71,28 @@ async def lifespan(app: FastAPI):
67
71
  logger.info(f"Environment: {settings.environment}")
68
72
  logger.info(f"GCS Bucket: {settings.gcs_bucket_name}")
69
73
  logger.info(f"Tracing enabled: {tracing_enabled}")
70
-
74
+
75
+ # Preload NLP models and resources to avoid cold start delays
76
+ # See docs/archive/2026-01-08-performance-investigation.md for background
77
+
78
+ # 1. SpaCy model (60+ second delay without preload)
79
+ try:
80
+ preload_spacy_model("en_core_web_sm")
81
+ except Exception as e:
82
+ logger.warning(f"SpaCy preload failed (will load lazily): {e}")
83
+
84
+ # 2. NLTK cmudict (50-100+ second delay without preload)
85
+ try:
86
+ preload_all_nltk_resources()
87
+ except Exception as e:
88
+ logger.warning(f"NLTK preload failed (will load lazily): {e}")
89
+
90
+ # 3. Langfuse callback handler (200+ second delay without preload)
91
+ try:
92
+ preload_langfuse_handler()
93
+ except Exception as e:
94
+ logger.warning(f"Langfuse preload failed (will initialize lazily): {e}")
95
+
71
96
  # Validate OAuth credentials (non-blocking)
72
97
  try:
73
98
  await validate_credentials_on_startup()
@@ -104,6 +129,9 @@ app.add_middleware(
104
129
  # Add audit logging middleware (captures all requests with request_id for correlation)
105
130
  app.add_middleware(AuditLoggingMiddleware)
106
131
 
132
+ # Add tenant detection middleware (extracts tenant from subdomain/headers)
133
+ app.add_middleware(TenantMiddleware)
134
+
107
135
  # Include routers
108
136
  app.include_router(health.router, prefix="/api")
109
137
  app.include_router(jobs.router, prefix="/api")
@@ -115,6 +143,7 @@ app.include_router(audio_search.router, prefix="/api") # Audio search (artist+t
115
143
  app.include_router(themes.router, prefix="/api") # Theme selection for styles
116
144
  app.include_router(users.router, prefix="/api") # User auth, credits, and Stripe webhooks
117
145
  app.include_router(admin.router, prefix="/api") # Admin dashboard and management
146
+ app.include_router(tenant.router) # Tenant/white-label configuration (no /api prefix, router has it)
118
147
 
119
148
 
120
149
  @app.get("/")
@@ -1,5 +1,11 @@
1
1
  """Middleware package for FastAPI application."""
2
2
 
3
3
  from backend.middleware.audit_logging import AuditLoggingMiddleware
4
+ from backend.middleware.tenant import TenantMiddleware, get_tenant_from_request, get_tenant_config_from_request
4
5
 
5
- __all__ = ["AuditLoggingMiddleware"]
6
+ __all__ = [
7
+ "AuditLoggingMiddleware",
8
+ "TenantMiddleware",
9
+ "get_tenant_from_request",
10
+ "get_tenant_config_from_request",
11
+ ]
@@ -0,0 +1,192 @@
1
+ """
2
+ Tenant detection middleware for white-label B2B portals.
3
+
4
+ This middleware extracts the tenant ID from incoming requests and attaches
5
+ the tenant configuration to the request state for use by downstream handlers.
6
+
7
+ Tenant detection priority:
8
+ 1. X-Tenant-ID header (explicitly set by frontend)
9
+ 2. `tenant` query parameter (for development/testing)
10
+ 3. Host header subdomain detection (production flow)
11
+
12
+ The middleware is non-blocking - if no tenant is detected, the request
13
+ proceeds as a default Nomad Karaoke request (tenant_id = None).
14
+ """
15
+
16
+ import logging
17
+ import os
18
+ from typing import Optional
19
+
20
+ from starlette.middleware.base import BaseHTTPMiddleware
21
+ from starlette.requests import Request
22
+ from starlette.responses import Response
23
+
24
+ from backend.services.tenant_service import get_tenant_service
25
+ from backend.models.tenant import TenantConfig
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Only allow query param tenant override in non-production environments
30
+ IS_PRODUCTION = os.environ.get("ENV", "").lower() == "production" or \
31
+ os.environ.get("ENVIRONMENT", "").lower() == "production"
32
+
33
+
34
+ # Known non-tenant subdomains that should be treated as default Nomad Karaoke
35
+ NON_TENANT_SUBDOMAINS = {"gen", "api", "www", "buy", "admin", "app", "beta"}
36
+
37
+
38
+ class TenantMiddleware(BaseHTTPMiddleware):
39
+ """
40
+ Middleware that detects tenant from request and attaches config to state.
41
+
42
+ After this middleware runs, routes can access:
43
+ - request.state.tenant_id: str or None
44
+ - request.state.tenant_config: TenantConfig or None
45
+
46
+ Usage in route handlers:
47
+ ```python
48
+ @router.get("/something")
49
+ async def handler(request: Request):
50
+ tenant_id = getattr(request.state, "tenant_id", None)
51
+ tenant_config = getattr(request.state, "tenant_config", None)
52
+ ```
53
+ """
54
+
55
+ async def dispatch(self, request: Request, call_next) -> Response:
56
+ # Extract tenant ID from request
57
+ tenant_id = self._extract_tenant_id(request)
58
+
59
+ # Load tenant config if tenant detected
60
+ tenant_config: Optional[TenantConfig] = None
61
+ if tenant_id:
62
+ tenant_service = get_tenant_service()
63
+ tenant_config = tenant_service.get_tenant_config(tenant_id)
64
+
65
+ if tenant_config and not tenant_config.is_active:
66
+ # Tenant exists but is inactive - treat as default
67
+ logger.warning(f"Inactive tenant requested: {tenant_id}")
68
+ tenant_id = None
69
+ tenant_config = None
70
+
71
+ # Attach to request state
72
+ request.state.tenant_id = tenant_id
73
+ request.state.tenant_config = tenant_config
74
+
75
+ # Log tenant detection for debugging
76
+ if tenant_id:
77
+ logger.debug(f"Request tenant: {tenant_id} (path: {request.url.path})")
78
+
79
+ # Process request
80
+ response = await call_next(request)
81
+
82
+ # Add tenant ID to response headers for debugging
83
+ if tenant_id:
84
+ response.headers["X-Tenant-ID"] = tenant_id
85
+
86
+ return response
87
+
88
+ def _extract_tenant_id(self, request: Request) -> Optional[str]:
89
+ """
90
+ Extract tenant ID from request using priority-based detection.
91
+
92
+ Returns:
93
+ Tenant ID if detected, None for default Nomad Karaoke
94
+ """
95
+ # Priority 1: X-Tenant-ID header (explicitly set by frontend)
96
+ header_tenant = request.headers.get("X-Tenant-ID")
97
+ if header_tenant:
98
+ return header_tenant.lower().strip()
99
+
100
+ # Priority 2: Query parameter (for development/testing only)
101
+ # Disabled in production to prevent tenant spoofing
102
+ if not IS_PRODUCTION:
103
+ query_tenant = request.query_params.get("tenant")
104
+ if query_tenant:
105
+ return query_tenant.lower().strip()
106
+
107
+ # Priority 3: Host header subdomain detection
108
+ host = request.headers.get("Host", "")
109
+ return self._extract_tenant_from_host(host)
110
+
111
+ def _extract_tenant_from_host(self, host: str) -> Optional[str]:
112
+ """
113
+ Extract tenant ID from Host header subdomain.
114
+
115
+ Patterns handled:
116
+ - vocalstar.nomadkaraoke.com -> vocalstar
117
+ - vocalstar.gen.nomadkaraoke.com -> vocalstar
118
+ - localhost:3000 -> None (local dev)
119
+ - gen.nomadkaraoke.com -> None (main app)
120
+ - api.nomadkaraoke.com -> None (API)
121
+
122
+ Returns:
123
+ Tenant ID if subdomain matches tenant, None otherwise
124
+ """
125
+ if not host:
126
+ return None
127
+
128
+ # Normalize
129
+ host_lower = host.lower()
130
+
131
+ # Remove port if present
132
+ if ":" in host_lower:
133
+ host_lower = host_lower.split(":")[0]
134
+
135
+ # Check if this is a nomadkaraoke.com domain
136
+ if "nomadkaraoke.com" not in host_lower:
137
+ return None
138
+
139
+ # Split into parts
140
+ parts = host_lower.split(".")
141
+
142
+ # Need at least 3 parts: subdomain.nomadkaraoke.com
143
+ if len(parts) < 3:
144
+ return None
145
+
146
+ # First part is the potential tenant ID
147
+ potential_tenant = parts[0]
148
+
149
+ # Skip known non-tenant subdomains
150
+ if potential_tenant in NON_TENANT_SUBDOMAINS:
151
+ return None
152
+
153
+ # Verify tenant exists in GCS
154
+ tenant_service = get_tenant_service()
155
+ if tenant_service.tenant_exists(potential_tenant):
156
+ return potential_tenant
157
+
158
+ return None
159
+
160
+
161
+ def get_tenant_from_request(request: Request) -> Optional[str]:
162
+ """
163
+ Helper function to get tenant ID from request state.
164
+
165
+ Usage in route handlers:
166
+ ```python
167
+ from backend.middleware.tenant import get_tenant_from_request
168
+
169
+ @router.get("/something")
170
+ async def handler(request: Request):
171
+ tenant_id = get_tenant_from_request(request)
172
+ ```
173
+ """
174
+ return getattr(request.state, "tenant_id", None)
175
+
176
+
177
+ def get_tenant_config_from_request(request: Request) -> Optional[TenantConfig]:
178
+ """
179
+ Helper function to get tenant config from request state.
180
+
181
+ Usage in route handlers:
182
+ ```python
183
+ from backend.middleware.tenant import get_tenant_config_from_request
184
+
185
+ @router.get("/something")
186
+ async def handler(request: Request):
187
+ config = get_tenant_config_from_request(request)
188
+ if config and not config.features.audio_search:
189
+ raise HTTPException(403, "Audio search not available")
190
+ ```
191
+ """
192
+ return getattr(request.state, "tenant_config", None)
backend/models/job.py CHANGED
@@ -207,6 +207,9 @@ class Job(BaseModel):
207
207
  webhook_url: Optional[str] = None # Webhook for notifications
208
208
  user_email: Optional[str] = None # Email for notifications
209
209
  non_interactive: bool = False # Skip interactive steps (lyrics review, instrumental selection)
210
+
211
+ # Multi-tenant support (None = default Nomad Karaoke)
212
+ tenant_id: Optional[str] = None # Tenant ID for white-label portal scoping
210
213
 
211
214
  # Theme configuration (pre-made themes from GCS)
212
215
  theme_id: Optional[str] = None # Theme identifier (e.g., "nomad", "default")
@@ -276,7 +279,12 @@ class Job(BaseModel):
276
279
  review_token_expires_at: Optional[datetime] = None # Token expiry time (optional, for extra security)
277
280
  instrumental_token: Optional[str] = None # Job-scoped token for instrumental review UI access (generated when entering AWAITING_INSTRUMENTAL_SELECTION)
278
281
  instrumental_token_expires_at: Optional[datetime] = None # Token expiry time
279
-
282
+
283
+ # Made-for-you order tracking
284
+ made_for_you: bool = False # Flag indicating this is a made-for-you customer order
285
+ customer_email: Optional[str] = None # Customer email for final delivery (job owned by admin during processing)
286
+ customer_notes: Optional[str] = None # Notes provided by customer with their order
287
+
280
288
  # Processing state
281
289
  track_output_dir: Optional[str] = None # Local output directory (temp)
282
290
  audio_hash: Optional[str] = None # Hash for deduplication
@@ -474,7 +482,12 @@ class JobCreate(BaseModel):
474
482
  prep_only: bool = False # Stop after review, don't run finalisation
475
483
  finalise_only: bool = False # Skip prep, run only finalisation
476
484
  keep_brand_code: Optional[str] = None # Preserve existing brand code instead of generating new one
477
-
485
+
486
+ # Made-for-you order tracking
487
+ made_for_you: bool = False # Flag indicating this is a made-for-you customer order
488
+ customer_email: Optional[str] = None # Customer email for final delivery (job owned by admin during processing)
489
+ customer_notes: Optional[str] = None # Notes provided by customer with their order
490
+
478
491
  # Request metadata (set by API endpoint from request headers)
479
492
  request_metadata: Dict[str, Any] = Field(default_factory=dict)
480
493
  """
@@ -486,7 +499,10 @@ class JobCreate(BaseModel):
486
499
  - server_version: Current server version
487
500
  - custom_headers: All X-* headers
488
501
  """
489
-
502
+
503
+ # Tenant scoping for white-label portals
504
+ tenant_id: Optional[str] = None # Tenant ID for job scoping
505
+
490
506
  @validator('url')
491
507
  def validate_url(cls, v):
492
508
  """Validate URL is not empty."""
@@ -0,0 +1,208 @@
1
+ """
2
+ Tenant data models for white-label B2B portals.
3
+
4
+ Tenants are B2B customers who get their own branded portal with custom
5
+ configuration. Each tenant has:
6
+ - Custom branding (logo, colors, site title)
7
+ - Feature flags (enable/disable audio search, distribution, etc.)
8
+ - Default settings (theme, distribution mode)
9
+ - Auth configuration (allowed email domains, fixed tokens)
10
+
11
+ Tenant configs are stored in GCS at tenants/{tenant_id}/config.json
12
+ """
13
+
14
+ from datetime import datetime
15
+ from typing import List, Optional
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+
20
+ class TenantBranding(BaseModel):
21
+ """Branding configuration for a tenant's portal."""
22
+
23
+ logo_url: Optional[str] = Field(
24
+ None, description="URL to tenant's logo image (PNG with transparency preferred)"
25
+ )
26
+ logo_height: int = Field(40, description="Logo height in pixels for header display")
27
+ primary_color: str = Field(
28
+ "#ff5bb8", description="Primary brand color (hex format)"
29
+ )
30
+ secondary_color: str = Field(
31
+ "#8b5cf6", description="Secondary brand color (hex format)"
32
+ )
33
+ accent_color: Optional[str] = Field(
34
+ None, description="Accent color for highlights (hex format)"
35
+ )
36
+ background_color: Optional[str] = Field(
37
+ None, description="Custom background color (defaults to dark theme)"
38
+ )
39
+ favicon_url: Optional[str] = Field(None, description="URL to custom favicon")
40
+ site_title: str = Field(
41
+ "Karaoke Generator", description="Browser tab title"
42
+ )
43
+ tagline: Optional[str] = Field(
44
+ None, description="Optional tagline shown below logo"
45
+ )
46
+
47
+
48
+ class TenantFeatures(BaseModel):
49
+ """Feature flags controlling what's available in the tenant's portal."""
50
+
51
+ # Audio input methods
52
+ audio_search: bool = Field(
53
+ True, description="Enable audio search (flacfetch integration)"
54
+ )
55
+ file_upload: bool = Field(True, description="Enable direct file upload")
56
+ youtube_url: bool = Field(True, description="Enable YouTube URL input")
57
+
58
+ # Distribution options
59
+ youtube_upload: bool = Field(True, description="Enable YouTube upload option")
60
+ dropbox_upload: bool = Field(True, description="Enable Dropbox upload option")
61
+ gdrive_upload: bool = Field(True, description="Enable Google Drive upload option")
62
+
63
+ # Customization options
64
+ theme_selection: bool = Field(
65
+ True, description="Allow user to select theme (false = use default)"
66
+ )
67
+ color_overrides: bool = Field(
68
+ True, description="Allow user to customize colors"
69
+ )
70
+
71
+ # Output formats
72
+ enable_cdg: bool = Field(True, description="Generate CDG format output")
73
+ enable_4k: bool = Field(True, description="Generate 4K video output")
74
+
75
+ # Advanced features
76
+ admin_access: bool = Field(
77
+ False, description="Allow access to admin dashboard"
78
+ )
79
+
80
+
81
+ class TenantDefaults(BaseModel):
82
+ """Default settings applied to all jobs for this tenant."""
83
+
84
+ theme_id: Optional[str] = Field(
85
+ None, description="Default theme ID (required if theme_selection=false)"
86
+ )
87
+ locked_theme: Optional[str] = Field(
88
+ None,
89
+ description="If set, users cannot change theme - always uses this theme ID"
90
+ )
91
+ distribution_mode: str = Field(
92
+ "all",
93
+ description="Distribution mode: 'all', 'download_only', or 'cloud_only'"
94
+ )
95
+ brand_prefix: Optional[str] = Field(
96
+ None, description="Prefix for output filenames (e.g., 'VSTAR')"
97
+ )
98
+ youtube_description_template: Optional[str] = Field(
99
+ None, description="Default YouTube description template"
100
+ )
101
+
102
+
103
+ class TenantAuth(BaseModel):
104
+ """Authentication configuration for a tenant."""
105
+
106
+ allowed_email_domains: List[str] = Field(
107
+ default_factory=list,
108
+ description="Email domains allowed for magic link auth (e.g., ['vocal-star.com'])"
109
+ )
110
+ require_email_domain: bool = Field(
111
+ True,
112
+ description="If true, only allowed domains can sign up. If false, domains get auto-approved."
113
+ )
114
+ fixed_token_ids: List[str] = Field(
115
+ default_factory=list,
116
+ description="IDs of fixed API tokens for this tenant (tokens stored in auth_tokens)"
117
+ )
118
+ sender_email: Optional[str] = Field(
119
+ None,
120
+ description="Email sender address for this tenant (e.g., 'vocalstar@nomadkaraoke.com')"
121
+ )
122
+
123
+
124
+ class TenantConfig(BaseModel):
125
+ """Complete configuration for a white-label tenant."""
126
+
127
+ id: str = Field(..., description="Unique tenant identifier (e.g., 'vocalstar')")
128
+ name: str = Field(..., description="Display name (e.g., 'Vocal Star')")
129
+ subdomain: str = Field(
130
+ ..., description="Full subdomain (e.g., 'vocalstar.nomadkaraoke.com')"
131
+ )
132
+ is_active: bool = Field(True, description="Whether tenant portal is active")
133
+
134
+ branding: TenantBranding = Field(default_factory=TenantBranding)
135
+ features: TenantFeatures = Field(default_factory=TenantFeatures)
136
+ defaults: TenantDefaults = Field(default_factory=TenantDefaults)
137
+ auth: TenantAuth = Field(default_factory=TenantAuth)
138
+
139
+ created_at: Optional[datetime] = None
140
+ updated_at: Optional[datetime] = None
141
+
142
+ def get_sender_email(self) -> str:
143
+ """Get the email sender address for this tenant."""
144
+ if self.auth.sender_email:
145
+ return self.auth.sender_email
146
+ # Default pattern: {tenant_id}@nomadkaraoke.com
147
+ return f"{self.id}@nomadkaraoke.com"
148
+
149
+ def is_email_allowed(self, email: str) -> bool:
150
+ """Check if an email address is allowed for this tenant."""
151
+ if not self.auth.allowed_email_domains:
152
+ # No domain restrictions
153
+ return True
154
+
155
+ email_lower = email.lower()
156
+ for domain in self.auth.allowed_email_domains:
157
+ if email_lower.endswith(f"@{domain.lower()}"):
158
+ return True
159
+
160
+ return not self.auth.require_email_domain
161
+
162
+
163
+ class TenantPublicConfig(BaseModel):
164
+ """
165
+ Public tenant configuration returned to frontend.
166
+
167
+ This excludes sensitive auth details like token IDs.
168
+ """
169
+
170
+ id: str
171
+ name: str
172
+ subdomain: str
173
+ is_active: bool
174
+ branding: TenantBranding
175
+ features: TenantFeatures
176
+ defaults: TenantDefaults
177
+ # Auth info limited to what frontend needs
178
+ allowed_email_domains: List[str] = Field(default_factory=list)
179
+
180
+ @classmethod
181
+ def from_config(cls, config: TenantConfig) -> "TenantPublicConfig":
182
+ """Create public config from full config."""
183
+ return cls(
184
+ id=config.id,
185
+ name=config.name,
186
+ subdomain=config.subdomain,
187
+ is_active=config.is_active,
188
+ branding=config.branding,
189
+ features=config.features,
190
+ defaults=TenantDefaults(
191
+ theme_id=config.defaults.theme_id,
192
+ locked_theme=config.defaults.locked_theme,
193
+ distribution_mode=config.defaults.distribution_mode,
194
+ # Don't expose brand_prefix or youtube_description_template
195
+ ),
196
+ allowed_email_domains=config.auth.allowed_email_domains,
197
+ )
198
+
199
+
200
+ class TenantConfigResponse(BaseModel):
201
+ """Response from GET /api/tenant/config endpoint."""
202
+
203
+ tenant: Optional[TenantPublicConfig] = Field(
204
+ None, description="Tenant config if found, null for default Nomad Karaoke"
205
+ )
206
+ is_default: bool = Field(
207
+ True, description="True if using default Nomad Karaoke config"
208
+ )
backend/models/user.py CHANGED
@@ -45,11 +45,17 @@ class User(BaseModel):
45
45
 
46
46
  Users are identified by email address. Authentication is via magic links
47
47
  (no passwords). Credits are consumed when creating karaoke jobs.
48
+
49
+ For multi-tenant white-label portals, users are scoped to a tenant.
50
+ Users with tenant_id=None are default Nomad Karaoke users.
48
51
  """
49
52
  email: str # Primary identifier
50
53
  role: UserRole = UserRole.USER
51
54
  credits: int = 0
52
55
 
56
+ # Multi-tenant support (None = default Nomad Karaoke)
57
+ tenant_id: Optional[str] = None
58
+
53
59
  # Stripe integration
54
60
  stripe_customer_id: Optional[str] = None
55
61
 
@@ -87,6 +93,9 @@ class MagicLinkToken(BaseModel):
87
93
 
88
94
  Tokens are short-lived (15 minutes) and single-use.
89
95
  Stored in Firestore with automatic TTL.
96
+
97
+ For multi-tenant portals, the tenant_id determines which portal
98
+ the user will be redirected to after verification.
90
99
  """
91
100
  token: str # Secure random token
92
101
  email: str
@@ -97,6 +106,9 @@ class MagicLinkToken(BaseModel):
97
106
  ip_address: Optional[str] = None
98
107
  user_agent: Optional[str] = None
99
108
 
109
+ # Multi-tenant support (None = default Nomad Karaoke)
110
+ tenant_id: Optional[str] = None
111
+
100
112
 
101
113
  class Session(BaseModel):
102
114
  """
@@ -104,6 +116,8 @@ class Session(BaseModel):
104
116
 
105
117
  Sessions are created after successful magic link verification.
106
118
  Sessions expire after 7 days of inactivity or 30 days absolute.
119
+
120
+ For multi-tenant portals, sessions are scoped to a tenant.
107
121
  """
108
122
  token: str # Secure random session token
109
123
  user_email: str
@@ -114,6 +128,9 @@ class Session(BaseModel):
114
128
  user_agent: Optional[str] = None
115
129
  is_active: bool = True
116
130
 
131
+ # Multi-tenant support (None = default Nomad Karaoke)
132
+ tenant_id: Optional[str] = None
133
+
117
134
 
118
135
  # Pydantic models for API requests/responses
119
136
 
@@ -149,6 +166,7 @@ class UserPublic(BaseModel):
149
166
  display_name: Optional[str] = None
150
167
  total_jobs_created: int = 0
151
168
  total_jobs_completed: int = 0
169
+ tenant_id: Optional[str] = None
152
170
 
153
171
 
154
172
  class AddCreditsRequest(BaseModel):