-
Notifications
You must be signed in to change notification settings - Fork 290
[comp] Production Deploy #2510
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
[comp] Production Deploy #2510
Changes from all commits
726760d
a9cb9c5
81425cb
8e53a10
7d990c2
b1dec0e
14a35df
bca9525
56ba6ec
03452e3
9b884f0
80db5d9
9d02143
b165a18
887dfa9
3f530b7
438d371
0ec7eed
082501f
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 |
|---|---|---|
| @@ -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 {} |
| 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)); | ||
| 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 }; | ||
| } | ||
| } | ||
| 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); | ||
| } |
| 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 }, | ||
| }); | ||
|
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. Cancel action can delete completed org via raceMedium Severity The 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, | ||
| }; | ||
| }); | ||


There was a problem hiding this comment.
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.lengthcompares JavaScript string lengths, buttimingSafeEqualcompares buffer byte lengths. If an attacker submits atokencontaining 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, butBuffer.from(token)produces more bytes thanBuffer.from(expectedToken), causingtimingSafeEqualto throw an unhandledRangeError. This results in a 500 instead of a 400.Reviewed by Cursor Bugbot for commit 80db5d9. Configure here.