|
| 1 | +import type { DelegatedPrincipal } from '@sim/auth/principal' |
| 2 | +import { |
| 3 | + type CopilotDelegationConfiguration, |
| 4 | + type CopilotExecutionContext, |
| 5 | + type CopilotResourceScope, |
| 6 | + createCopilotApplicationPrincipal, |
| 7 | + requireTrustedCopilotExecutionContext, |
| 8 | + type TrustedCopilotExecutionContext, |
| 9 | +} from '@/lib/copilot/auth/application-delegation' |
| 10 | +import type { OperationUseCase, WorkspaceOperation } from '@/lib/core/application' |
| 11 | + |
| 12 | +type CopilotApplicationPrincipalFactory = (args: { |
| 13 | + context: TrustedCopilotExecutionContext |
| 14 | + resourceScope: CopilotResourceScope |
| 15 | +}) => DelegatedPrincipal |
| 16 | + |
| 17 | +interface CopilotApplicationAdapterOptions<O extends WorkspaceOperation, ScopeInput = undefined> { |
| 18 | + domain: string |
| 19 | + delegation: CopilotDelegationConfiguration |
| 20 | + operations: Readonly<Record<string, O>> |
| 21 | + projectResourceScope?( |
| 22 | + input: ScopeInput, |
| 23 | + context: TrustedCopilotExecutionContext |
| 24 | + ): CopilotResourceScope |
| 25 | + createPrincipal?: CopilotApplicationPrincipalFactory |
| 26 | +} |
| 27 | + |
| 28 | +type ScopeArguments<ScopeInput> = [ScopeInput] extends [undefined] ? [] : [scope: ScopeInput] |
| 29 | + |
| 30 | +const RESOURCE_SCOPE_KEYS = ['fileId', 'tableId', 'chatId', 'executionId'] as const |
| 31 | + |
| 32 | +function requireValidProjectedResourceScope(resourceScope: CopilotResourceScope): void { |
| 33 | + if (resourceScope.fileId !== undefined && !resourceScope.fileId.trim()) { |
| 34 | + throw new Error('Copilot application resource scope contains an invalid file ID') |
| 35 | + } |
| 36 | + if (resourceScope.tableId !== undefined && !resourceScope.tableId.trim()) { |
| 37 | + throw new Error('Copilot application resource scope contains an invalid table ID') |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +function expectedResourceScope( |
| 42 | + context: TrustedCopilotExecutionContext, |
| 43 | + resourceScope: CopilotResourceScope |
| 44 | +): NonNullable<DelegatedPrincipal['resourceScope']> { |
| 45 | + return { |
| 46 | + ...resourceScope, |
| 47 | + ...(context.chatId ? { chatId: context.chatId } : {}), |
| 48 | + ...(context.executionId ? { executionId: context.executionId } : {}), |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +function requireMatchingPrincipal( |
| 53 | + principal: DelegatedPrincipal, |
| 54 | + context: TrustedCopilotExecutionContext, |
| 55 | + delegation: CopilotDelegationConfiguration, |
| 56 | + resourceScope: CopilotResourceScope |
| 57 | +): void { |
| 58 | + const delegationId = delegation.createDelegationId(context) |
| 59 | + if ( |
| 60 | + principal.kind !== 'delegated' || |
| 61 | + principal.serviceId !== delegation.serviceId || |
| 62 | + principal.subjectUserId !== context.userId || |
| 63 | + principal.workspaceId !== context.workspaceId || |
| 64 | + !delegationId.trim() || |
| 65 | + principal.delegationId !== delegationId || |
| 66 | + principal.audience !== delegation.audience |
| 67 | + ) { |
| 68 | + throw new Error('Copilot principal factory violated the configured delegation identity') |
| 69 | + } |
| 70 | + |
| 71 | + const issuedAt = principal.issuedAt.getTime() |
| 72 | + const expiresAt = principal.expiresAt.getTime() |
| 73 | + if ( |
| 74 | + !Number.isFinite(issuedAt) || |
| 75 | + !Number.isFinite(expiresAt) || |
| 76 | + issuedAt > Date.now() || |
| 77 | + expiresAt <= Date.now() || |
| 78 | + expiresAt - issuedAt !== delegation.ttlMs |
| 79 | + ) { |
| 80 | + throw new Error('Copilot principal factory violated the configured delegation expiry') |
| 81 | + } |
| 82 | + |
| 83 | + const expectedScope = expectedResourceScope(context, resourceScope) |
| 84 | + if (RESOURCE_SCOPE_KEYS.some((key) => principal.resourceScope?.[key] !== expectedScope[key])) { |
| 85 | + throw new Error('Copilot principal factory violated the configured resource scope') |
| 86 | + } |
| 87 | +} |
| 88 | + |
| 89 | +/** Adapts trusted Copilot calls to a domain's existing application use cases. */ |
| 90 | +export function createCopilotApplicationAdapter< |
| 91 | + O extends WorkspaceOperation, |
| 92 | + ScopeInput = undefined, |
| 93 | +>(options: CopilotApplicationAdapterOptions<O, ScopeInput>) { |
| 94 | + if (!options.domain.trim()) throw new Error('Copilot application executor requires a domain') |
| 95 | + if (options.delegation.serviceId !== 'copilot') { |
| 96 | + throw new Error('Copilot application executor requires the Copilot service identity') |
| 97 | + } |
| 98 | + if (!options.delegation.audience.trim()) { |
| 99 | + throw new Error('Copilot application executor requires a delegation audience') |
| 100 | + } |
| 101 | + if (!Number.isInteger(options.delegation.ttlMs) || options.delegation.ttlMs <= 0) { |
| 102 | + throw new Error('Copilot application executor requires a positive integer delegation TTL') |
| 103 | + } |
| 104 | + |
| 105 | + const operations = Object.values(options.operations) |
| 106 | + if (operations.length === 0) { |
| 107 | + throw new Error(`Copilot ${options.domain} operation registry cannot be empty`) |
| 108 | + } |
| 109 | + const operationIds = new Set<string>() |
| 110 | + for (const operation of operations) { |
| 111 | + if (!Object.isFrozen(operation)) { |
| 112 | + throw new Error(`Copilot ${options.domain} operation ${operation.id} must be immutable`) |
| 113 | + } |
| 114 | + if (operationIds.has(operation.id)) { |
| 115 | + throw new Error(`Copilot ${options.domain} operation registry contains duplicate IDs`) |
| 116 | + } |
| 117 | + operationIds.add(operation.id) |
| 118 | + } |
| 119 | + const registeredOperations = new Set<O>(operations) |
| 120 | + |
| 121 | + return function executeCopilotApplicationUseCase<Selected extends O, I, R>( |
| 122 | + context: CopilotExecutionContext | undefined, |
| 123 | + useCase: OperationUseCase<Selected, I, R>, |
| 124 | + input: I, |
| 125 | + ...scopeArguments: ScopeArguments<ScopeInput> |
| 126 | + ): Promise<R> { |
| 127 | + if (!registeredOperations.has(useCase.operation)) { |
| 128 | + throw new Error(`Unregistered Copilot ${options.domain} operation: ${useCase.operation.id}`) |
| 129 | + } |
| 130 | + |
| 131 | + const trustedContext = requireTrustedCopilotExecutionContext(context) |
| 132 | + let resourceScope: CopilotResourceScope = {} |
| 133 | + if (options.projectResourceScope) { |
| 134 | + if (scopeArguments.length !== 1) { |
| 135 | + throw new Error(`Copilot ${options.domain} execution requires trusted scope input`) |
| 136 | + } |
| 137 | + resourceScope = options.projectResourceScope(scopeArguments[0], trustedContext) |
| 138 | + } else if (scopeArguments.length !== 0) { |
| 139 | + throw new Error(`Copilot ${options.domain} execution does not accept resource scope input`) |
| 140 | + } |
| 141 | + requireValidProjectedResourceScope(resourceScope) |
| 142 | + |
| 143 | + const principal = options.createPrincipal |
| 144 | + ? options.createPrincipal({ context: trustedContext, resourceScope }) |
| 145 | + : createCopilotApplicationPrincipal(trustedContext, { |
| 146 | + ...options.delegation, |
| 147 | + resourceScope, |
| 148 | + }) |
| 149 | + requireMatchingPrincipal(principal, trustedContext, options.delegation, resourceScope) |
| 150 | + |
| 151 | + return useCase.execute({ principal, input }) |
| 152 | + } |
| 153 | +} |
0 commit comments