diff --git a/apps/sim/lib/admin/invitation-operation.ts b/apps/sim/lib/admin/invitation-operation.ts index d6356701308..3b5b3c6fbba 100644 --- a/apps/sim/lib/admin/invitation-operation.ts +++ b/apps/sim/lib/admin/invitation-operation.ts @@ -18,7 +18,7 @@ import { import { DIRECT_GRANT_EMAIL_EVENT_TYPE, type DirectGrantEmailPayload, -} from '@/lib/invitations/direct-grant' +} from '@/lib/invitations/direct-grant-event' import { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits' export const ADMIN_INVITATION_OPERATION_EVENT_TYPE = 'admin.organization-invitation-operation' diff --git a/apps/sim/lib/billing/enterprise-provisioning.ts b/apps/sim/lib/billing/enterprise-provisioning.ts index b0eb8d078f6..08431d3a6a0 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.ts @@ -86,7 +86,7 @@ import { outboxEventHasSourceOperationId, } from '@/lib/core/outbox/service' import type { DbOrTx } from '@/lib/db/types' -import { DIRECT_GRANT_EMAIL_EVENT_TYPE } from '@/lib/invitations/direct-grant' +import { DIRECT_GRANT_EMAIL_EVENT_TYPE } from '@/lib/invitations/direct-grant-event' import { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits' import { sendInvitationEmail } from '@/lib/invitations/send' import { diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index bb36babdcca..40b2026a0ca 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -18,7 +18,6 @@ import { isOrgAdminRole, PERMISSION_RANK, type PermissionType } from '@sim/platf import { generateId } from '@sim/utils/id' import { normalizeEmail } from '@sim/utils/string' import { and, asc, count, eq, inArray, lte, sql } from 'drizzle-orm' -import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy' import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan' @@ -1435,6 +1434,9 @@ async function runInvitationAcceptancePostCommitEffects( if (effects.organizationId) { try { + const { setActiveOrganizationForCurrentSession } = await import( + '@/lib/auth/active-organization' + ) await setActiveOrganizationForCurrentSession(effects.organizationId) } catch (activeOrgError) { logger.error('Failed to activate organization after accepting invitation', { diff --git a/apps/sim/lib/invitations/direct-grant-event.ts b/apps/sim/lib/invitations/direct-grant-event.ts new file mode 100644 index 00000000000..f2918f2f49c --- /dev/null +++ b/apps/sim/lib/invitations/direct-grant-event.ts @@ -0,0 +1,9 @@ +export const DIRECT_GRANT_EMAIL_EVENT_TYPE = 'invitation.send-workspace-added' + +export interface DirectGrantEmailPayload { + email: string + inviterName: string + workspaceId: string + workspaceName: string + sourceOperationId?: string +} diff --git a/apps/sim/lib/invitations/direct-grant.test.ts b/apps/sim/lib/invitations/direct-grant.test.ts index f83cb12b42c..e66b9f1a590 100644 --- a/apps/sim/lib/invitations/direct-grant.test.ts +++ b/apps/sim/lib/invitations/direct-grant.test.ts @@ -78,11 +78,11 @@ vi.mock('@/lib/posthog/server', () => ({ })) import { - DIRECT_GRANT_EMAIL_EVENT_TYPE, DirectGrantContextChangedError, directGrantOutboxHandlers, grantWorkspaceAccessDirectly, } from '@/lib/invitations/direct-grant' +import { DIRECT_GRANT_EMAIL_EVENT_TYPE } from '@/lib/invitations/direct-grant-event' const baseInput = { userId: 'user-2', diff --git a/apps/sim/lib/invitations/direct-grant.ts b/apps/sim/lib/invitations/direct-grant.ts index 4ec3184ac3c..e18b6b19e21 100644 --- a/apps/sim/lib/invitations/direct-grant.ts +++ b/apps/sim/lib/invitations/direct-grant.ts @@ -23,6 +23,10 @@ import { PlatformEvents } from '@/lib/core/telemetry' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import type { DbOrTx } from '@/lib/db/types' import { revokeInvitationWorkspaceGrantTx } from '@/lib/invitations/core' +import { + DIRECT_GRANT_EMAIL_EVENT_TYPE, + type DirectGrantEmailPayload, +} from '@/lib/invitations/direct-grant-event' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' import { sendWorkspaceAddedEmail } from '@/lib/invitations/send' import { captureServerEvent } from '@/lib/posthog/server' @@ -34,16 +38,6 @@ import { const logger = createLogger('InvitationDirectGrant') -export const DIRECT_GRANT_EMAIL_EVENT_TYPE = 'invitation.send-workspace-added' - -export interface DirectGrantEmailPayload { - email: string - inviterName: string - workspaceId: string - workspaceName: string - sourceOperationId?: string -} - export type DirectGrantOutcome = | { outcome: 'added'; permission: PermissionType } | { outcome: 'updated'; permission: PermissionType; previousPermission: PermissionType } diff --git a/apps/sim/lib/invitations/import-boundary.test.ts b/apps/sim/lib/invitations/import-boundary.test.ts new file mode 100644 index 00000000000..2ee329218cb --- /dev/null +++ b/apps/sim/lib/invitations/import-boundary.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment node + */ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const APP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const DIRECT_GRANT_ENTRY = join(APP_DIR, 'lib/invitations/direct-grant.ts') +const DIRECT_GRANT_EVENT = join(APP_DIR, 'lib/invitations/direct-grant-event.ts') +const AUTH_ENTRY = join(APP_DIR, 'lib/auth/auth.ts') +const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] as const +const IMPORT_PATTERN = /(?:^|\n)\s*import\s+(?!type\b)(?:[\s\S]*?from\s*)?['"]([^'"]+)['"]/g +const REEXPORT_PATTERN = + /(?:^|\n)\s*export\s+(?!type\b)(?:\*(?:\s+as\s+[\w$]+)?|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g + +function resolveStaticSpecifier(specifier: string, importer: string): string | null { + let base: string + if (specifier.startsWith('@/')) base = join(APP_DIR, specifier.slice(2)) + else if (specifier.startsWith('.')) base = resolve(dirname(importer), specifier) + else return null + + if (existsSync(base) && statSync(base).isFile()) return base + for (const extension of EXTENSIONS) { + if (existsSync(base + extension)) return base + extension + } + if (existsSync(base) && statSync(base).isDirectory()) { + for (const extension of EXTENSIONS) { + const indexPath = join(base, `index${extension}`) + if (existsSync(indexPath)) return indexPath + } + } + return null +} + +function getStaticDependencies(file: string): string[] { + const source = readFileSync(file, 'utf8') + const dependencies = new Set() + + for (const pattern of [IMPORT_PATTERN, REEXPORT_PATTERN]) { + pattern.lastIndex = 0 + let match = pattern.exec(source) + while (match !== null) { + const resolved = resolveStaticSpecifier(match[1], file) + if (resolved) dependencies.add(resolved) + match = pattern.exec(source) + } + } + + return [...dependencies] +} + +function findStaticPath(entry: string, target: string): string[] { + const importedBy = new Map([[entry, null]]) + const queue = [entry] + + while (queue.length > 0) { + const file = queue.shift() as string + if (file === target) { + const path: string[] = [] + let cursor: string | null = file + while (cursor) { + path.unshift(relative(APP_DIR, cursor)) + cursor = importedBy.get(cursor) ?? null + } + return path + } + + for (const dependency of getStaticDependencies(file)) { + if (!importedBy.has(dependency)) { + importedBy.set(dependency, file) + queue.push(dependency) + } + } + } + + return [] +} + +describe('invitation import boundaries', () => { + it('keeps direct grants out of the auth initialization graph', () => { + const path = findStaticPath(DIRECT_GRANT_ENTRY, AUTH_ENTRY) + expect(path, `Unexpected static import path:\n${path.join('\n -> ')}`).toEqual([]) + }) + + it('keeps the direct-grant event contract dependency-free', () => { + expect(getStaticDependencies(DIRECT_GRANT_EVENT)).toEqual([]) + }) +})