Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
726760d
fix(onboarding): fix org creation timeout and improve error handling
tofikwest Apr 10, 2026
a9cb9c5
fix(onboarding): don't delete org after session activation succeeds
tofikwest Apr 10, 2026
81425cb
Merge branch 'main' into fix/onboarding-org-creation-timeout
tofikwest Apr 10, 2026
8e53a10
fix(onboarding): disable Complete button while server action is running
tofikwest Apr 10, 2026
7d990c2
feat(onboarding): add cancel button to abandon onboarding and return …
tofikwest Apr 10, 2026
b1dec0e
fix(onboarding): harden cancel action — guard completed orgs, switch …
tofikwest Apr 10, 2026
14a35df
fix(onboarding): sanitize error messages shown to users
tofikwest Apr 10, 2026
bca9525
Merge branch 'main' into fix/onboarding-org-creation-timeout
tofikwest Apr 10, 2026
56ba6ec
chore: merge release v3.20.2 back to main [skip ci]
github-actions[bot] Apr 10, 2026
03452e3
fix(onboarding): require fallback org before allowing cancel
tofikwest Apr 10, 2026
9b884f0
fix(onboarding): rollback active org switch if delete fails
tofikwest Apr 10, 2026
80db5d9
feat: add List-Unsubscribe headers and throttle email sends (#2507)
claudfuen Apr 10, 2026
9d02143
Merge branch 'main' into fix/onboarding-org-creation-timeout
tofikwest Apr 10, 2026
b165a18
fix: use barrel import for email package (Trigger build fix)
claudfuen Apr 10, 2026
887dfa9
fix(onboarding): hide cancel button while onboarding submission is in…
tofikwest Apr 10, 2026
3f530b7
Merge pull request #2511 from trycompai/fix/email-unsubscribe-import
tofikwest Apr 10, 2026
438d371
Merge branch 'main' into fix/onboarding-org-creation-timeout
tofikwest Apr 10, 2026
0ec7eed
Merge pull request #2503 from trycompai/fix/onboarding-org-creation-t…
tofikwest Apr 10, 2026
082501f
fix(onboarding): add initialize-organization trigger task and recover…
Marfuen Apr 10, 2026
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
7 changes: 6 additions & 1 deletion apps/api/prisma/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient {
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
const adapter = new PrismaPg({ connectionString: url, ssl });
return new PrismaClient({ adapter });
return new PrismaClient({
adapter,
transactionOptions: {
timeout: 30000,
},
});
}

export const db = globalForPrisma.prisma || createPrismaClient();
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/email/email.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { EmailController } from './email.controller';
import { UnsubscribeController } from './unsubscribe.controller';

@Module({
imports: [AuthModule],
controllers: [EmailController],
controllers: [EmailController, UnsubscribeController],
})
export class EmailModule {}
71 changes: 71 additions & 0 deletions apps/api/src/email/unsubscribe.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Controller, Post, Body, Query, HttpCode, BadRequestException } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { db } from '@db';
import { generateUnsubscribeToken } from '@trycompai/email';
import { timingSafeEqual } from 'node:crypto';

@ApiTags('Email - Unsubscribe')
@Controller({ path: 'email/unsubscribe', version: '1' })
export class UnsubscribeController {
/**
* RFC 8058 one-click unsubscribe endpoint.
* Gmail POSTs to this URL with List-Unsubscribe=One-Click in the body.
* Email and token come via query params in the URL.
*/
@Post()
@HttpCode(200)
@ApiOperation({ summary: 'One-click unsubscribe (RFC 8058)' })
async unsubscribe(
@Query('email') queryEmail?: string,
@Query('token') queryToken?: string,
@Body() body?: { email?: string; token?: string },
) {
// Coerce to string - query params can be arrays if repeated
const rawEmail = queryEmail || body?.email;
const rawToken = queryToken || body?.token;
const email = typeof rawEmail === 'string' ? rawEmail : undefined;
const token = typeof rawToken === 'string' ? rawToken : undefined;

if (!email || !token) {
throw new BadRequestException('Email and token are required');
}

// Verify HMAC token (timing-safe comparison)
const expectedToken = generateUnsubscribeToken(email);
const tokensMatch =
expectedToken.length === token.length &&
timingSafeEqual(Buffer.from(expectedToken), Buffer.from(token));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

String length guard doesn't prevent timingSafeEqual buffer mismatch

Low Severity

The guard expectedToken.length === token.length compares JavaScript string lengths, but timingSafeEqual compares buffer byte lengths. If an attacker submits a token containing multi-byte UTF-8 characters whose JS string length matches the expected token's length (43 chars for SHA-256 base64url), the string-length check passes, but Buffer.from(token) produces more bytes than Buffer.from(expectedToken), causing timingSafeEqual to throw an unhandled RangeError. This results in a 500 instead of a 400.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 80db5d9. Configure here.

if (!tokensMatch) {
throw new BadRequestException('Invalid token');
}

// Unsubscribe the user from all email notifications
const user = await db.user.findUnique({
where: { email },
select: { id: true },
});

if (!user) {
// Don't reveal user existence - just return success
return { success: true };
}

await db.user.update({
where: { id: user.id },
data: {
emailNotificationsUnsubscribed: true,
emailPreferences: {
policyNotifications: false,
taskReminders: false,
weeklyTaskDigest: false,
unassignedItemsNotifications: false,
taskMentions: false,
taskAssignments: false,
findingNotifications: false,
},
},
});

return { success: true };
}
}
16 changes: 15 additions & 1 deletion apps/api/src/trigger/email/send-email.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { logger, queue, schemaTask } from '@trigger.dev/sdk';
import { z } from 'zod';
import { resend } from '../../email/resend';
import { generateUnsubscribeToken } from '@trycompai/email';

const emailQueue = queue({
name: 'send-email',
concurrencyLimit: 30,
concurrencyLimit: 10,
});

export const sendEmailTask = schemaTask({
Expand Down Expand Up @@ -51,12 +52,22 @@ export const sendEmailTask = schemaTask({
}

try {
// Build List-Unsubscribe headers for Gmail/RFC 8058 one-click compliance
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai';
const token = generateUnsubscribeToken(params.to);
const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(params.to)}&token=${encodeURIComponent(token)}`;
const headers: Record<string, string> = {
'List-Unsubscribe': `<${oneClickUrl}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
};

const { data, error } = await resend.emails.send({
from: fromAddress,
to: toAddress,
cc: params.cc,
subject: params.subject,
html: params.html,
headers,
scheduledAt: params.scheduledAt,
attachments: params.attachments?.map((att) => ({
filename: att.filename,
Expand All @@ -76,6 +87,9 @@ export const sendEmailTask = schemaTask({

logger.info('Email sent', { to: params.to, id: data?.id });

// Throttle: hold the concurrency slot for 1s to space out sends
await new Promise((r) => setTimeout(r, 1000));

return { id: data?.id };
} catch (error) {
logger.error('Email sending failed', {
Expand Down
7 changes: 6 additions & 1 deletion apps/app/prisma/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient {
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
const adapter = new PrismaPg({ connectionString: url, ssl });
return new PrismaClient({ adapter });
return new PrismaClient({
adapter,
transactionOptions: {
timeout: 30000,
},
});
}

export const db = globalForPrisma.prisma || createPrismaClient();
Expand Down
52 changes: 52 additions & 0 deletions apps/app/src/actions/organization/lib/resolve-framework-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { db } from '@db/server';

/**
* Resolves framework IDs for an organization by:
* 1. Checking for a raw frameworkIds context entry (JSON array, saved by newer code)
* 2. Falling back to reverse-looking framework names from the onboarding context
*/
export async function resolveFrameworkIds(organizationId: string): Promise<string[]> {
// Try the raw IDs context entry first (saved by newer createOrganizationMinimal)
const rawIdsContext = await db.context.findFirst({
where: {
organizationId,
question: 'frameworkIds',
tags: { has: 'onboarding' },
},
});

if (rawIdsContext?.answer) {
try {
const ids = JSON.parse(rawIdsContext.answer);
if (Array.isArray(ids) && ids.length > 0) {
return ids;
}
} catch {
// Fall through to name-based lookup
}
}

// Fall back to reverse-looking from framework names
const frameworkContext = await db.context.findFirst({
where: {
organizationId,
question: 'Which compliance frameworks do you need?',
tags: { has: 'onboarding' },
},
});

if (!frameworkContext?.answer) {
return [];
}

const frameworkNames = frameworkContext.answer.split(',').map((name) => name.trim());

const frameworks = await db.frameworkEditorFramework.findMany({
where: {
name: { in: frameworkNames, mode: 'insensitive' },
},
select: { id: true },
});

return frameworks.map((f) => f.id);
}
11 changes: 11 additions & 0 deletions apps/app/src/app/(app)/onboarding/[orgId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,23 @@ export default async function OnboardingPage({ params }: OnboardingPageProps) {
});
}

// Check if user has other completed orgs (for cancel button)
const otherOrgCount = await db.member.count({
where: {
userId: session.user.id,
organizationId: { not: orgId },
deactivated: false,
organization: { onboardingCompleted: true, hasAccess: true },
},
});

// We'll use a modified version that starts at step 3
return (
<PostPaymentOnboarding
organization={organization}
initialData={initialData}
userEmail={session.user.email}
hasOtherOrgs={otherOrgCount > 0}
/>
);
}
106 changes: 106 additions & 0 deletions apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use server';

import { authActionClientWithoutOrg } from '@/actions/safe-action';
import { auth } from '@/utils/auth';
import { db } from '@db/server';
import { headers } from 'next/headers';
import { z } from 'zod';

const cancelSchema = z.object({
organizationId: z.string().min(1),
});

export const cancelOnboarding = authActionClientWithoutOrg
.inputSchema(cancelSchema)
.metadata({
name: 'cancel-onboarding',
track: {
event: 'cancel-onboarding',
channel: 'server',
},
})
.action(async ({ parsedInput, ctx }) => {
const session = await auth.api.getSession({
headers: await headers(),
});

if (!session) {
return { success: false, error: 'Not authorized.' };
}

// Verify the user owns this org and it's still incomplete
const member = await db.member.findFirst({
where: {
userId: session.user.id,
organizationId: parsedInput.organizationId,
role: { contains: 'owner' },
},
include: { organization: { select: { onboardingCompleted: true } } },
});

if (!member) {
return { success: false, error: 'Only the owner can cancel onboarding.' };
}

if (member.organization.onboardingCompleted) {
return { success: false, error: 'Cannot cancel a completed organization.' };
}

// Find a fallback org to switch to BEFORE deleting
const fallbackOrg = await db.member.findFirst({
where: {
userId: session.user.id,
organizationId: { not: parsedInput.organizationId },
deactivated: false,
organization: {
onboardingCompleted: true,
hasAccess: true,
},
},
select: { organizationId: true },
orderBy: { createdAt: 'desc' },
});

// Must have a fallback org — refuse to delete if there's nowhere to go.
// The UI guards this too, but a race condition could remove fallback orgs
// between page render and action execution.
if (!fallbackOrg) {
return { success: false, error: 'No other organization to switch to.' };
}

// Switch active org BEFORE deletion so the session never
// references a deleted org (even if the client redirect is slow).
try {
await auth.api.setActiveOrganization({
headers: await headers(),
body: { organizationId: fallbackOrg.organizationId },
});
} catch (error) {
console.error('Failed to switch to fallback org:', error);
return { success: false, error: 'Failed to switch organization.' };
}

// Delete the incomplete org (cascade handles related records).
// If this fails, roll back the active org switch to keep state consistent.
try {
await db.organization.delete({
where: { id: parsedInput.organizationId },
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancel action can delete completed org via race

Medium Severity

The onboardingCompleted check at line 45 and the db.organization.delete at line 86 are not atomic. If a concurrent request (e.g., a background job or another browser tab) sets onboardingCompleted: true between the check and the delete, a fully completed organization with all its production data gets cascade-deleted. The delete WHERE clause only uses id and does not re-verify onboardingCompleted: false.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0ec7eed. Configure here.

} catch (error) {
console.error('Failed to delete organization:', error);
try {
await auth.api.setActiveOrganization({
headers: await headers(),
body: { organizationId: parsedInput.organizationId },
});
} catch (rollbackError) {
console.error('Failed to rollback active org switch:', rollbackError);
}
return { success: false, error: 'Failed to cancel onboarding.' };
}

return {
success: true,
fallbackOrgId: fallbackOrg?.organizationId ?? null,
};
});
40 changes: 40 additions & 0 deletions apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use server';

import { initializeOrganization } from '@/actions/organization/lib/initialize-organization';
import { resolveFrameworkIds } from '@/actions/organization/lib/resolve-framework-ids';
import { authActionClientWithoutOrg } from '@/actions/safe-action';
import { steps } from '@/app/(app)/setup/lib/constants';
import { createFleetLabelForOrg } from '@/trigger/tasks/device/create-fleet-label-for-org';
Expand Down Expand Up @@ -155,6 +157,43 @@ export const completeOnboarding = authActionClientWithoutOrg
data: { onboardingCompleted: true },
});

// Ensure framework structure exists before triggering the onboard job.
// If createOrganizationMinimal partially failed (org created but
// initializeOrganization didn't run), recover by initializing now.
const existingFrameworks = await db.frameworkInstance.findFirst({
where: { organizationId: parsedInput.organizationId },
});

if (!existingFrameworks) {
console.warn(
`[complete-onboarding] No framework instances found for org ${parsedInput.organizationId}, running initializeOrganization as recovery`,
);

const frameworkIds = await resolveFrameworkIds(parsedInput.organizationId);

if (frameworkIds.length > 0) {
await initializeOrganization({
frameworkIds,
organizationId: parsedInput.organizationId,
});
} else {
console.error(
`[complete-onboarding] Could not resolve framework IDs for org ${parsedInput.organizationId}`,
);
}
}

// Ensure onboarding record exists (may be missing if createOrganizationMinimal
// failed before creating it).
await db.onboarding.upsert({
where: { organizationId: parsedInput.organizationId },
create: {
organizationId: parsedInput.organizationId,
triggerJobCompleted: false,
},
update: {},
});

// Now trigger the jobs that were skipped during minimal creation
const handle = await tasks.trigger<typeof onboardOrganizationTask>('onboard-organization', {
organizationId: parsedInput.organizationId,
Expand Down Expand Up @@ -208,3 +247,4 @@ export const completeOnboarding = authActionClientWithoutOrg
};
}
});

Loading
Loading