Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/cloud/src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { Router, Request, Response } from 'express';
import { getConfig } from '../config.js';
import { getProvisioner, WorkspaceProvisioner } from '../provisioner/index.js';
import { getDb } from '../db/drizzle.js';

export const adminRouter = Router();

Expand Down Expand Up @@ -109,6 +110,84 @@ adminRouter.post('/workspaces/update-image', async (req: Request, res: Response)
}
});

/**
* POST /api/admin/workspaces/community/create
*
* Create or ensure the public community workspace exists.
* This is the viral growth mechanism - a public workspace any user can join.
*
* Response:
* - workspaceId: ID of the community workspace
* - created: Whether it was just created (true) or already existed (false)
* - status: Current workspace status
* - publicUrl: Public URL if available
*/
adminRouter.post('/workspaces/community/create', async (req: Request, res: Response) => {
const { userId, name = 'Community' } = req.body as {
userId?: string;
name?: string;
};

if (!userId) {
res.status(400).json({ error: 'userId is required to create community workspace' });
return;
}

try {
const db = getDb();
const provisioner = getProvisioner();

// Check if community workspace already exists
const allWorkspaces = await db.workspaces.findAll();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
const communityWorkspace = allWorkspaces.find(
w => w.name.toLowerCase() === name.toLowerCase() && w.isPublic
);

if (communityWorkspace) {
return res.json({
workspaceId: communityWorkspace.id,
created: false,
status: communityWorkspace.status,
publicUrl: communityWorkspace.publicUrl,
message: 'Community workspace already exists',
});
}

// Create the community workspace
// Note: Requires at least one provider, but repositories can be empty
const result = await provisioner.provision({
userId,
name,
providers: ['claude'], // Default provider
repositories: [], // No repos needed for community workspace
supervisorEnabled: true,
maxAgents: 10,
isPublic: true,
});

if (result.status === 'error') {
return res.status(500).json({
error: 'Failed to create community workspace',
details: result.error,
});
}

res.json({
workspaceId: result.workspaceId,
created: true,
status: result.status,
publicUrl: result.publicUrl,
message: 'Community workspace created successfully',
});
} catch (error) {
console.error('[admin] Error creating community workspace:', error);
res.status(500).json({
error: 'Failed to create community workspace',
details: (error as Error).message,
});
}
});

/**
* POST /api/admin/workspaces/:id/update-image
*
Expand Down
139 changes: 127 additions & 12 deletions packages/cloud/src/api/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Cache staleness when public workspace status changes

The checkWorkspaceAccess function caches access results for 5 minutes, but when a workspace's isPublic flag or status changes, the cache is never invalidated.

Click to expand

Root Cause

At packages/cloud/src/api/workspaces.ts:346-348, access is granted based on workspace.isPublic && workspace.status === 'running' and cached:

if (workspace.isPublic && workspace.status === 'running') {
  setCachedAccess(userId, workspaceId, { hasAccess: true, accessType: 'public', permission: 'read' });
  return { hasAccess: true, accessType: 'public', permission: 'read' };
}

The _invalidateCachedAccess function exists at line 51 but is never called anywhere in the codebase when workspace visibility or status changes.

Actual vs Expected

  • Actual: If a public workspace is made private or stops running, users may still have cached hasAccess: true for up to 5 minutes. Conversely, if a workspace becomes public, users who previously checked access may be denied for up to 5 minutes.
  • Expected: Cache should be invalidated when isPublic or status changes.

Impact

  • Users could access a workspace that was made private (security issue)
  • Users could access a workspace that is no longer running
  • Users may be denied access to newly public workspaces

Recommendation: Call _invalidateCachedAccess when workspace visibility or status changes. Since access is cached per user-workspace pair, consider broadcasting cache invalidation when isPublic changes, or reduce the cache TTL for public workspace access checks.

Open in Devin Review

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' });
Expand Down Expand Up @@ -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 warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
* 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') {
Expand Down Expand Up @@ -541,6 +572,7 @@
repositories,
supervisorEnabled,
maxAgents,
isPublic: isPublic ?? false,
});

if (result.status === 'error') {
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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 }> = [];
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions packages/cloud/src/db/drizzle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,9 @@ export interface WorkspaceQueries {
findByCustomDomain(domain: string): Promise<schema.Workspace | null>;
findByRepoFullName(repoFullName: string): Promise<schema.Workspace | null>;
findAll(): Promise<schema.Workspace[]>;
findPublic(): Promise<schema.Workspace[]>;
create(data: schema.NewWorkspace): Promise<schema.Workspace>;
update(id: string, data: Partial<Pick<schema.Workspace, 'name' | 'config'>>): Promise<void>;
update(id: string, data: Partial<Pick<schema.Workspace, 'name' | 'config' | 'isPublic'>>): Promise<void>;
updateStatus(
id: string,
status: string,
Expand Down Expand Up @@ -501,13 +502,23 @@ export const workspaceQueries: WorkspaceQueries = {
.orderBy(desc(schema.workspaces.createdAt));
},

async findPublic(): Promise<schema.Workspace[]> {
const db = getDb();
return db
.select()
.from(schema.workspaces)
.where(eq(schema.workspaces.isPublic, true))
.where(eq(schema.workspaces.status, 'running'))
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
.orderBy(desc(schema.workspaces.createdAt));
},

async create(data: schema.NewWorkspace): Promise<schema.Workspace> {
const db = getDb();
const result = await db.insert(schema.workspaces).values(data).returning();
return result[0];
},

async update(id: string, data: Partial<Pick<schema.Workspace, 'name' | 'config'>>): Promise<void> {
async update(id: string, data: Partial<Pick<schema.Workspace, 'name' | 'config' | 'isPublic'>>): Promise<void> {
const db = getDb();
await db
.update(schema.workspaces)
Expand Down
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");
3 changes: 3 additions & 0 deletions packages/cloud/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,14 @@ export const workspaces = pgTable('workspaces', {
customDomainStatus: varchar('custom_domain_status', { length: 50 }),
config: jsonb('config').$type<WorkspaceConfig>().notNull().default({}),
errorMessage: text('error_message'),
/** Whether this workspace is publicly accessible to all logged-in users */
isPublic: boolean('is_public').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
userIdIdx: index('idx_workspaces_user_id').on(table.userId),
customDomainIdx: index('idx_workspaces_custom_domain').on(table.customDomain),
isPublicIdx: index('idx_workspaces_is_public').on(table.isPublic),
}));

export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
Expand Down
3 changes: 3 additions & 0 deletions packages/cloud/src/provisioner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
return null;
}

Expand All @@ -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 failure

Code scanning / CodeQL

Use of externally-controlled format string High

Format string depends on a
user-provided value
.

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
return null;
}
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -1977,11 +1980,11 @@
const userRepos = await db.repositories.findByUserId(config.userId);
const repoRecord = userRepos.find(
r => r.githubFullName.toLowerCase() === repoFullName.toLowerCase()
);

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
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 warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
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}`);
Expand All @@ -1990,10 +1993,10 @@
githubFullName: repoFullName,
githubId: 0, // Will be updated when actually synced
defaultBranch: 'main',
isPrivate: true, // Assume private, will be updated

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
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 failure

Code scanning / CodeQL

Use of externally-controlled format string High

Format string depends on a
user-provided value
.
Format string depends on a user-provided value.

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
Log entry depends on a user-provided value.
}
} catch (err) {
console.warn(`[provisioner] Failed to link repo ${repoFullName}:`, err);
Expand Down Expand Up @@ -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 warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
}
}
}
Expand Down
Loading
Loading