Forge
csharp4405de34
1using System.Security.Claims;
2using IntercomService.Contracts;
3using IntercomService.Data;
4using IntercomService.Services;
5using Microsoft.EntityFrameworkCore;
6
7namespace IntercomService.Endpoints;
8
9public static partial class ApiEndpoints
10{
11 private static void MapIdentity(RouteGroupBuilder api)
12 {
13 api.MapGet("/auth/providers", (AuthProviderRegistry registry) =>
14 Results.Json(registry.ListProviders()));
15
16 api.MapPost("/auth/redeem-invite", async (
17 RedeemInviteRequest body,
18 TeamInviteService invites,
19 CancellationToken ct) =>
20 {
21 if (string.IsNullOrWhiteSpace(body.InviteToken) || string.IsNullOrWhiteSpace(body.TeamId))
22 return Results.BadRequest(new { error = "invite_token and team_id required" });
23
24 var valid = await invites.IsValidInviteAsync(body.TeamId, body.InviteToken, ct).ConfigureAwait(false);
25 return valid ? Results.Ok(new { status = "valid" }) : Results.BadRequest(new { error = "invalid_invite" });
26 });
27
28 api.MapPost("/teams", async (
29 CreateTeamRequest body,
30 TeamMembershipService teams,
31 IntercomDbContext db,
32 ClaimsPrincipal user,
33 CancellationToken ct) =>
34 {
35 var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier);
36 if (string.IsNullOrWhiteSpace(memberId))
37 return Results.Unauthorized();
38
39 if (string.IsNullOrWhiteSpace(body.TeamId))
40 return Results.BadRequest(new { error = "team_id required" });
41
42 var teamId = body.TeamId.Trim();
43 if (await db.Teams.AnyAsync(x => x.TeamId == teamId, ct).ConfigureAwait(false))
44 return Results.Conflict(new { error = "team_exists" });
45
46 var team = await teams.CreateTeamAsync(teamId, body.DisplayName, memberId, ct)
47 .ConfigureAwait(false);
48 return Results.Json(new TeamDto(team.TeamId, team.DisplayName, team.JoinPolicy, team.DefaultTeamRole));
49 }).RequireAuthorization();
50
51 api.MapPatch("/teams/{teamId}", async (
52 string teamId,
53 PatchTeamRequest body,
54 TeamMembershipService teams,
55 ClaimsPrincipal user,
56 CancellationToken ct) =>
57 {
58 var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
59 var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false);
60 if (role is null || !TeamRoleAuthorization.CanPatchTeam(role))
61 return Results.Forbid();
62
63 if (!string.IsNullOrWhiteSpace(body.JoinPolicy)
64 && !TeamRoleAuthorization.CanChangeJoinPolicy(role))
65 return Results.Forbid();
66
67 var ok = await teams.PatchTeamAsync(teamId, body, ct).ConfigureAwait(false);
68 return ok ? Results.Ok() : Results.NotFound();
69 }).RequireAuthorization();
70
71 api.MapGet("/teams/{teamId}/members", async (
72 string teamId,
73 TeamMembershipService teams,
74 ClaimsPrincipal user,
75 CancellationToken ct) =>
76 {
77 var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
78 var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false);
79 if (role is null || !TeamRoleAuthorization.MeetsMinimum(role, TeamRoles.Member))
80 return Results.Forbid();
81
82 var list = await teams.ListTeamMembersAsync(teamId, ct).ConfigureAwait(false);
83 return Results.Json(list);
84 }).RequireAuthorization();
85
86 api.MapPatch("/teams/{teamId}/members/me", async (
87 string teamId,
88 PatchSelfTeamMemberRequest body,
89 TeamMembershipService teams,
90 ClaimsPrincipal user,
91 CancellationToken ct) =>
92 {
93 var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
94 if (!await teams.IsMemberOfTeamAsync(teamId, memberId, ct).ConfigureAwait(false))
95 return Results.Forbid();
96
97 var ok = await teams.PatchSelfTeamDisplayNameAsync(teamId, memberId, body, ct).ConfigureAwait(false);
98 return ok ? Results.Ok() : Results.NotFound();
99 }).RequireAuthorization();
100
101 api.MapPatch("/teams/{teamId}/members/{targetMemberId}", async (
102 string teamId,
103 string targetMemberId,
104 PatchTeamMemberRequest body,
105 TeamMembershipService teams,
106 ClaimsPrincipal user,
107 CancellationToken ct) =>
108 {
109 var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
110 var (ok, error) = await teams
111 .PatchTeamMemberRoleAsync(teamId, targetMemberId, memberId, body, ct)
112 .ConfigureAwait(false);
113
114 return error switch
115 {
116 "forbidden" => Results.Forbid(),
117 "member_not_found" => Results.NotFound(),
118 "last_owner" => Results.Conflict(new { error }),
119 "owner_promotion_forbidden" => Results.Forbid(),
120 "invalid_role" => Results.BadRequest(new { error }),
121 _ when ok => Results.Ok(),
122 _ => Results.BadRequest(new { error = error ?? "failed" }),
123 };
124 }).RequireAuthorization();
125
126 api.MapPost("/teams/{teamId}/invites", async (
127 string teamId,
128 CreateInviteRequest body,
129 TeamInviteService invites,
130 TeamMembershipService teams,
131 ClaimsPrincipal user,
132 CancellationToken ct) =>
133 {
134 var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
135 var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false);
136 if (role is null || !TeamRoleAuthorization.CanManageInvites(role))
137 return Results.Forbid();
138
139 var teamRole = string.IsNullOrWhiteSpace(body.TeamRole) ? TeamRoles.Member : body.TeamRole.Trim();
140 var ttl = TimeSpan.FromHours(Math.Clamp(body.TtlHours ?? 168, 1, 8760));
141 var maxUses = Math.Clamp(body.MaxUses ?? 1, 1, 1000);
142
143 var created = await invites
144 .CreateInviteAsync(teamId, memberId, teamRole, ttl, maxUses, ct)
145 .ConfigureAwait(false);
146 if (created is null)
147 return Results.BadRequest(new { error = "invalid_role" });
148
149 var (invite, token) = created.Value;
150 return Results.Json(new InviteDto(
151 invite.InviteId,
152 token,
153 invite.TeamRole,
154 invite.ExpiresAtUtc.ToString("O"),
155 invite.MaxUses));
156 }).RequireAuthorization();
157
158 api.MapPost("/teams/{teamId}/agents", async (
159 string teamId,
160 CreateAgentRequest body,
161 AgentAccountService agents,
162 TeamMembershipService teams,
163 ClaimsPrincipal user,
164 CancellationToken ct) =>
165 {
166 var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
167 if (!await teams.EnsureAccessAsync(teamId, memberId, IsDevAuth(user), user.FindFirstValue("display_name"), ct)
168 .ConfigureAwait(false))
169 return Results.Forbid();
170
171 var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false);
172 if (role is null || !TeamRoleAuthorization.CanManageAgents(role))
173 return Results.Forbid();
174
175 if (string.IsNullOrWhiteSpace(body.DisplayName))
176 return Results.BadRequest(new { error = "display_name required" });
177
178 var agent = await agents
179 .ProvisionAsync(teamId, body.DisplayName, body.AvatarGlyph, ct)
180 .ConfigureAwait(false);
181 return agent is null ? Results.BadRequest() : Results.Json(agent);
182 }).RequireAuthorization();
183
184 api.MapPatch("/teams/{teamId}/agents/{agentMemberId}", async (
185 string teamId,
186 string agentMemberId,
187 PatchAgentRequest body,
188 AgentAccountService agents,
189 TeamMembershipService teams,
190 ClaimsPrincipal user,
191 CancellationToken ct) =>
192 {
193 var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
194 var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false);
195 var isAdmin = role is not null && TeamRoleAuthorization.CanManageAgents(role);
196 var isSelf = string.Equals(memberId, agentMemberId, StringComparison.Ordinal);
197
198 if (!isAdmin && !isSelf)
199 return Results.Forbid();
200
201 var ok = await agents.PatchAsync(teamId, agentMemberId, body, isAdmin, ct).ConfigureAwait(false);
202 return ok ? Results.Ok() : Results.NotFound();
203 }).RequireAuthorization();
204
205 api.MapGet("/resolve/workspace-context", async (
206 HttpRequest request,
207 WorkspaceResolveService resolve,
208 ClaimsPrincipal user,
209 CancellationToken ct) =>
210 {
211 var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier);
212 if (string.IsNullOrWhiteSpace(memberId))
213 return Results.Unauthorized();
214
215 var repoUrls = request.Query["repo_url"].ToArray().Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!).ToArray();
216 if (repoUrls.Length == 0)
217 return Results.BadRequest(new { error = "repo_url required" });
218
219 var response = await resolve.ResolveAsync(memberId, repoUrls, ct).ConfigureAwait(false);
220 return Results.Json(response);
221 }).RequireAuthorization();
222
223 api.MapPost("/projects", async (
224 CreateProjectRequest body,
225 IntercomDbContext db,
226 CancellationToken ct) =>
227 {
228 if (string.IsNullOrWhiteSpace(body.ProjectId))
229 return Results.BadRequest(new { error = "project_id required" });
230
231 var exists = await db.Projects.AnyAsync(x => x.ProjectId == body.ProjectId, ct).ConfigureAwait(false);
232 if (exists)
233 return Results.Conflict(new { error = "exists" });
234
235 var project = new ProjectEntity
236 {
237 ProjectId = body.ProjectId.Trim(),
238 DisplayName = string.IsNullOrWhiteSpace(body.DisplayName) ? body.ProjectId.Trim() : body.DisplayName.Trim(),
239 CreatedAtUtc = DateTimeOffset.UtcNow,
240 };
241 db.Projects.Add(project);
242 await db.SaveChangesAsync(ct).ConfigureAwait(false);
243 return Results.Json(new ProjectDto(project.ProjectId, project.DisplayName));
244 }).RequireAuthorization();
245
246 api.MapPut("/projects/{projectId}/repos", async (
247 string projectId,
248 PutProjectReposRequest body,
249 IntercomDbContext db,
250 CancellationToken ct) =>
251 {
252 var project = await db.Projects.FindAsync([projectId], ct).ConfigureAwait(false);
253 if (project is null)
254 return Results.NotFound();
255
256 var normalized = GitRepoUrlNormalizer.NormalizeMany(body.RepoUrls ?? []);
257 var existing = await db.ProjectRepos.Where(x => x.ProjectId == projectId).ToListAsync(ct)
258 .ConfigureAwait(false);
259 db.ProjectRepos.RemoveRange(existing);
260
261 foreach (var url in normalized)
262 {
263 db.ProjectRepos.Add(new ProjectRepoEntity
264 {
265 ProjectId = projectId,
266 NormalizedRepoUrl = url,
267 LinkedAtUtc = DateTimeOffset.UtcNow,
268 });
269 }
270
271 await db.SaveChangesAsync(ct).ConfigureAwait(false);
272 return Results.Ok(new { repo_urls = normalized });
273 }).RequireAuthorization();
274
275 api.MapPut("/teams/{teamId}/projects", async (
276 string teamId,
277 PutTeamProjectsRequest body,
278 IntercomDbContext db,
279 TeamMembershipService teams,
280 ClaimsPrincipal user,
281 CancellationToken ct) =>
282 {
283 var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
284 var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false);
285 if (role is null || !TeamRoleAuthorization.CanPatchTeam(role))
286 return Results.Forbid();
287
288 var team = await db.Teams.FindAsync([teamId], ct).ConfigureAwait(false);
289 if (team is null)
290 return Results.NotFound();
291
292 var projectIds = (body.ProjectIds ?? [])
293 .Where(x => !string.IsNullOrWhiteSpace(x))
294 .Select(x => x.Trim())
295 .Distinct(StringComparer.Ordinal)
296 .ToList();
297
298 var existing = await db.TeamProjects.Where(x => x.TeamId == teamId).ToListAsync(ct)
299 .ConfigureAwait(false);
300 db.TeamProjects.RemoveRange(existing);
301
302 foreach (var pid in projectIds)
303 {
304 if (!await db.Projects.AnyAsync(x => x.ProjectId == pid, ct).ConfigureAwait(false))
305 continue;
306
307 db.TeamProjects.Add(new TeamProjectEntity
308 {
309 TeamId = teamId,
310 ProjectId = pid,
311 LinkedAtUtc = DateTimeOffset.UtcNow,
312 });
313 }
314
315 await db.SaveChangesAsync(ct).ConfigureAwait(false);
316 return Results.Ok(new { project_ids = projectIds });
317 }).RequireAuthorization();
318 }
319}
320
View only · write via MCP/CIDE