@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,129 @@
1
+ """
2
+ Server-Sent Events (SSE) endpoints for real-time generation progress.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Request
10
+ from fastapi.responses import StreamingResponse
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from ...config import Settings
14
+ from ...database.connection import get_db_session
15
+ from ...jobs import repository as jobs_repo
16
+ from ...logging import get_logger
17
+ from ...redis_pool import get_redis_client
18
+ from ..auth import AuthenticatedUser, get_current_user
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ router = APIRouter()
24
+ _settings = Settings()
25
+ # Use the shared Redis connection pool
26
+ _redis = get_redis_client()
27
+
28
+
29
+ @router.get("/generations/{generation_id}/progress")
30
+ async def generation_progress_stream(
31
+ generation_id: str,
32
+ request: Request,
33
+ db: AsyncSession = Depends(get_db_session),
34
+ current_user: AuthenticatedUser = Depends(get_current_user),
35
+ ):
36
+ """Server-sent events for job progress, backed by Redis pub/sub.
37
+
38
+ Requires authentication. Users can only monitor progress for their own generations
39
+ or generations within their tenant (depending on access control policy).
40
+ """
41
+
42
+ logger.info(
43
+ "SSE: generation progress stream requested",
44
+ generation_id=generation_id,
45
+ url=request.url,
46
+ )
47
+ # Verify user has access to this generation
48
+ try:
49
+ generation = await jobs_repo.get_generation(db, generation_id)
50
+
51
+ # Check if user owns this generation or belongs to the same tenant
52
+ if generation.user_id != current_user.user_id:
53
+ # Allow tenant-level access (users in same tenant can see each other's jobs)
54
+ # You may want to make this more restrictive based on your requirements
55
+ if generation.tenant_id != current_user.tenant_id:
56
+ logger.warning(
57
+ "User attempted to access generation belonging to different user",
58
+ user_id=str(current_user.user_id),
59
+ generation_id=generation_id,
60
+ owner_user_id=str(generation.user_id),
61
+ )
62
+ raise HTTPException(
63
+ status_code=403,
64
+ detail="You don't have permission to access this generation",
65
+ )
66
+
67
+ logger.info(
68
+ "User connected to progress stream",
69
+ user_id=str(current_user.user_id),
70
+ generation_id=generation_id,
71
+ )
72
+
73
+ except HTTPException:
74
+ raise
75
+ except Exception as e:
76
+ logger.error(
77
+ "Failed to verify access to generation",
78
+ generation_id=generation_id,
79
+ error=str(e),
80
+ )
81
+ raise HTTPException(status_code=404, detail="Generation not found") from e
82
+
83
+ channel = f"job:{generation_id}:progress"
84
+
85
+ async def event_stream():
86
+ pubsub = _redis.pubsub()
87
+ await pubsub.subscribe(channel)
88
+ logger.info(
89
+ "SSE: Subscribed to Redis channel",
90
+ generation_id=generation_id,
91
+ channel=channel,
92
+ )
93
+ try:
94
+ while True:
95
+ if await request.is_disconnected():
96
+ logger.info(
97
+ "Client disconnected from progress stream",
98
+ generation_id=generation_id,
99
+ )
100
+ break
101
+ msg = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
102
+ if msg:
103
+ logger.info(
104
+ "SSE: pubsub message received",
105
+ message=msg,
106
+ msg_type=msg.get("type"),
107
+ )
108
+ if msg and msg.get("type") == "message":
109
+ data = msg["data"]
110
+ logger.info(
111
+ "SSE: sending progress data to client",
112
+ generation_id=generation_id,
113
+ data_preview=(data[:100] if isinstance(data, str) else str(data)[:100]),
114
+ )
115
+ yield f"data: {data}\n\n"
116
+ else:
117
+ # Send keep-alive every 15 seconds to prevent timeout
118
+ await asyncio.sleep(15)
119
+ logger.debug("SSE: sending keep-alive", generation_id=generation_id)
120
+ yield ": keep-alive\n\n"
121
+ finally:
122
+ logger.info(
123
+ "SSE: Cleaning up stream",
124
+ generation_id=generation_id,
125
+ )
126
+ await pubsub.unsubscribe(channel)
127
+ await pubsub.close()
128
+
129
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
@@ -0,0 +1,74 @@
1
+ """
2
+ Storage endpoints for file uploads and management
3
+ """
4
+
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+ from fastapi.responses import FileResponse
9
+
10
+ from ...logging import get_logger
11
+ from ...storage.factory import create_storage_manager
12
+ from ...storage.implementations.local import LocalStorageProvider
13
+
14
+ logger = get_logger(__name__)
15
+ router = APIRouter()
16
+
17
+
18
+ @router.get("/status")
19
+ async def storage_status():
20
+ """Storage status endpoint."""
21
+ return {"status": "Storage endpoint ready"}
22
+
23
+
24
+ @router.get("/{full_path:path}")
25
+ async def serve_file(full_path: str):
26
+ """Serve a file from local storage.
27
+
28
+ This endpoint serves files that were uploaded to local storage.
29
+ The full_path includes the tenant_id/artifact_type/board_id/artifact_id/variant structure.
30
+ """
31
+ try:
32
+ logger.info("Serving file", full_path=full_path)
33
+ # Create storage manager to get the configured local storage path
34
+ storage_manager = create_storage_manager()
35
+
36
+ # Get the local provider (assumes 'local' is the provider name)
37
+ local_provider = storage_manager.providers.get("local")
38
+ if not local_provider:
39
+ raise HTTPException(status_code=500, detail="Local storage provider not configured")
40
+
41
+ # Type check: ensure it's a LocalStorageProvider
42
+ # This endpoint only serves local files; cloud providers return direct URLs
43
+ if not isinstance(local_provider, LocalStorageProvider):
44
+ raise HTTPException(
45
+ status_code=500,
46
+ detail="Storage provider does not support local file serving",
47
+ )
48
+
49
+ base_path = local_provider.base_path
50
+ file_path = Path(base_path) / full_path
51
+
52
+ # Security check: ensure the resolved path is within base_path
53
+ try:
54
+ file_path.resolve().relative_to(Path(base_path).resolve())
55
+ except ValueError as e:
56
+ logger.warning("Path traversal attempt detected", requested_path=full_path)
57
+ raise HTTPException(status_code=403, detail="Access denied") from e
58
+
59
+ # Check if file exists
60
+ if not file_path.exists():
61
+ logger.warning("File not found", path=str(file_path))
62
+ raise HTTPException(status_code=404, detail="File not found")
63
+
64
+ if not file_path.is_file():
65
+ raise HTTPException(status_code=400, detail="Path is not a file")
66
+
67
+ # Serve the file
68
+ return FileResponse(file_path)
69
+
70
+ except HTTPException:
71
+ raise
72
+ except Exception as e:
73
+ logger.error("Error serving file", path=full_path, error=str(e))
74
+ raise HTTPException(status_code=500, detail="Internal server error") from e
@@ -0,0 +1,296 @@
1
+ """
2
+ Self-service tenant registration endpoints.
3
+
4
+ This module provides endpoints for organizations to register new tenants
5
+ in multi-tenant mode, with optional approval workflows.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException
13
+ from pydantic import BaseModel, Field, field_validator
14
+
15
+ from ...auth.context import AuthContext
16
+ from ...auth.middleware import get_auth_context
17
+ from ...config import settings
18
+ from ...database.connection import get_async_session
19
+ from ...database.seed_data import ensure_tenant, seed_tenant_with_data
20
+ from ...logging import get_logger
21
+
22
+ logger = get_logger(__name__)
23
+
24
+ router = APIRouter()
25
+
26
+
27
+ class TenantRegistrationRequest(BaseModel):
28
+ """Request model for self-service tenant registration."""
29
+
30
+ organization_name: str = Field(
31
+ ..., min_length=1, max_length=255, description="Organization name for the new tenant"
32
+ )
33
+ organization_slug: str | None = Field(
34
+ None,
35
+ min_length=1,
36
+ max_length=255,
37
+ pattern=r"^[a-z0-9-]+$",
38
+ description="Desired tenant slug (auto-generated if not provided)",
39
+ )
40
+ admin_email: str = Field(..., description="Email of the organization administrator")
41
+ admin_name: str | None = Field(None, description="Full name of the organization administrator")
42
+ use_case: str | None = Field(
43
+ None, max_length=500, description="Brief description of intended use case"
44
+ )
45
+ organization_size: str | None = Field(
46
+ None, description="Size of organization (small, medium, large, enterprise)"
47
+ )
48
+ metadata: dict[str, Any] = Field(
49
+ default_factory=dict, description="Additional registration metadata"
50
+ )
51
+ include_sample_data: bool = Field(
52
+ default=True, description="Whether to include sample boards and data"
53
+ )
54
+
55
+ @field_validator("admin_email")
56
+ @classmethod
57
+ def validate_email(cls, v: str) -> str:
58
+ """Basic email validation."""
59
+ import re
60
+
61
+ if not re.match(r"^[^@]+@[^@]+\.[^@]+$", v):
62
+ raise ValueError("Invalid email format")
63
+ return v.lower()
64
+
65
+ @field_validator("organization_size")
66
+ @classmethod
67
+ def validate_organization_size(cls, v: str | None) -> str | None:
68
+ """Validate organization size values."""
69
+ if v is not None:
70
+ valid_sizes = {"small", "medium", "large", "enterprise"}
71
+ if v.lower() not in valid_sizes:
72
+ raise ValueError(f'Organization size must be one of: {", ".join(valid_sizes)}')
73
+ return v.lower() if v else None
74
+
75
+
76
+ class TenantRegistrationResponse(BaseModel):
77
+ """Response model for tenant registration."""
78
+
79
+ tenant_id: str = Field(..., description="UUID of the registered tenant")
80
+ organization_name: str = Field(..., description="Organization name")
81
+ tenant_slug: str = Field(..., description="Assigned tenant slug")
82
+ status: str = Field(..., description="Registration status")
83
+ admin_instructions: str = Field(..., description="Next steps for the administrator")
84
+ dashboard_url: str | None = Field(None, description="URL to access tenant dashboard")
85
+ api_access: dict[str, Any] = Field(..., description="API access information")
86
+
87
+
88
+ class TenantRegistrationStatus(BaseModel):
89
+ """Model for checking registration status."""
90
+
91
+ enabled: bool = Field(..., description="Whether self-service registration is enabled")
92
+ requires_approval: bool = Field(..., description="Whether registrations require approval")
93
+ max_tenants_per_user: int | None = Field(None, description="Maximum tenants per user")
94
+ allowed_domains: list[str] | None = Field(None, description="Allowed email domains")
95
+
96
+
97
+ @router.get("/registration/status", response_model=TenantRegistrationStatus)
98
+ async def get_registration_status() -> TenantRegistrationStatus:
99
+ """
100
+ Get the current tenant registration status and configuration.
101
+ """
102
+ return TenantRegistrationStatus(
103
+ enabled=settings.multi_tenant_mode,
104
+ requires_approval=getattr(settings, "tenant_registration_requires_approval", False),
105
+ max_tenants_per_user=getattr(settings, "max_tenants_per_user", None),
106
+ allowed_domains=getattr(settings, "tenant_registration_allowed_domains", None),
107
+ )
108
+
109
+
110
+ @router.post("/register", response_model=TenantRegistrationResponse)
111
+ async def register_tenant(
112
+ request: TenantRegistrationRequest,
113
+ auth_context: AuthContext = Depends(get_auth_context),
114
+ ) -> TenantRegistrationResponse:
115
+ """
116
+ Register a new tenant for an organization.
117
+
118
+ This endpoint allows authenticated users to create new tenants for their
119
+ organizations. In multi-tenant mode, this enables self-service onboarding.
120
+
121
+ Requirements:
122
+ - Multi-tenant mode must be enabled
123
+ - User must be authenticated
124
+ - Organization slug must be unique
125
+ - Optional: email domain validation
126
+ - Optional: approval workflow
127
+ """
128
+
129
+ # Validate prerequisites
130
+ if not settings.multi_tenant_mode:
131
+ raise HTTPException(
132
+ status_code=400, detail="Tenant registration is only available in multi-tenant mode"
133
+ )
134
+
135
+ if not auth_context.user_id:
136
+ raise HTTPException(
137
+ status_code=401, detail="Authentication required for tenant registration"
138
+ )
139
+
140
+ # Validate email domain if restrictions are configured
141
+ allowed_domains = getattr(settings, "tenant_registration_allowed_domains", None)
142
+ if allowed_domains:
143
+ admin_domain = request.admin_email.split("@")[1].lower()
144
+ if admin_domain not in allowed_domains:
145
+ raise HTTPException(
146
+ status_code=400,
147
+ detail=f"Email domain '{admin_domain}' is not allowed for registration",
148
+ )
149
+
150
+ # Check if user has reached tenant limit
151
+ max_tenants = getattr(settings, "max_tenants_per_user", None)
152
+ if max_tenants:
153
+ # TODO: Implement tenant count check per user
154
+ logger.warning(
155
+ "Tenant limit check not implemented",
156
+ user_id=str(auth_context.user_id),
157
+ max_tenants=max_tenants,
158
+ )
159
+
160
+ # Generate tenant slug if not provided
161
+ tenant_slug = request.organization_slug
162
+ if not tenant_slug:
163
+ tenant_slug = _generate_slug_from_name(request.organization_name)
164
+
165
+ logger.info(
166
+ "Processing tenant registration request",
167
+ organization_name=request.organization_name,
168
+ tenant_slug=tenant_slug,
169
+ admin_email=request.admin_email,
170
+ user_id=str(auth_context.user_id),
171
+ )
172
+
173
+ try:
174
+ async with get_async_session() as db:
175
+ # Create tenant with sample data
176
+ if request.include_sample_data:
177
+ tenant_id = await seed_tenant_with_data(
178
+ db,
179
+ tenant_name=request.organization_name,
180
+ tenant_slug=tenant_slug,
181
+ tenant_settings={
182
+ "admin_email": request.admin_email,
183
+ "admin_name": request.admin_name,
184
+ "use_case": request.use_case,
185
+ "organization_size": request.organization_size,
186
+ "registered_by": str(auth_context.user_id),
187
+ "registration_metadata": request.metadata,
188
+ },
189
+ include_sample_data=True,
190
+ )
191
+ else:
192
+ tenant_id = await ensure_tenant(
193
+ db,
194
+ name=request.organization_name,
195
+ slug=tenant_slug,
196
+ settings_dict={
197
+ "admin_email": request.admin_email,
198
+ "admin_name": request.admin_name,
199
+ "use_case": request.use_case,
200
+ "organization_size": request.organization_size,
201
+ "registered_by": str(auth_context.user_id),
202
+ "registration_metadata": request.metadata,
203
+ },
204
+ )
205
+
206
+ # Determine status based on approval requirements
207
+ requires_approval = getattr(settings, "tenant_registration_requires_approval", False)
208
+ status = "pending_approval" if requires_approval else "active"
209
+
210
+ # Generate dashboard URL if available
211
+ dashboard_url = _generate_dashboard_url(tenant_slug)
212
+
213
+ logger.info(
214
+ "Tenant registration completed",
215
+ tenant_id=str(tenant_id),
216
+ tenant_slug=tenant_slug,
217
+ status=status,
218
+ admin_email=request.admin_email,
219
+ )
220
+
221
+ return TenantRegistrationResponse(
222
+ tenant_id=str(tenant_id),
223
+ organization_name=request.organization_name,
224
+ tenant_slug=tenant_slug,
225
+ status=status,
226
+ admin_instructions=_generate_admin_instructions(status, tenant_slug),
227
+ dashboard_url=dashboard_url,
228
+ api_access={
229
+ "tenant_header": f"X-Tenant: {tenant_slug}",
230
+ "graphql_endpoint": "/graphql",
231
+ "api_base_url": "/api",
232
+ "authentication_required": True,
233
+ },
234
+ )
235
+
236
+ except Exception as e:
237
+ logger.error(
238
+ "Tenant registration failed",
239
+ error=str(e),
240
+ organization_name=request.organization_name,
241
+ tenant_slug=tenant_slug,
242
+ )
243
+ raise HTTPException(status_code=500, detail=f"Failed to register tenant: {str(e)}") from e
244
+
245
+
246
+ def _generate_slug_from_name(organization_name: str) -> str:
247
+ """Generate a URL-safe slug from organization name."""
248
+ import re
249
+ import uuid
250
+
251
+ # Basic normalization
252
+ slug = organization_name.lower().strip()
253
+
254
+ # Replace spaces and special characters with hyphens
255
+ slug = re.sub(r"[^a-z0-9]+", "-", slug)
256
+
257
+ # Remove multiple consecutive hyphens
258
+ slug = re.sub(r"-+", "-", slug)
259
+
260
+ # Remove leading/trailing hyphens
261
+ slug = slug.strip("-")
262
+
263
+ # Ensure it's not empty
264
+ if not slug or len(slug) < 3:
265
+ slug = f"org-{uuid.uuid4().hex[:8]}"
266
+
267
+ # Ensure it's not too long
268
+ if len(slug) > 50:
269
+ slug = slug[:47] + f"-{uuid.uuid4().hex[:2]}"
270
+
271
+ return slug
272
+
273
+
274
+ def _generate_dashboard_url(tenant_slug: str) -> str | None:
275
+ """Generate dashboard URL for the tenant."""
276
+ # This would typically point to your frontend application
277
+ frontend_base_url = getattr(settings, "frontend_base_url", None)
278
+ if frontend_base_url:
279
+ return f"{frontend_base_url}/?tenant={tenant_slug}"
280
+ return None
281
+
282
+
283
+ def _generate_admin_instructions(status: str, tenant_slug: str) -> str:
284
+ """Generate setup instructions for the tenant administrator."""
285
+ if status == "pending_approval":
286
+ return (
287
+ "Your tenant registration is pending approval. "
288
+ "You will receive an email notification when your tenant is activated. "
289
+ f"Your tenant slug is '{tenant_slug}' - save this for future reference."
290
+ )
291
+ else:
292
+ return (
293
+ f"Your tenant '{tenant_slug}' is ready to use! "
294
+ "Include the X-Tenant header in all API requests. "
295
+ "Visit the dashboard to get started with boards and content generation."
296
+ )
@@ -0,0 +1,13 @@
1
+ """
2
+ Webhook endpoints for external service integrations
3
+ """
4
+
5
+ from fastapi import APIRouter
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("/status")
11
+ async def webhook_status():
12
+ """Webhook status endpoint."""
13
+ return {"status": "Webhook endpoint ready"}
@@ -0,0 +1,15 @@
1
+ """Authentication and authorization system for Boards."""
2
+
3
+ from .adapters.base import AuthAdapter, Principal
4
+ from .context import AuthContext
5
+ from .factory import get_auth_adapter
6
+ from .middleware import get_auth_context, get_auth_context_optional
7
+
8
+ __all__ = [
9
+ "AuthAdapter",
10
+ "Principal",
11
+ "AuthContext",
12
+ "get_auth_context",
13
+ "get_auth_context_optional",
14
+ "get_auth_adapter",
15
+ ]
@@ -0,0 +1,20 @@
1
+ """Authentication adapters for different providers."""
2
+
3
+ from .auth0 import Auth0OIDCAdapter
4
+ from .base import AuthAdapter, Principal
5
+ from .clerk import ClerkAuthAdapter
6
+ from .jwt import JWTAuthAdapter
7
+ from .none import NoAuthAdapter
8
+ from .oidc import OIDCAdapter
9
+ from .supabase import SupabaseAuthAdapter
10
+
11
+ __all__ = [
12
+ "AuthAdapter",
13
+ "Principal",
14
+ "SupabaseAuthAdapter",
15
+ "JWTAuthAdapter",
16
+ "NoAuthAdapter",
17
+ "ClerkAuthAdapter",
18
+ "Auth0OIDCAdapter",
19
+ "OIDCAdapter",
20
+ ]