mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
fix(invitations): break outbox import cycle (#6969)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user