-
Notifications
You must be signed in to change notification settings - Fork 46
feat: Public community rooms infrastructure (bd-viral-001) #295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,7 @@ | |
|
|
||
| interface CachedAccess { | ||
| hasAccess: boolean; | ||
| accessType: 'owner' | 'member' | 'contributor' | 'none'; | ||
| accessType: 'owner' | 'member' | 'contributor' | 'public' | 'none'; | ||
| permission?: 'admin' | 'write' | 'read'; | ||
| cachedAt: number; | ||
| } | ||
|
|
@@ -310,12 +310,13 @@ | |
| * Check if user has access to a workspace via: | ||
| * 1. Workspace ownership (userId matches) | ||
| * 2. Explicit workspace_members record | ||
| * 3. GitHub repo access (just-in-time check via Nango) | ||
| * 3. Public workspace (isPublic = true) - any logged-in user can access | ||
| * 4. GitHub repo access (just-in-time check via Nango) | ||
| */ | ||
| export async function checkWorkspaceAccess( | ||
| userId: string, | ||
| workspaceId: string | ||
| ): Promise<{ hasAccess: boolean; accessType: 'owner' | 'member' | 'contributor' | 'none'; permission?: 'admin' | 'write' | 'read' }> { | ||
| ): Promise<{ hasAccess: boolean; accessType: 'owner' | 'member' | 'contributor' | 'public' | 'none'; permission?: 'admin' | 'write' | 'read' }> { | ||
| // Check cache first | ||
| const cached = getCachedAccess(userId, workspaceId); | ||
| if (cached) { | ||
|
|
@@ -341,7 +342,13 @@ | |
| return { hasAccess: true, accessType: 'member', permission }; | ||
| } | ||
|
|
||
| // 3. Check GitHub repo access (just-in-time) | ||
| // 3. Check if workspace is public (any logged-in user can access) | ||
| if (workspace.isPublic && workspace.status === 'running') { | ||
| setCachedAccess(userId, workspaceId, { hasAccess: true, accessType: 'public', permission: 'read' }); | ||
| return { hasAccess: true, accessType: 'public', permission: 'read' }; | ||
|
Comment on lines
+346
to
+348
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Cache staleness when public workspace status changes The Click to expandRoot CauseAt if (workspace.isPublic && workspace.status === 'running') {
setCachedAccess(userId, workspaceId, { hasAccess: true, accessType: 'public', permission: 'read' });
return { hasAccess: true, accessType: 'public', permission: 'read' };
}The Actual vs Expected
Impact
Recommendation: Call Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
|
|
||
| // 4. Check GitHub repo access (just-in-time) | ||
| const user = await db.users.findById(userId); | ||
| if (!user?.nangoConnectionId) { | ||
| setCachedAccess(userId, workspaceId, { hasAccess: false, accessType: 'none' }); | ||
|
|
@@ -471,13 +478,37 @@ | |
| } | ||
| }); | ||
|
|
||
| /** | ||
| * GET /api/workspaces/public | ||
| * List all public workspaces that any logged-in user can join | ||
| */ | ||
| workspacesRouter.get('/public', requireAuth, async (req: Request, res: Response) => { | ||
| try { | ||
| const publicWorkspaces = await db.workspaces.findPublic(); | ||
|
|
||
| res.json({ | ||
| workspaces: publicWorkspaces.map((w) => ({ | ||
| id: w.id, | ||
| name: w.name, | ||
| status: w.status, | ||
| publicUrl: w.publicUrl, | ||
| createdAt: w.createdAt, | ||
| isPublic: w.isPublic, | ||
| })), | ||
| }); | ||
| } catch (error) { | ||
| console.error('Error listing public workspaces:', error); | ||
| res.status(500).json({ error: 'Failed to list public workspaces' }); | ||
| } | ||
| }); | ||
|
|
||
| /** | ||
Check warningCode scanning / CodeQL Log injection Medium
Log entry depends on a
user-provided value Error loading related location Loading |
||
| * POST /api/workspaces | ||
| * Create (provision) a new workspace | ||
| */ | ||
| workspacesRouter.post('/', checkWorkspaceLimit, async (req: Request, res: Response) => { | ||
| const userId = req.session.userId!; | ||
| const { name, providers, repositories, supervisorEnabled, maxAgents } = req.body; | ||
| const { name, providers, repositories, supervisorEnabled, maxAgents, isPublic } = req.body; | ||
|
|
||
| // Validation | ||
| if (!name || typeof name !== 'string') { | ||
|
|
@@ -541,6 +572,7 @@ | |
| repositories, | ||
| supervisorEnabled, | ||
| maxAgents, | ||
| isPublic: isPublic ?? false, | ||
| }); | ||
|
|
||
| if (result.status === 'error') { | ||
|
|
@@ -726,6 +758,7 @@ | |
| * List all workspaces the user can access: | ||
| * - Owned workspaces | ||
| * - Workspaces where user is a member | ||
| * - Public workspaces (any logged-in user can access) | ||
| * - Workspaces with repos the user has GitHub access to | ||
| * NOTE: This route MUST be before /:id to avoid being caught by parameterized route | ||
| */ | ||
|
|
@@ -755,6 +788,16 @@ | |
| if (ws) memberWorkspaces.push(ws); | ||
| } | ||
|
|
||
| // 3. Get public workspaces (excluding owned/member ones to prevent duplicates) | ||
| const knownWorkspaceIds = new Set([ | ||
| ...ownedWorkspaceIds, | ||
| ...memberWorkspaceIds, | ||
| ]); | ||
| const publicWorkspaces = await db.workspaces.findPublic(); | ||
| const accessiblePublicWorkspaces = publicWorkspaces.filter( | ||
| (w) => !knownWorkspaceIds.has(w.id) | ||
| ); | ||
|
|
||
| // 3. Get workspaces via GitHub repo access (if user has Nango connection) | ||
| // Uses background caching to handle users with many repos (>100) | ||
| const contributorWorkspaces: Array<Workspace & { accessPermission: string }> = []; | ||
|
|
@@ -779,12 +822,11 @@ | |
| console.log(`[workspaces/accessible] Cache miss - initialized with ${userRepos.length} repos (background refresh may add more)`); | ||
| } | ||
|
|
||
| // Get workspaces that aren't owned or membered | ||
| // Reuse ownedWorkspaceIds and add member workspace IDs | ||
| const knownWorkspaceIds = new Set([ | ||
| ...ownedWorkspaceIds, | ||
| ...memberWorkspaceIds, | ||
| ]); | ||
| // Get workspaces that aren't owned, membered, or public | ||
| // Add public workspace IDs to known set | ||
| for (const publicWs of accessiblePublicWorkspaces) { | ||
| knownWorkspaceIds.add(publicWs.id); | ||
| } | ||
|
|
||
| // Get all repo full names from user's accessible repos | ||
| for (const repo of userRepos) { | ||
|
|
@@ -845,13 +887,15 @@ | |
| const membership = memberships.find((m) => m.workspaceId === w.id); | ||
| return formatWorkspace(w, 'member', membership?.role); | ||
| }), | ||
| ...accessiblePublicWorkspaces.map((w) => formatWorkspace(w, 'public', 'read')), | ||
| ...contributorWorkspaces.map((w) => formatWorkspace(w, 'contributor', w.accessPermission)), | ||
| ], | ||
| summary: { | ||
| owned: ownedWorkspaces.length, | ||
| member: memberWorkspaces.length, | ||
| public: accessiblePublicWorkspaces.length, | ||
| contributor: contributorWorkspaces.length, | ||
| total: ownedWorkspaces.length + memberWorkspaces.length + contributorWorkspaces.length, | ||
| total: ownedWorkspaces.length + memberWorkspaces.length + accessiblePublicWorkspaces.length + contributorWorkspaces.length, | ||
| }, | ||
| }); | ||
| } catch (error) { | ||
|
|
@@ -902,6 +946,77 @@ | |
| } | ||
| }); | ||
|
|
||
| /** | ||
| * POST /api/workspaces/:id/join | ||
| * Join a public workspace (adds user as member) | ||
| */ | ||
| workspacesRouter.post('/:id/join', requireAuth, async (req: Request, res: Response) => { | ||
| const userId = req.session.userId!; | ||
| const workspaceId = req.params.id as string; | ||
|
|
||
| try { | ||
| const workspace = await db.workspaces.findById(workspaceId); | ||
|
|
||
| if (!workspace) { | ||
| return res.status(404).json({ error: 'Workspace not found' }); | ||
| } | ||
|
|
||
| // Check if workspace is public | ||
| if (!workspace.isPublic) { | ||
| return res.status(403).json({ error: 'Workspace is not public' }); | ||
| } | ||
|
|
||
| // Check if user is already a member | ||
| const existingMember = await db.workspaceMembers.findMembership(workspaceId, userId); | ||
| if (existingMember) { | ||
| if (existingMember.acceptedAt) { | ||
| return res.status(200).json({ | ||
| message: 'Already a member', | ||
| workspace: { | ||
| id: workspace.id, | ||
| name: workspace.name, | ||
| publicUrl: workspace.publicUrl, | ||
| }, | ||
| }); | ||
| } else { | ||
| // Accept pending invite | ||
| await db.workspaceMembers.acceptInvite(workspaceId, userId); | ||
| return res.status(200).json({ | ||
| message: 'Joined workspace', | ||
| workspace: { | ||
| id: workspace.id, | ||
| name: workspace.name, | ||
| publicUrl: workspace.publicUrl, | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // Add user as member | ||
| await db.workspaceMembers.addMember({ | ||
| workspaceId, | ||
| userId, | ||
| role: 'member', | ||
| invitedBy: workspace.userId, | ||
| }); | ||
|
|
||
| // Auto-accept since it's a public workspace | ||
| await db.workspaceMembers.acceptInvite(workspaceId, userId); | ||
|
|
||
| res.status(200).json({ | ||
| message: 'Joined workspace', | ||
| workspace: { | ||
| id: workspace.id, | ||
| name: workspace.name, | ||
| publicUrl: workspace.publicUrl, | ||
| }, | ||
| }); | ||
| } catch (error) { | ||
| console.error('Error joining workspace:', error); | ||
| res.status(500).json({ error: 'Failed to join workspace' }); | ||
| } | ||
| }); | ||
|
|
||
| /** | ||
| * GET /api/workspaces/:id/status | ||
| * Get current workspace status (polls compute provider) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| -- Add isPublic column to workspaces table for public community rooms | ||
| ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "is_public" boolean DEFAULT false NOT NULL; | ||
| --> statement-breakpoint | ||
| CREATE INDEX IF NOT EXISTS "idx_workspaces_is_public" ON "workspaces" USING btree ("is_public"); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -140,7 +140,7 @@ | |
| const repoWithConnection = repos.find(r => r.nangoConnectionId); | ||
|
|
||
| if (!repoWithConnection?.nangoConnectionId) { | ||
| console.warn(`[provisioner] No Nango GitHub App connection found for user ${userId}`); | ||
Check warningCode scanning / CodeQL Log injection Medium
Log entry depends on a
user-provided value Error loading related location Loading |
||
| return null; | ||
| } | ||
|
|
||
|
|
@@ -148,7 +148,7 @@ | |
| const token = await nangoService.getGithubAppToken(repoWithConnection.nangoConnectionId); | ||
| return token; | ||
| } catch (error) { | ||
| console.error(`[provisioner] Failed to get GitHub App token for user ${userId}:`, error); | ||
Check failureCode scanning / CodeQL Use of externally-controlled format string High
Format string depends on a
user-provided value Error loading related location Loading Check warningCode scanning / CodeQL Log injection Medium
Log entry depends on a
user-provided value Error loading related location Loading |
||
| return null; | ||
| } | ||
| } | ||
|
|
@@ -349,6 +349,8 @@ | |
| repositories: string[]; | ||
| supervisorEnabled?: boolean; | ||
| maxAgents?: number; | ||
| /** Whether this workspace is publicly accessible to all logged-in users */ | ||
| isPublic?: boolean; | ||
| /** Direct GitHub token for testing (bypasses Nango lookup) */ | ||
| githubToken?: string; | ||
| } | ||
|
|
@@ -1951,6 +1953,7 @@ | |
| userId: config.userId, | ||
| name: config.name, | ||
| computeProvider: getConfig().compute.provider, | ||
| isPublic: config.isPublic ?? false, | ||
| config: { | ||
| providers: config.providers, | ||
| repositories: config.repositories, | ||
|
|
@@ -1977,11 +1980,11 @@ | |
| const userRepos = await db.repositories.findByUserId(config.userId); | ||
| const repoRecord = userRepos.find( | ||
| r => r.githubFullName.toLowerCase() === repoFullName.toLowerCase() | ||
| ); | ||
Check warningCode scanning / CodeQL Log injection Medium
Log entry depends on a
user-provided value Error loading related location Loading Log entry depends on a user-provided value. |
||
| if (repoRecord) { | ||
| await db.repositories.assignToWorkspace(repoRecord.id, workspace.id); | ||
| console.log(`[provisioner] Linked repo ${repoFullName} to workspace ${workspace.id.substring(0, 8)}`); | ||
| } else { | ||
Check warningCode scanning / CodeQL Log injection Medium
Log entry depends on a
user-provided value Error loading related location Loading Log entry depends on a user-provided value. |
||
| // Create a placeholder repo record if it doesn't exist | ||
| // This ensures the repo is tracked for workspace access checks | ||
| console.log(`[provisioner] Creating repo record for ${repoFullName}`); | ||
|
|
@@ -1990,10 +1993,10 @@ | |
| githubFullName: repoFullName, | ||
| githubId: 0, // Will be updated when actually synced | ||
| defaultBranch: 'main', | ||
| isPrivate: true, // Assume private, will be updated | ||
Check warningCode scanning / CodeQL Log injection Medium
Log entry depends on a
user-provided value Error loading related location Loading Log entry depends on a user-provided value. |
||
| workspaceId: workspace.id, | ||
| }); | ||
| console.log(`[provisioner] Created and linked repo ${repoFullName} (id: ${newRepo.id.substring(0, 8)})`); | ||
Check failureCode scanning / CodeQL Use of externally-controlled format string High
Format string depends on a
user-provided value Error loading related location Loading Format string depends on a user-provided value. Check warningCode scanning / CodeQL Log injection Medium
Log entry depends on a
user-provided value Error loading related location Loading Log entry depends on a user-provided value. |
||
| } | ||
| } catch (err) { | ||
| console.warn(`[provisioner] Failed to link repo ${repoFullName}:`, err); | ||
|
|
@@ -2039,7 +2042,7 @@ | |
| if (githubToken) { | ||
| credentials.set('github', githubToken); | ||
| } else { | ||
| console.warn(`[provisioner] No GitHub App token for user ${config.userId}; repository cloning may fail.`); | ||
Check warningCode scanning / CodeQL Log injection Medium
Log entry depends on a
user-provided value Error loading related location Loading |
||
| } | ||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.