Skip to content

Commit 3e805b3

Browse files
committed
fix(webapp): create dev environments for SSO and Directory Sync members
Members added by SSO just-in-time provisioning or Directory Sync never got their per-member DEVELOPMENT environments - only invite acceptance and project creation created them. `trigger dev` returned "Environment not found" for those members and the dashboard had no dev view. ensureOrgMember now queues provisioning for every membership it settles, so both paths are covered and members missing environments are repaired on their next sync. Provisioning runs as a common-worker job to keep sign-in and directory webhooks off the per-project write loop. A failed enqueue surfaces for Directory Sync, whose worker retries the idempotent effect, and is swallowed for sign-in, where the next login enqueues again. Environment creation now tolerates a concurrent creator so the project-creation loop and the job cannot collide on the unique index. Also fixes environment resolution ignoring dev-environment ownership: a member without their own dev environment could be handed a colleague's and have it persisted as their dashboard preference.
1 parent 86b948b commit 3e805b3

20 files changed

Lines changed: 1390 additions & 74 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Team members who join an organization through single sign-on or directory sync now get their own development environment for every project, so the dashboard's dev view and `trigger dev` become available to them without an admin having to set anything up. Members who were already missing one are repaired the next time their membership is synced from your identity provider.

apps/webapp/app/models/member.server.ts

Lines changed: 88 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { Organization, OrgMember, Project } from "@trigger.dev/database";
2+
import { z } from "zod";
23
import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server";
3-
import { createEnvironment } from "./organization.server";
4+
import {
5+
createDevelopmentEnvironmentForMember,
6+
memberDevelopmentEnvironmentWhere,
7+
} from "./organization.server";
48
import { customAlphabet } from "nanoid";
59
import { logger } from "~/services/logger.server";
610
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
@@ -13,6 +17,31 @@ export const INVITE_BLOCKED_DIRECTORY_MANAGED =
1317
export const ENV_SETUP_INCOMPLETE =
1418
"You joined the organization, but we couldn't finish setting up your development environments. Please try accepting the invite again, or contact support if this persists.";
1519

20+
/** How a membership came to exist. Also validates the queued job's payload. */
21+
export const MembershipSourceSchema = z.enum(["invite", "sso_jit", "directory_sync", "manual"]);
22+
23+
export type MembershipSource = z.infer<typeof MembershipSourceSchema>;
24+
25+
/** Thrown when provisioning fails partway through; the membership is still valid. */
26+
export class DevEnvironmentProvisioningError extends Error {
27+
readonly logLevel = "warn" as const;
28+
29+
constructor(
30+
message: string,
31+
readonly context: {
32+
source: MembershipSource;
33+
organizationId: string;
34+
orgMemberId: string;
35+
failedProjectId?: string;
36+
createdProjectIds: string[];
37+
},
38+
options?: { cause?: unknown }
39+
) {
40+
super(message, options);
41+
this.name = "DevEnvironmentProvisioningError";
42+
}
43+
}
44+
1645
export function isAcceptInviteFormError(error: unknown): error is Error {
1746
return (
1847
error instanceof Error &&
@@ -186,7 +215,7 @@ export async function getUsersInvites({ email }: { email: string }) {
186215
});
187216
}
188217

189-
async function getProjectsMissingMemberDevelopmentEnvironments({
218+
export async function getProjectsMissingMemberDevelopmentEnvironments({
190219
memberId,
191220
organizationId,
192221
projects,
@@ -201,10 +230,11 @@ async function getProjectsMissingMemberDevelopmentEnvironments({
201230

202231
const existingEnvs = await prisma.runtimeEnvironment.findMany({
203232
where: {
204-
orgMemberId: memberId,
205233
organizationId,
206-
type: "DEVELOPMENT",
207-
projectId: { in: projects.map((project) => project.id) },
234+
...memberDevelopmentEnvironmentWhere({
235+
orgMemberId: memberId,
236+
projectId: { in: projects.map((project) => project.id) },
237+
}),
208238
},
209239
select: { projectId: true },
210240
});
@@ -214,15 +244,15 @@ async function getProjectsMissingMemberDevelopmentEnvironments({
214244
}
215245

216246
export async function provisionMemberDevelopmentEnvironments({
247+
source,
217248
inviteId,
218-
user,
219249
member,
220250
organization,
221251
projects,
222252
maximumConcurrencyLimit,
223253
}: {
224-
inviteId: string;
225-
user: { id: string; email: string };
254+
source: MembershipSource;
255+
inviteId?: string;
226256
member: OrgMember;
227257
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
228258
projects: Pick<Project, "id">[];
@@ -233,7 +263,7 @@ export async function provisionMemberDevelopmentEnvironments({
233263
organizationId: organization.id,
234264
projects,
235265
});
236-
const projectIds = projects.map((project) => project.id);
266+
const requestedProjectIds = projects.map((project) => project.id);
237267
const createdProjectIds: string[] = [];
238268
let failedProjectId: string | undefined;
239269
let failedProjectIndex: number | undefined;
@@ -243,39 +273,74 @@ export async function provisionMemberDevelopmentEnvironments({
243273
failedProjectId = project.id;
244274
failedProjectIndex = index;
245275

246-
await createEnvironment({
276+
const { created } = await createDevelopmentEnvironmentForMember({
247277
organization,
248278
project,
249-
type: "DEVELOPMENT",
250-
// We set this true but no backfill (yet!?) so never used
251-
// for dev environments
252-
isBranchableEnvironment: true,
253279
member,
254280
maximumConcurrencyLimit,
255281
});
256282

257-
createdProjectIds.push(project.id);
283+
if (created) {
284+
createdProjectIds.push(project.id);
285+
}
258286
failedProjectId = undefined;
259287
failedProjectIndex = undefined;
260288
}
261289
} catch (error) {
262-
logger.error("acceptInvite: development environment creation failed after membership created", {
290+
const message =
291+
"provisionMemberDevelopmentEnvironments: development environment creation failed after membership created";
292+
const context = {
293+
source,
263294
inviteId,
264-
userId: user.id,
295+
userId: member.userId,
265296
organizationId: organization.id,
266297
orgMemberId: member.id,
267-
projectIds,
298+
requestedProjectIds,
268299
failedProjectId,
269300
failedProjectIndex,
270-
totalProjects: projectsNeedingEnvs.length,
301+
projectsNeedingEnvs: projectsNeedingEnvs.length,
271302
createdProjectIds,
272303
error:
273304
error instanceof Error
274305
? { name: error.name, message: error.message, stack: error.stack }
275306
: String(error),
276-
});
307+
};
308+
309+
if (source === "invite") {
310+
logger.error(message, context);
311+
} else {
312+
logger.warn(message, context);
313+
}
277314

278-
throw new Error(ENV_SETUP_INCOMPLETE);
315+
throw new DevEnvironmentProvisioningError(
316+
`Failed to create development environments for org member ${member.id}`,
317+
{
318+
source,
319+
organizationId: organization.id,
320+
orgMemberId: member.id,
321+
failedProjectId,
322+
createdProjectIds,
323+
},
324+
{ cause: error }
325+
);
326+
}
327+
}
328+
329+
/** Provisions inline and surfaces a failure to the joiner as a retryable message. */
330+
async function provisionInviteDevelopmentEnvironments(args: {
331+
inviteId: string;
332+
member: OrgMember;
333+
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
334+
projects: Pick<Project, "id">[];
335+
maximumConcurrencyLimit: number;
336+
}) {
337+
try {
338+
await provisionMemberDevelopmentEnvironments({ source: "invite", ...args });
339+
} catch (error) {
340+
if (error instanceof DevEnvironmentProvisioningError) {
341+
throw new Error(ENV_SETUP_INCOMPLETE, { cause: error });
342+
}
343+
throw error;
279344
}
280345
}
281346

@@ -375,9 +440,8 @@ async function tryRecoverIncompleteInviteAccept({
375440
"DEVELOPMENT"
376441
);
377442

378-
await provisionMemberDevelopmentEnvironments({
443+
await provisionInviteDevelopmentEnvironments({
379444
inviteId,
380-
user,
381445
member,
382446
organization: member.organization,
383447
projects: missingProjects,
@@ -484,9 +548,8 @@ export async function acceptInvite({
484548
}
485549
}
486550

487-
await provisionMemberDevelopmentEnvironments({
551+
await provisionInviteDevelopmentEnvironments({
488552
inviteId,
489-
user,
490553
member,
491554
organization: invite.organization,
492555
projects: invite.organization.projects,

apps/webapp/app/models/orgMember.server.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Prisma, prisma } from "~/db.server";
2+
import type { MembershipSource } from "~/models/member.server";
23
import { logger } from "~/services/logger.server";
4+
import { enqueueMemberDevelopmentEnvironments } from "~/services/memberDevEnvironments.server";
35
import { rbac } from "~/services/rbac.server";
46
import {
57
getValidPersonalAccessTokens,
@@ -13,10 +15,15 @@ export type EnsureOrgMemberParams = {
1315
// value is an RBAC role id; when an RBAC plugin is installed it gets
1416
// attached after the OrgMember row is created.
1517
roleId: string | null;
16-
source: "sso_jit" | "invite" | "manual" | "directory_sync";
18+
source: MembershipSource;
1719
};
1820

19-
export type EnsureOrgMemberResult = { created: boolean; orgMemberId: string };
21+
export type EnsureOrgMemberResult = {
22+
created: boolean;
23+
orgMemberId: string;
24+
/** False when provisioning could not be queued; the membership is still valid. */
25+
devEnvironmentsQueued: boolean;
26+
};
2027

2128
// Completes a JIT role assignment for an ALREADY-existing membership whose
2229
// RBAC role never got applied. This is a no-op when a role is already
@@ -82,7 +89,12 @@ export async function ensureOrgMember(
8289
if (roleId !== null) {
8390
await healMissingRoleAssignment({ userId, organizationId, roleId, source });
8491
}
85-
return { created: false, orgMemberId: existing.id };
92+
const { enqueued } = await enqueueMemberDevelopmentEnvironments({
93+
userId,
94+
organizationId,
95+
source,
96+
});
97+
return { created: false, orgMemberId: existing.id, devEnvironmentsQueued: enqueued };
8698
}
8799

88100
// Two concurrent JIT/invite flows can both miss the findFirst above and
@@ -106,7 +118,16 @@ export async function ensureOrgMember(
106118
select: { id: true },
107119
});
108120
if (existingAfterConflict) {
109-
return { created: false, orgMemberId: existingAfterConflict.id };
121+
const { enqueued } = await enqueueMemberDevelopmentEnvironments({
122+
userId,
123+
organizationId,
124+
source,
125+
});
126+
return {
127+
created: false,
128+
orgMemberId: existingAfterConflict.id,
129+
devEnvironmentsQueued: enqueued,
130+
};
110131
}
111132
}
112133
throw error;
@@ -134,7 +155,13 @@ export async function ensureOrgMember(
134155
}
135156
}
136157

137-
return { created: true, orgMemberId: member.id };
158+
const { enqueued } = await enqueueMemberDevelopmentEnvironments({
159+
userId,
160+
organizationId,
161+
source,
162+
});
163+
164+
return { created: true, orgMemberId: member.id, devEnvironmentsQueued: enqueued };
138165
}
139166

140167
// Find-or-create a User for a directory-provisioned member. Directory Sync

apps/webapp/app/models/organization.server.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import { tryCatch } from "@trigger.dev/core/utils";
1010
import { customAlphabet } from "nanoid";
1111
import { generate } from "random-words";
1212
import slug from "slug";
13-
import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server";
13+
import {
14+
$replica,
15+
Prisma as PrismaNamespace,
16+
prisma,
17+
type PrismaClientOrTransaction,
18+
} from "~/db.server";
1419
import { env } from "~/env.server";
1520
import { featuresForUrl } from "~/features.server";
1621
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
@@ -224,6 +229,83 @@ export async function createEnvironment({
224229
return environment;
225230
}
226231

232+
/**
233+
* A member's root development environment for a project, never a branch under
234+
* it. Not keyed on slug, so a legacy root with another slug still matches.
235+
*/
236+
export function memberDevelopmentEnvironmentWhere({
237+
projectId,
238+
orgMemberId,
239+
}: {
240+
projectId?: string | { in: string[] };
241+
orgMemberId: string;
242+
}): Prisma.RuntimeEnvironmentWhereInput {
243+
return {
244+
...(projectId === undefined ? {} : { projectId }),
245+
orgMemberId,
246+
type: "DEVELOPMENT",
247+
parentEnvironmentId: null,
248+
};
249+
}
250+
251+
/**
252+
* Create a member's development environment, reporting `created: false` when a
253+
* concurrent writer already made it. Any other conflict still throws.
254+
*
255+
* Not transaction-aware: a unique violation aborts an enclosing transaction, so
256+
* the read that confirms the concurrent row has to run outside one.
257+
*/
258+
export async function createDevelopmentEnvironmentForMember({
259+
organization,
260+
project,
261+
member,
262+
maximumConcurrencyLimit,
263+
}: {
264+
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
265+
project: Pick<Project, "id">;
266+
member: OrgMember;
267+
maximumConcurrencyLimit?: number;
268+
}): Promise<{ created: boolean }> {
269+
try {
270+
await createEnvironment({
271+
organization,
272+
project,
273+
type: "DEVELOPMENT",
274+
isBranchableEnvironment: true,
275+
member,
276+
maximumConcurrencyLimit,
277+
});
278+
return { created: true };
279+
} catch (error) {
280+
if (
281+
!(error instanceof PrismaNamespace.PrismaClientKnownRequestError) ||
282+
error.code !== "P2002"
283+
) {
284+
throw error;
285+
}
286+
287+
const existing = await prisma.runtimeEnvironment.findFirst({
288+
where: memberDevelopmentEnvironmentWhere({
289+
projectId: project.id,
290+
orgMemberId: member.id,
291+
}),
292+
select: { id: true },
293+
});
294+
295+
if (!existing) {
296+
throw error;
297+
}
298+
299+
logger.debug("Development environment already created by a concurrent writer", {
300+
organizationId: organization.id,
301+
projectId: project.id,
302+
orgMemberId: member.id,
303+
});
304+
305+
return { created: false };
306+
}
307+
}
308+
227309
function createShortcode() {
228310
return generate({ exactly: 2 });
229311
}

0 commit comments

Comments
 (0)