@weirdfingers/baseboards 0.2.0

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 (139) hide show
  1. package/README.md +191 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +887 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +64 -0
  6. package/templates/README.md +120 -0
  7. package/templates/api/.env.example +62 -0
  8. package/templates/api/Dockerfile +32 -0
  9. package/templates/api/README.md +132 -0
  10. package/templates/api/alembic/env.py +106 -0
  11. package/templates/api/alembic/script.py.mako +28 -0
  12. package/templates/api/alembic/versions/20250101_000000_initial_schema.py +448 -0
  13. package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +71 -0
  14. package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +411 -0
  15. package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +85 -0
  16. package/templates/api/alembic.ini +36 -0
  17. package/templates/api/config/generators.yaml +25 -0
  18. package/templates/api/config/storage_config.yaml +26 -0
  19. package/templates/api/docs/ADDING_GENERATORS.md +409 -0
  20. package/templates/api/docs/GENERATORS_API.md +502 -0
  21. package/templates/api/docs/MIGRATIONS.md +472 -0
  22. package/templates/api/docs/storage_providers.md +337 -0
  23. package/templates/api/pyproject.toml +165 -0
  24. package/templates/api/src/boards/__init__.py +10 -0
  25. package/templates/api/src/boards/api/app.py +171 -0
  26. package/templates/api/src/boards/api/auth.py +75 -0
  27. package/templates/api/src/boards/api/endpoints/__init__.py +3 -0
  28. package/templates/api/src/boards/api/endpoints/jobs.py +76 -0
  29. package/templates/api/src/boards/api/endpoints/setup.py +505 -0
  30. package/templates/api/src/boards/api/endpoints/sse.py +129 -0
  31. package/templates/api/src/boards/api/endpoints/storage.py +74 -0
  32. package/templates/api/src/boards/api/endpoints/tenant_registration.py +296 -0
  33. package/templates/api/src/boards/api/endpoints/webhooks.py +13 -0
  34. package/templates/api/src/boards/auth/__init__.py +15 -0
  35. package/templates/api/src/boards/auth/adapters/__init__.py +20 -0
  36. package/templates/api/src/boards/auth/adapters/auth0.py +220 -0
  37. package/templates/api/src/boards/auth/adapters/base.py +73 -0
  38. package/templates/api/src/boards/auth/adapters/clerk.py +172 -0
  39. package/templates/api/src/boards/auth/adapters/jwt.py +122 -0
  40. package/templates/api/src/boards/auth/adapters/none.py +102 -0
  41. package/templates/api/src/boards/auth/adapters/oidc.py +284 -0
  42. package/templates/api/src/boards/auth/adapters/supabase.py +110 -0
  43. package/templates/api/src/boards/auth/context.py +35 -0
  44. package/templates/api/src/boards/auth/factory.py +115 -0
  45. package/templates/api/src/boards/auth/middleware.py +221 -0
  46. package/templates/api/src/boards/auth/provisioning.py +129 -0
  47. package/templates/api/src/boards/auth/tenant_extraction.py +278 -0
  48. package/templates/api/src/boards/cli.py +354 -0
  49. package/templates/api/src/boards/config.py +116 -0
  50. package/templates/api/src/boards/database/__init__.py +7 -0
  51. package/templates/api/src/boards/database/cli.py +110 -0
  52. package/templates/api/src/boards/database/connection.py +252 -0
  53. package/templates/api/src/boards/database/models.py +19 -0
  54. package/templates/api/src/boards/database/seed_data.py +182 -0
  55. package/templates/api/src/boards/dbmodels/__init__.py +455 -0
  56. package/templates/api/src/boards/generators/__init__.py +57 -0
  57. package/templates/api/src/boards/generators/artifacts.py +53 -0
  58. package/templates/api/src/boards/generators/base.py +140 -0
  59. package/templates/api/src/boards/generators/implementations/__init__.py +12 -0
  60. package/templates/api/src/boards/generators/implementations/audio/__init__.py +3 -0
  61. package/templates/api/src/boards/generators/implementations/audio/whisper.py +66 -0
  62. package/templates/api/src/boards/generators/implementations/image/__init__.py +3 -0
  63. package/templates/api/src/boards/generators/implementations/image/dalle3.py +93 -0
  64. package/templates/api/src/boards/generators/implementations/image/flux_pro.py +85 -0
  65. package/templates/api/src/boards/generators/implementations/video/__init__.py +3 -0
  66. package/templates/api/src/boards/generators/implementations/video/lipsync.py +70 -0
  67. package/templates/api/src/boards/generators/loader.py +253 -0
  68. package/templates/api/src/boards/generators/registry.py +114 -0
  69. package/templates/api/src/boards/generators/resolution.py +515 -0
  70. package/templates/api/src/boards/generators/testmods/class_gen.py +34 -0
  71. package/templates/api/src/boards/generators/testmods/import_side_effect.py +35 -0
  72. package/templates/api/src/boards/graphql/__init__.py +7 -0
  73. package/templates/api/src/boards/graphql/access_control.py +136 -0
  74. package/templates/api/src/boards/graphql/mutations/root.py +136 -0
  75. package/templates/api/src/boards/graphql/queries/root.py +116 -0
  76. package/templates/api/src/boards/graphql/resolvers/__init__.py +8 -0
  77. package/templates/api/src/boards/graphql/resolvers/auth.py +12 -0
  78. package/templates/api/src/boards/graphql/resolvers/board.py +1055 -0
  79. package/templates/api/src/boards/graphql/resolvers/generation.py +889 -0
  80. package/templates/api/src/boards/graphql/resolvers/generator.py +50 -0
  81. package/templates/api/src/boards/graphql/resolvers/user.py +25 -0
  82. package/templates/api/src/boards/graphql/schema.py +81 -0
  83. package/templates/api/src/boards/graphql/types/board.py +102 -0
  84. package/templates/api/src/boards/graphql/types/generation.py +130 -0
  85. package/templates/api/src/boards/graphql/types/generator.py +17 -0
  86. package/templates/api/src/boards/graphql/types/user.py +47 -0
  87. package/templates/api/src/boards/jobs/repository.py +104 -0
  88. package/templates/api/src/boards/logging.py +195 -0
  89. package/templates/api/src/boards/middleware.py +339 -0
  90. package/templates/api/src/boards/progress/__init__.py +4 -0
  91. package/templates/api/src/boards/progress/models.py +25 -0
  92. package/templates/api/src/boards/progress/publisher.py +64 -0
  93. package/templates/api/src/boards/py.typed +0 -0
  94. package/templates/api/src/boards/redis_pool.py +118 -0
  95. package/templates/api/src/boards/storage/__init__.py +52 -0
  96. package/templates/api/src/boards/storage/base.py +363 -0
  97. package/templates/api/src/boards/storage/config.py +187 -0
  98. package/templates/api/src/boards/storage/factory.py +278 -0
  99. package/templates/api/src/boards/storage/implementations/__init__.py +27 -0
  100. package/templates/api/src/boards/storage/implementations/gcs.py +340 -0
  101. package/templates/api/src/boards/storage/implementations/local.py +201 -0
  102. package/templates/api/src/boards/storage/implementations/s3.py +294 -0
  103. package/templates/api/src/boards/storage/implementations/supabase.py +218 -0
  104. package/templates/api/src/boards/tenant_isolation.py +446 -0
  105. package/templates/api/src/boards/validation.py +262 -0
  106. package/templates/api/src/boards/workers/__init__.py +1 -0
  107. package/templates/api/src/boards/workers/actors.py +201 -0
  108. package/templates/api/src/boards/workers/cli.py +125 -0
  109. package/templates/api/src/boards/workers/context.py +188 -0
  110. package/templates/api/src/boards/workers/middleware.py +58 -0
  111. package/templates/api/src/py.typed +0 -0
  112. package/templates/compose.dev.yaml +39 -0
  113. package/templates/compose.yaml +109 -0
  114. package/templates/docker/env.example +23 -0
  115. package/templates/web/.env.example +28 -0
  116. package/templates/web/Dockerfile +51 -0
  117. package/templates/web/components.json +22 -0
  118. package/templates/web/imageLoader.js +18 -0
  119. package/templates/web/next-env.d.ts +5 -0
  120. package/templates/web/next.config.js +36 -0
  121. package/templates/web/package.json +37 -0
  122. package/templates/web/postcss.config.mjs +7 -0
  123. package/templates/web/public/favicon.ico +0 -0
  124. package/templates/web/src/app/boards/[boardId]/page.tsx +232 -0
  125. package/templates/web/src/app/globals.css +120 -0
  126. package/templates/web/src/app/layout.tsx +21 -0
  127. package/templates/web/src/app/page.tsx +35 -0
  128. package/templates/web/src/app/providers.tsx +18 -0
  129. package/templates/web/src/components/boards/ArtifactInputSlots.tsx +142 -0
  130. package/templates/web/src/components/boards/ArtifactPreview.tsx +125 -0
  131. package/templates/web/src/components/boards/GenerationGrid.tsx +45 -0
  132. package/templates/web/src/components/boards/GenerationInput.tsx +251 -0
  133. package/templates/web/src/components/boards/GeneratorSelector.tsx +89 -0
  134. package/templates/web/src/components/header.tsx +30 -0
  135. package/templates/web/src/components/ui/button.tsx +58 -0
  136. package/templates/web/src/components/ui/card.tsx +92 -0
  137. package/templates/web/src/components/ui/navigation-menu.tsx +168 -0
  138. package/templates/web/src/lib/utils.ts +6 -0
  139. package/templates/web/tsconfig.json +47 -0
@@ -0,0 +1,195 @@
1
+ """
2
+ Centralized logging configuration using structlog
3
+ """
4
+
5
+ import base64
6
+ import logging
7
+ import secrets
8
+ import sys
9
+ import time
10
+ from contextvars import ContextVar
11
+ from typing import Any
12
+
13
+ import structlog
14
+ from fastapi import Request
15
+
16
+ # Context variables for request tracking
17
+ request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None)
18
+ user_id_ctx: ContextVar[str | None] = ContextVar("user_id", default=None)
19
+
20
+
21
+ class RequestContextFilter:
22
+ """Add request context to log records."""
23
+
24
+ def __call__(self, logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
25
+ """Add request context to the event dict."""
26
+ # Suppress unused parameter warnings - these are required by structlog interface
27
+ _ = logger, method_name
28
+
29
+ request_id = request_id_ctx.get()
30
+ user_id = user_id_ctx.get()
31
+
32
+ if request_id:
33
+ event_dict["request_id"] = request_id
34
+
35
+ if user_id:
36
+ event_dict["user_id"] = user_id
37
+
38
+ return event_dict
39
+
40
+
41
+ def configure_logging(debug: bool = False) -> None:
42
+ """Configure structlog with appropriate processors and formatters.
43
+
44
+ Args:
45
+ debug: If True, use human-readable console output. If False, use JSON.
46
+ """
47
+
48
+ # Determine log level
49
+ log_level = logging.DEBUG if debug else logging.INFO
50
+
51
+ # Configure stdlib logging
52
+ logging.basicConfig(
53
+ level=log_level,
54
+ stream=sys.stdout,
55
+ format="%(message)s",
56
+ force=True,
57
+ )
58
+
59
+ # Configure structlog processors
60
+ processors = [
61
+ # Filter out keys with underscores (internal)
62
+ structlog.stdlib.filter_by_level,
63
+ # Add logger name to event dict
64
+ structlog.stdlib.add_logger_name,
65
+ # Add log level to event dict
66
+ structlog.stdlib.add_log_level,
67
+ # Add request context
68
+ RequestContextFilter(),
69
+ # Add timestamp
70
+ structlog.processors.TimeStamper(fmt="ISO", utc=True),
71
+ # Perform %-style string formatting
72
+ structlog.stdlib.PositionalArgumentsFormatter(),
73
+ # Stack info processor (for exceptions)
74
+ structlog.processors.StackInfoRenderer(),
75
+ # Exception info processor
76
+ structlog.processors.format_exc_info,
77
+ # Unicode decoder processor
78
+ structlog.processors.UnicodeDecoder(),
79
+ ]
80
+
81
+ if debug:
82
+ # Development: human-readable console output
83
+ processors.append(structlog.dev.ConsoleRenderer(colors=True))
84
+ else:
85
+ # Production: JSON output
86
+ processors.append(structlog.processors.JSONRenderer())
87
+
88
+ # Configure structlog
89
+ structlog.configure(
90
+ processors=processors,
91
+ wrapper_class=structlog.stdlib.BoundLogger,
92
+ logger_factory=structlog.stdlib.LoggerFactory(),
93
+ context_class=dict,
94
+ cache_logger_on_first_use=True,
95
+ )
96
+
97
+
98
+ def get_logger(name: str) -> structlog.BoundLogger:
99
+ """Get a structlog logger instance.
100
+
101
+ Args:
102
+ name: Logger name (typically __name__)
103
+
104
+ Returns:
105
+ Configured structlog logger
106
+ """
107
+ return structlog.get_logger(name)
108
+
109
+
110
+ def generate_request_id() -> str:
111
+ """Generate a compact, secure request ID with timestamp and randomness.
112
+
113
+ Uses microsecond precision timestamp with added randomness for security.
114
+ Format: 11-character base64 string (e.g., 'Ab3X9mF2xYz')
115
+
116
+ Provides high uniqueness probability while preventing predictable enumeration.
117
+ """
118
+ # Get microseconds since epoch (8 bytes when encoded as int64)
119
+ timestamp_us = int(time.time() * 1_000_000)
120
+
121
+ # Add 2 bytes of cryptographically secure randomness
122
+ random_bytes = secrets.token_bytes(2)
123
+
124
+ # Convert timestamp to bytes (8 bytes for int64) and combine with random bytes
125
+ timestamp_bytes = timestamp_us.to_bytes(8, byteorder="big")
126
+ combined_bytes = timestamp_bytes + random_bytes
127
+
128
+ # Encode as base64 and strip padding
129
+ b64 = base64.urlsafe_b64encode(combined_bytes).decode("ascii").rstrip("=")
130
+
131
+ return b64
132
+
133
+
134
+ def set_request_context(request_id: str | None = None, user_id: str | None = None) -> None:
135
+ """Set request context variables.
136
+
137
+ Args:
138
+ request_id: Request ID to set (generates one if None)
139
+ user_id: User ID to set
140
+ """
141
+ if request_id is None:
142
+ request_id = generate_request_id()
143
+
144
+ request_id_ctx.set(request_id)
145
+ if user_id is not None:
146
+ user_id_ctx.set(user_id)
147
+
148
+
149
+ def clear_request_context() -> None:
150
+ """Clear request context variables."""
151
+ request_id_ctx.set(None)
152
+ user_id_ctx.set(None)
153
+
154
+
155
+ def get_request_id() -> str | None:
156
+ """Get the current request ID."""
157
+ return request_id_ctx.get()
158
+
159
+
160
+ def get_user_id() -> str | None:
161
+ """Get the current user ID."""
162
+ return user_id_ctx.get()
163
+
164
+
165
+ def extract_user_id_from_request(request: Request) -> str | None:
166
+ """Extract user ID from FastAPI request.
167
+
168
+ This should be customized based on your authentication implementation.
169
+
170
+ Args:
171
+ request: FastAPI request object
172
+
173
+ Returns:
174
+ User ID if authenticated, None otherwise
175
+ """
176
+ # TODO: Implement based on your auth strategy
177
+ # Examples:
178
+ # - JWT token in Authorization header
179
+ # - Session data
180
+ # - Supabase auth token
181
+
182
+ # For now, look for a user ID in headers (customize as needed)
183
+ auth_header = request.headers.get("authorization")
184
+ if auth_header:
185
+ # This is a placeholder - implement actual token parsing
186
+ # For example, if using Bearer tokens:
187
+ # if auth_header.startswith("Bearer "):
188
+ # token = auth_header[7:]
189
+ # # Parse JWT token to extract user_id
190
+ # return parsed_user_id
191
+
192
+ # For now, return None until auth is implemented
193
+ return None
194
+
195
+ return None
@@ -0,0 +1,339 @@
1
+ """
2
+ Middleware for request context and logging
3
+ """
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ from fastapi import Request, Response
11
+ from starlette.middleware.base import BaseHTTPMiddleware
12
+
13
+ from .config import settings
14
+ from .logging import (
15
+ clear_request_context,
16
+ extract_user_id_from_request,
17
+ get_logger,
18
+ set_request_context,
19
+ )
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ def sanitize_query_params(params: dict[str, Any]) -> dict[str, Any]:
25
+ """Remove sensitive query parameters from logging.
26
+
27
+ Args:
28
+ params: Dictionary of query parameters
29
+
30
+ Returns:
31
+ Dictionary with sensitive parameters redacted
32
+ """
33
+ sensitive_keys = {
34
+ "password",
35
+ "token",
36
+ "api_key",
37
+ "secret",
38
+ "auth",
39
+ "authorization",
40
+ "access_token",
41
+ "refresh_token",
42
+ "key",
43
+ "private_key",
44
+ "jwt",
45
+ "session",
46
+ "session_id",
47
+ "cookie",
48
+ "credentials",
49
+ }
50
+
51
+ sanitized = {}
52
+ for key, value in params.items():
53
+ # Check if any sensitive keyword is in the parameter name (case insensitive)
54
+ if any(sensitive in key.lower() for sensitive in sensitive_keys):
55
+ sanitized[key] = "[REDACTED]"
56
+ else:
57
+ sanitized[key] = value
58
+
59
+ return sanitized
60
+
61
+
62
+ # 1) Extend extractor to support GET /graphql as well
63
+ async def extract_graphql_operation_name(request: Request) -> str | None:
64
+ if request.url.path != "/graphql":
65
+ return None
66
+
67
+ # Handle GET /graphql (operationName in query params, or parse from query)
68
+ if request.method == "GET":
69
+ params = dict(request.query_params)
70
+ op = params.get("operationName")
71
+ if isinstance(op, str) and op:
72
+ return op
73
+
74
+ q = params.get("query", "")
75
+ if not isinstance(q, str) or not q:
76
+ return None
77
+ if "__schema" in q or "IntrospectionQuery" in q:
78
+ return "__introspection"
79
+
80
+ match = re.search(r"\bquery\s+(\w+)", q) or re.search(r"\bmutation\s+(\w+)", q)
81
+ if match:
82
+ kind = "mutation:" if q.lstrip().startswith("mutation") else ""
83
+ return f"{kind}{match.group(1)}"
84
+ return "unnamed_operation"
85
+
86
+ # Existing POST /graphql logic
87
+ if request.method == "POST":
88
+ try:
89
+ body = await request.body()
90
+ if not body:
91
+ return None
92
+ data = json.loads(body)
93
+ op = data.get("operationName")
94
+ if isinstance(op, str) and op:
95
+ return op
96
+ q = data.get("query", "")
97
+ if not isinstance(q, str) or not q:
98
+ return None
99
+ if "__schema" in q or "IntrospectionQuery" in q:
100
+ return "__introspection"
101
+ m = re.search(r"\bquery\s+(\w+)", q) or re.search(r"\bmutation\s+(\w+)", q)
102
+ if m:
103
+ kind = "mutation:" if q.lstrip().startswith("mutation") else ""
104
+ return f"{kind}{m.group(1)}"
105
+ return "unnamed_operation"
106
+ except (json.JSONDecodeError, TypeError):
107
+ return None
108
+
109
+ return None
110
+
111
+
112
+ class LoggingContextMiddleware(BaseHTTPMiddleware):
113
+ """Middleware to set logging context for each request."""
114
+
115
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
116
+ """Process request and set logging context."""
117
+
118
+ # Extract user ID from request (implement based on your auth)
119
+ user_id = extract_user_id_from_request(request)
120
+
121
+ # Set request context
122
+ set_request_context(user_id=user_id)
123
+
124
+ try:
125
+ # Sanitize query parameters for logging
126
+ sanitized_params = None
127
+ if request.query_params:
128
+ sanitized_params = sanitize_query_params(dict(request.query_params))
129
+ # If hitting /graphql, never log raw GraphQL payload in query string
130
+ # Comment this out to see the raw GraphQL payload in the query string
131
+ if request.url.path == "/graphql" and isinstance(sanitized_params, dict):
132
+ for k in ("query", "variables", "extensions"):
133
+ if k in sanitized_params:
134
+ sanitized_params[k] = "[REDACTED]"
135
+
136
+ # Extract GraphQL operation name if applicable
137
+ graphql_operation = await extract_graphql_operation_name(request)
138
+
139
+ # Log request start
140
+ log_data = {
141
+ "method": request.method,
142
+ "path": request.url.path,
143
+ "query_params": sanitized_params,
144
+ "user_agent": request.headers.get("user-agent"),
145
+ "remote_addr": request.client.host if request.client else None,
146
+ }
147
+
148
+ # Add GraphQL operation name if available
149
+ if graphql_operation:
150
+ log_data["graphql_operation"] = graphql_operation
151
+
152
+ logger.info("Request started", **log_data)
153
+
154
+ # Process request
155
+ response = await call_next(request)
156
+
157
+ # Log request completion
158
+ logger.info(
159
+ "Request completed",
160
+ status_code=response.status_code,
161
+ method=request.method,
162
+ path=request.url.path,
163
+ graphql_operation=graphql_operation, # Include in completion log too
164
+ )
165
+
166
+ return response
167
+
168
+ except Exception as e:
169
+ # Log request error
170
+ logger.error(
171
+ "Request failed",
172
+ method=request.method,
173
+ path=request.url.path,
174
+ error=str(e),
175
+ )
176
+ raise
177
+
178
+ finally:
179
+ # Clear context
180
+ clear_request_context()
181
+
182
+
183
+ class TenantRoutingMiddleware(BaseHTTPMiddleware):
184
+ """
185
+ Middleware for tenant-aware request routing and validation.
186
+
187
+ This middleware:
188
+ 1. Validates X-Tenant headers in multi-tenant mode
189
+ 2. Enforces tenant isolation rules
190
+ 3. Sets up request context for tenant-scoped operations
191
+ 4. Provides early tenant validation before auth processing
192
+ """
193
+
194
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
195
+ """Process request with tenant routing and validation."""
196
+
197
+ # Extract tenant information from headers
198
+ x_tenant = request.headers.get("X-Tenant")
199
+
200
+ # Log incoming request with tenant info
201
+ logger.debug(
202
+ "Processing request with tenant context",
203
+ method=request.method,
204
+ path=request.url.path,
205
+ x_tenant=x_tenant,
206
+ multi_tenant_mode=settings.multi_tenant_mode,
207
+ )
208
+
209
+ # Validate tenant header in multi-tenant mode
210
+ if settings.multi_tenant_mode:
211
+ tenant_validation_result = await self._validate_tenant_header(x_tenant, request)
212
+ if tenant_validation_result is not None:
213
+ return tenant_validation_result
214
+
215
+ # Add tenant context to request state for downstream use
216
+ request.state.tenant_slug = x_tenant or settings.default_tenant_slug
217
+ request.state.multi_tenant_mode = settings.multi_tenant_mode
218
+
219
+ try:
220
+ response = await call_next(request)
221
+
222
+ # Add tenant information to response headers for debugging
223
+ if settings.debug:
224
+ response.headers["X-Tenant-Resolved"] = request.state.tenant_slug
225
+ if settings.multi_tenant_mode:
226
+ response.headers["X-Multi-Tenant-Mode"] = "true"
227
+
228
+ return response
229
+
230
+ except Exception as e:
231
+ logger.error(
232
+ "Request processing failed",
233
+ error=str(e),
234
+ tenant_slug=getattr(request.state, "tenant_slug", "unknown"),
235
+ path=request.url.path,
236
+ )
237
+ raise
238
+
239
+ async def _validate_tenant_header(
240
+ self, x_tenant: str | None, request: Request
241
+ ) -> Response | None:
242
+ """
243
+ Validate X-Tenant header in multi-tenant mode.
244
+
245
+ Returns:
246
+ Response if validation fails (error response), None if validation passes
247
+ """
248
+ from fastapi.responses import JSONResponse
249
+
250
+ # In multi-tenant mode, some endpoints may require X-Tenant header
251
+ if self._requires_tenant_header(request):
252
+ if not x_tenant:
253
+ logger.warning(
254
+ "Missing required X-Tenant header in multi-tenant mode",
255
+ path=request.url.path,
256
+ method=request.method,
257
+ )
258
+ return JSONResponse(
259
+ status_code=400,
260
+ content={
261
+ "error": "Missing X-Tenant header",
262
+ "detail": (
263
+ "X-Tenant header is required in multi-tenant mode " "for this endpoint"
264
+ ),
265
+ "multi_tenant_mode": True,
266
+ },
267
+ )
268
+
269
+ # Validate tenant slug format if provided
270
+ if x_tenant:
271
+ validation_error = self._validate_tenant_slug_format(x_tenant)
272
+ if validation_error:
273
+ logger.warning(
274
+ "Invalid X-Tenant header format",
275
+ x_tenant=x_tenant,
276
+ error=validation_error,
277
+ )
278
+ return JSONResponse(
279
+ status_code=400,
280
+ content={
281
+ "error": "Invalid X-Tenant header format",
282
+ "detail": validation_error,
283
+ "provided_tenant": x_tenant,
284
+ },
285
+ )
286
+
287
+ return None # Validation passed
288
+
289
+ def _requires_tenant_header(self, request: Request) -> bool:
290
+ """
291
+ Determine if the request requires an X-Tenant header.
292
+
293
+ In multi-tenant mode, most API endpoints require tenant specification,
294
+ except for certain system endpoints like health checks.
295
+ """
296
+ path = request.url.path
297
+
298
+ # System endpoints that don't require tenant specification
299
+ system_endpoints = {
300
+ "/health",
301
+ "/api/setup/status",
302
+ "/docs",
303
+ "/redoc",
304
+ "/openapi.json",
305
+ }
306
+
307
+ if path in system_endpoints:
308
+ return False
309
+
310
+ # Setup endpoints are special - they help create tenants
311
+ if path.startswith("/api/setup/"):
312
+ return False
313
+
314
+ # All other API endpoints require tenant in multi-tenant mode
315
+ if path.startswith("/api/") or path.startswith("/graphql"):
316
+ return True
317
+
318
+ return False
319
+
320
+ def _validate_tenant_slug_format(self, tenant_slug: str) -> str | None:
321
+ """
322
+ Validate tenant slug format.
323
+
324
+ Returns:
325
+ Error message if invalid, None if valid
326
+ """
327
+ if not tenant_slug:
328
+ return "Tenant slug cannot be empty"
329
+
330
+ if len(tenant_slug) > 255:
331
+ return "Tenant slug too long (max 255 characters)"
332
+
333
+ if not re.match(r"^[a-z0-9-]+$", tenant_slug):
334
+ return "Tenant slug must contain only lowercase letters, numbers, and hyphens"
335
+
336
+ if tenant_slug.startswith("-") or tenant_slug.endswith("-"):
337
+ return "Tenant slug cannot start or end with hyphen"
338
+
339
+ return None # Valid
@@ -0,0 +1,4 @@
1
+ """Progress publishing and models for generation jobs."""
2
+
3
+ from .models import ArtifactInfo, ProgressUpdate # noqa: F401
4
+ from .publisher import ProgressPublisher # noqa: F401
@@ -0,0 +1,25 @@
1
+ """Pydantic models for progress updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class ArtifactInfo(BaseModel):
12
+ url: str
13
+ type: str
14
+ metadata: dict = {}
15
+
16
+
17
+ class ProgressUpdate(BaseModel):
18
+ job_id: str
19
+ status: str # Use string to avoid tight coupling to GraphQL enums
20
+ progress: float
21
+ phase: Literal["queued", "initializing", "processing", "finalizing"]
22
+ message: str | None = None
23
+ estimated_completion: datetime | None = None
24
+ artifacts: list[ArtifactInfo] = []
25
+ timestamp: datetime = datetime.now(UTC)
@@ -0,0 +1,64 @@
1
+ """Publisher for progress updates via Redis pub/sub with DB persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..config import Settings
6
+ from ..database.connection import get_async_session
7
+ from ..jobs import repository as jobs_repo
8
+ from ..logging import get_logger
9
+ from ..redis_pool import get_redis_client
10
+ from .models import ProgressUpdate
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class ProgressPublisher:
16
+ def __init__(self, settings: Settings | None = None) -> None:
17
+ self.settings = settings or Settings()
18
+ # Use the shared Redis connection pool
19
+ self._redis = get_redis_client()
20
+
21
+ async def publish_progress(self, job_id: str, update: ProgressUpdate) -> None:
22
+ """Publish progress update to Redis and persist to database."""
23
+ channel = f"job:{job_id}:progress"
24
+ await self._persist_update(job_id, update)
25
+ json_data = update.model_dump_json()
26
+ logger.info(
27
+ "Publishing progress update to Redis",
28
+ job_id=job_id,
29
+ channel=channel,
30
+ status=update.status,
31
+ progress=update.progress,
32
+ data_length=len(json_data),
33
+ )
34
+ await self._redis.publish(channel, json_data)
35
+ logger.debug("Progress update published successfully", job_id=job_id)
36
+
37
+ async def publish_only(self, job_id: str, update: ProgressUpdate) -> None:
38
+ """Publish progress update to Redis without persisting to database.
39
+
40
+ Use this when the database has already been updated separately,
41
+ e.g., after calling finalize_success in the repository.
42
+ """
43
+ channel = f"job:{job_id}:progress"
44
+ json_data = update.model_dump_json()
45
+ logger.info(
46
+ "Publishing progress update to Redis (no DB persist)",
47
+ job_id=job_id,
48
+ channel=channel,
49
+ status=update.status,
50
+ progress=update.progress,
51
+ data_length=len(json_data),
52
+ )
53
+ await self._redis.publish(channel, json_data)
54
+ logger.debug("Progress update published successfully", job_id=job_id)
55
+
56
+ async def _persist_update(self, job_id: str, update: ProgressUpdate) -> None:
57
+ async with get_async_session() as session:
58
+ await jobs_repo.update_progress(
59
+ session,
60
+ generation_id=job_id,
61
+ status=update.status,
62
+ progress=(update.progress * 100 if update.progress <= 1.0 else update.progress),
63
+ error_message=update.message if update.status == "failed" else None,
64
+ )
File without changes