fix(invitations): break outbox import cycle (#6969)

This commit is contained in:
Theodore Li
2026-08-21 20:14:34 -07:00
committed by GitHub
parent 8177f9abeb
commit a781a325c5
7 changed files with 109 additions and 14 deletions
+1 -1
View File
@@ -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'
@@ -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 {
+3 -1
View File
@@ -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', {
@@ -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
}
@@ -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',
+4 -10
View File
@@ -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 }
@@ -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<string>()
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<string, string | null>([[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([])
})
})